0.2.78
This commit is contained in:
@@ -0,0 +1,369 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user