370 lines
14 KiB
Go
370 lines
14 KiB
Go
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)
|
||
|
|
}
|