package api import ( "context" "encoding/json" "net/http" "sort" "strconv" "strings" "time" "github.com/ponzischeme89/memby/server/internal/adminevents" "github.com/ponzischeme89/memby/server/internal/scheduler" "github.com/ponzischeme89/memby/server/internal/store" ) // The Integrations area's API. // // One service is one row, and the row is assembled from three things the gateway already // holds: the catalogue (what this service is and where its switch lives), the scheduler // (what work belongs to it, when it last ran and when it is next due) and the run table // read along the integration axis (what that work actually did). Nothing here is stored // for the console's benefit. // // The status word is the gateway's, not the console's — the stance every label Memby // prints takes. A console that decided for itself what "unhealthy" meant would have an // older build disagreeing with a newer one about the same server, and the threshold and // the sentence explaining it belong together. // Integration status words, in the order they outrank each other. See integrationStatus. const ( integrationStatusUnconfigured = "unconfigured" integrationStatusDisabled = "disabled" integrationStatusRunning = "running" integrationStatusError = "error" integrationStatusHealthy = "healthy" integrationStatusIdle = "idle" ) // integrationView is one service as the console reads it. type integrationView struct { ID string `json:"id"` Name string `json:"name"` Summary string `json:"summary"` Address string `json:"address,omitempty"` Configured bool `json:"configured"` Enabled bool `json:"enabled"` // Status is one word and StatusLabel is what to print; Detail is the sentence behind // it — the failure reason, or what is running, or why there is nothing to say. Status string `json:"status"` StatusLabel string `json:"statusLabel"` Detail string `json:"detail,omitempty"` Running bool `json:"running"` // Powers is what stops working when this is switched off, so a dependency is stated // rather than discovered by something silently not happening. Powers []string `json:"powers"` LastRun *store.TaskRun `json:"lastRun,omitempty"` LastSuccessAt *time.Time `json:"lastSuccessAt,omitempty"` LastFailureAt *time.Time `json:"lastFailureAt,omitempty"` LastError string `json:"lastError,omitempty"` NextRun *time.Time `json:"nextRun,omitempty"` Runs int `json:"runs"` Failures int `json:"failures"` // Health is the last reachability probe, absent for a service that is not probed. Health *integrationHealth `json:"health,omitempty"` // Probed says whether this service can be probed at all, which is what stops the // console drawing "never checked" beside MDBList for ever. Probed bool `json:"probed"` Tasks []scheduler.Status `json:"tasks"` Facts []integrationFact `json:"facts,omitempty"` } // integrationStatus resolves one word per service, in priority order, because a status // column with two answers in it is one nobody can scan. // // Not configured outranks everything: a service with no address has no switch, no work and // no health, and drawing it as "off" would suggest turning it on is a thing an operator // could do from this page. Switched off comes next for the same reason a disabled task // does — yesterday's success beside a service nobody is running reads as one that is still // working. A run in flight outranks its own history, and below those the service is // described by whichever of its probe and its last run has something to say. Idle is the // honest answer for a configured, enabled service that has not yet done anything: it is // neither working nor broken, and claiming either would be a guess. func integrationStatus(view integrationView) (string, string, string) { switch { case !view.Configured: return integrationStatusUnconfigured, "Not configured", "No address or credential is set for this service." case !view.Enabled: return integrationStatusDisabled, "Disabled", "Switched off: Memby schedules no work for it and makes no requests to it." case view.Running: return integrationStatusRunning, "Running", runningDetail(view.Tasks) } if view.Health != nil && !view.Health.Reachable && !view.Health.CheckedAt.IsZero() { return integrationStatusError, "Error", view.Health.Error } // A failure that has been followed by a success is history, not a verdict. Without // that comparison one bad night would leave a service red until the retention job // eventually removed the row. if view.LastFailureAt != nil && (view.LastSuccessAt == nil || view.LastSuccessAt.Before(*view.LastFailureAt)) { return integrationStatusError, "Error", view.LastError } if view.Health != nil && view.Health.Reachable { return integrationStatusHealthy, "Healthy", "" } if view.LastSuccessAt != nil { return integrationStatusHealthy, "Healthy", "" } return integrationStatusIdle, "Idle", "Nothing has run for this service yet." } func runningDetail(tasks []scheduler.Status) string { for _, task := range tasks { if task.Running { return task.Name + " is running now." } } return "" } // integrationViews assembles every service. One pass over the catalogue, one scheduler // snapshot and one grouped query, whatever the catalogue's length. func (s *Server) integrationViews(ctx context.Context) []integrationView { summaries := map[string]store.IntegrationRunSummary{} if s.store != nil { if read, err := s.store.IntegrationRunSummaries(ctx); err == nil { summaries = read } else { s.loggerFor(ctx).Warn("integration run summaries unavailable", "error", err) } } tasksByIntegration := map[string][]scheduler.Status{} if s.scheduler != nil { for _, task := range s.scheduler.Snapshot() { if task.Integration == "" { continue } tasksByIntegration[task.Integration] = append( tasksByIntegration[task.Integration], task) } } views := make([]integrationView, 0, len(integrationCatalogue())) for _, definition := range integrationCatalogue() { view := integrationView{ ID: definition.ID, Name: definition.Name, Summary: definition.Summary, Powers: definition.Powers, Configured: definition.Configured(s), Probed: definition.Probe != nil, Tasks: tasksByIntegration[definition.ID], } if view.Tasks == nil { view.Tasks = []scheduler.Status{} } if view.Configured { view.Address = definition.Address(s) view.Enabled = definition.Enabled(s, ctx) } if summary, ok := summaries[definition.ID]; ok { view.LastSuccessAt = summary.LastSuccessAt view.LastFailureAt = summary.LastFailureAt view.LastError = summary.LastError view.Runs, view.Failures = summary.Runs, summary.Failures } // The most recent run and the next due one come from the scheduler rather than // from a query: it holds both, and the next run has no row anywhere to read. for _, task := range view.Tasks { if task.Running { view.Running = true } if task.LastRun != nil && (view.LastRun == nil || task.LastRun.StartedAt.After(view.LastRun.StartedAt)) { last := *task.LastRun view.LastRun = &last } if task.NextRun != nil && (view.NextRun == nil || task.NextRun.Before(*view.NextRun)) { next := *task.NextRun view.NextRun = &next } } // A switched-off service has no next run to promise, whatever the scheduler still // holds: its tasks stand down as soon as they start. if !view.Enabled { view.NextRun = nil } if state, ok := s.integrationHealth.get(definition.ID); ok { health := state view.Health = &health } view.Status, view.StatusLabel, view.Detail = integrationStatus(view) views = append(views, view) } // Anything wrong sorts to the top, the order the tasks table takes and for the same // reason: a page read from the top down should not need a sort to find the one thing // that is broken. sort.SliceStable(views, func(i, j int) bool { return integrationRank(views[i].Status) < integrationRank(views[j].Status) }) return views } func integrationRank(status string) int { switch status { case integrationStatusError: return 0 case integrationStatusRunning: return 1 case integrationStatusIdle: return 2 case integrationStatusHealthy: return 3 case integrationStatusDisabled: return 4 default: return 5 } } func (s *Server) handleAdminIntegrationServices(w http.ResponseWriter, r *http.Request) { views := s.integrationViews(r.Context()) runs, err := s.store.IntegrationRuns(r.Context(), "", integrationRunLimit(r)) if err != nil { s.loggerFor(r.Context()).Warn("integration runs unavailable", "error", err) runs = []store.TaskRun{} } writeJSON(w, http.StatusOK, map[string]any{ "services": views, // Every service's runs interleaved, which is the view that shows two integrations // getting in each other's way — the same reason the tasks page carries one. "runs": runs, }) } func (s *Server) handleAdminIntegrationService(w http.ResponseWriter, r *http.Request) { id := strings.TrimSpace(r.PathValue("integrationID")) definition, ok := integrationDefinitionFor(id) if !ok { writeError(w, http.StatusNotFound, "no such integration") return } var view integrationView for _, candidate := range s.integrationViews(r.Context()) { if candidate.ID == id { view = candidate break } } // Facts are read only on the detail page: several of them are database queries, and // the overview draws four services at a poll. if definition.Facts != nil && view.Configured { view.Facts = definition.Facts(s, r.Context()) } runs, err := s.store.IntegrationRuns(r.Context(), id, integrationRunLimit(r)) if err != nil { s.loggerFor(r.Context()).Warn("integration runs unavailable", "integration", id, "error", err) runs = []store.TaskRun{} } writeJSON(w, http.StatusOK, map[string]any{"service": view, "runs": runs}) } func integrationRunLimit(r *http.Request) int { limit, err := strconv.Atoi(strings.TrimSpace(r.URL.Query().Get("limit"))) if err != nil || limit <= 0 { return 50 } return limit } func (s *Server) handleAdminIntegrationEnabled(w http.ResponseWriter, r *http.Request) { id := strings.TrimSpace(r.PathValue("integrationID")) definition, ok := integrationDefinitionFor(id) if !ok { writeError(w, http.StatusNotFound, "no such integration") return } if !definition.Configured(s) { // A service with no address has nothing to switch, and recording a preference // about one would leave a stored decision nothing reads. writeError(w, http.StatusConflict, definition.Name+" is not configured on this gateway") return } var req struct { Enabled bool `json:"enabled"` } if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "malformed request body") return } if err := definition.SetEnabled(s, r.Context(), req.Enabled); err != nil { s.loggerFor(r.Context()).Error("could not change integration", "integration", id, "error", err) writeError(w, http.StatusInternalServerError, "could not save the integration setting") return } if !req.Enabled { // Its last health reading described a service Memby has stopped calling. Left in // place it would sit under a disabled row claiming a verdict nothing is renewing. s.integrationHealth.forget(id) } s.loggerFor(r.Context()).Info("integration switched", "integration", id, "enabled", req.Enabled) state := "disabled" if req.Enabled { state = "enabled" } s.publishAdmin(r.Context(), adminevents.Event{ Type: adminevents.TypeSettingsChanged, Severity: adminevents.SeverityInfo, Title: definition.Name + " " + state, Summary: integrationSwitchSummary(definition, req.Enabled), Target: id, Link: "/admin/integrations/" + id, Metadata: adminevents.Meta(map[string]any{"integration": id, "enabled": req.Enabled}), }) writeJSON(w, http.StatusOK, map[string]any{"services": s.integrationViews(r.Context())}) } // integrationSwitchSummary spells out the consequence rather than repeating the switch. // "Sonarr disabled" in an activity feed says nothing an operator did not just do; what // they may not have thought about is the calendar going with it. func integrationSwitchSummary(definition integrationDefinition, enabled bool) string { if enabled { return definition.Name + " is back on: its scheduled work resumes." } if len(definition.Powers) == 0 { return definition.Name + " will no longer be called." } return "Memby will stop calling " + definition.Name + ". This also stops: " + strings.Join(definition.Powers, "; ") + "." } // handleAdminIntegrationTest probes one service now. // // It takes the same path the scheduled probe does, deliberately: a test that asked a // different question could report a service as working while the row beside it stayed red. func (s *Server) handleAdminIntegrationTest(w http.ResponseWriter, r *http.Request) { id := strings.TrimSpace(r.PathValue("integrationID")) definition, ok := integrationDefinitionFor(id) if !ok { writeError(w, http.StatusNotFound, "no such integration") return } if !definition.Configured(s) { writeError(w, http.StatusConflict, definition.Name+" is not configured on this gateway") return } if definition.Probe == nil { // Stated rather than faked. MDBList is the case: a probe would spend a request of // an allowance bought by the day, and answering "healthy" without asking would be // the console making something up. writeError(w, http.StatusNotImplemented, definition.Name+" cannot be tested without spending part of its daily allowance") return } state := s.probeIntegration(r.Context(), definition) s.loggerFor(r.Context()).Info("integration probed", "integration", id, "reachable", state.Reachable, "duration_ms", state.LatencyMS) writeJSON(w, http.StatusOK, state) }