Files
memby/server/internal/api/admin_integration_services.go
T

370 lines
14 KiB
Go
Raw Normal View History

2026-08-19 14:25:44 +12:00
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)
}