From c84a4be876b78e2c252e6b12ba316d273886d715 Mon Sep 17 00:00:00 2001 From: Tomas Dvorak Date: Mon, 21 Sep 2026 19:21:21 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20ops=20layer=20=E2=80=94=20incidents,=20?= =?UTF-8?q?maintenance,=20status=20pages,=20badges,=20metrics=20(merge=20p?= =?UTF-8?q?lan=20M4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit incidents + incident_updates + maintenance_windows + status_pages tables. Monitor transitions auto-open incidents on down and auto-resolve on recovery; an active maintenance window covering the monitor suppresses alerts and auto-incidents, and status pages report the covered monitor as `maintenance`. Public status view: GET /api/status-pages/:slug/public aggregates overall state, monitor uptime, systems, open incidents, and scheduled maintenance; rendered read-only at /status/:slug. GET /api/badge/:kind/:id.svg serves stateless shields; GET /api/metrics exposes Prometheus text over existing tables; POST /api/import/csv?kind=monitors|domains does header-driven bulk import; /api/export v2 folds monitors/domains/systems rows into the backup payload. Frontend: /incidents with timeline + status flow, /status admin for pages + windows, /status/:slug public page; nav and palette wired. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- ROADMAP.md | 14 +- apps/backend/internal/api/api.go | 20 + apps/backend/internal/api/api_test.go | 123 +++ apps/backend/internal/api/badge.go | 92 +++ apps/backend/internal/api/bulk.go | 96 +++ apps/backend/internal/api/metrics.go | 75 ++ apps/backend/internal/api/monitors.go | 7 +- apps/backend/internal/api/ops.go | 717 ++++++++++++++++++ apps/backend/internal/api/transfer.go | 54 +- .../internal/db/migrations/0006_ops.sql | 45 ++ apps/frontend/src/App.tsx | 16 +- .../src/components/CommandPalette.tsx | 4 +- apps/frontend/src/pages/IncidentsPage.tsx | 198 +++++ apps/frontend/src/pages/StatusPagesPage.tsx | 245 ++++++ apps/frontend/src/pages/StatusPublicPage.tsx | 128 ++++ apps/frontend/src/types.ts | 5 + openapi.yaml | 384 ++++++++++ 17 files changed, 2212 insertions(+), 11 deletions(-) create mode 100644 apps/backend/internal/api/badge.go create mode 100644 apps/backend/internal/api/bulk.go create mode 100644 apps/backend/internal/api/metrics.go create mode 100644 apps/backend/internal/api/ops.go create mode 100644 apps/backend/internal/db/migrations/0006_ops.sql create mode 100644 apps/frontend/src/pages/IncidentsPage.tsx create mode 100644 apps/frontend/src/pages/StatusPagesPage.tsx create mode 100644 apps/frontend/src/pages/StatusPublicPage.tsx diff --git a/ROADMAP.md b/ROADMAP.md index bd713a5..ba0e33a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -182,13 +182,13 @@ Beszel-style server monitoring, push-based. ### Phase M4 — Ops layer → v0.7.0 -- [ ] Incidents: `incidents` + `incident_updates`; manual CRUD + auto-open on monitor down, auto-resolve on recovery; severity + status flow (open → ack → resolved → closed) -- [ ] Maintenance windows: `maintenance_windows`; suppress alerts, show `maintenance` state while active -- [ ] Status pages: `status_pages` + `status_page_monitors`; public `/status/:slug` route + public API endpoint -- [ ] Badges: `GET /badge/:kind/:id.svg` — stateless SVG from live data (status/uptime/response/domain/system) -- [ ] `GET /metrics` Prometheus exposition -- [ ] Bulk: CSV import monitors + domains; JSON export folded into existing `/api/export` -- [ ] ⌘K palette + header nav wired for new pages +- [x] Incidents: `incidents` + `incident_updates`; manual CRUD + auto-open on monitor down, auto-resolve on recovery; severity + status flow (open → ack → resolved → closed) +- [x] Maintenance windows: `maintenance_windows` (monitor_ids empty = all); suppress alerts + auto-incidents, status pages show `maintenance` state while active +- [x] Status pages: `status_pages` (monitor_ids/system_ids JSON columns — a join table for ≤ dozens of ids is ceremony); admin `/status`, public `/status/:slug` + `GET /api/status-pages/:slug/public` +- [x] Badges: `GET /api/badge/:kind/:id.svg` — stateless SVG from live data (monitor status+uptime, domain days-left, system status) +- [x] `GET /api/metrics` Prometheus exposition +- [x] Bulk: `POST /api/import/csv?kind=monitors|domains` header-driven CSV; monitors/domains/systems rows folded into `/api/export` (v2, additive) +- [x] ⌘K palette + header nav wired for new pages ## Phase 4 — Open-source launch → v1.0.0 diff --git a/apps/backend/internal/api/api.go b/apps/backend/internal/api/api.go index b35d058..dc9d797 100644 --- a/apps/backend/internal/api/api.go +++ b/apps/backend/internal/api/api.go @@ -101,6 +101,26 @@ func (s *Server) routes() *gin.Engine { v1.GET("/systems/:id/stats", s.systemStats) v1.POST("/systems/ingest", s.ingestSystem) + v1.GET("/incidents", s.listIncidents) + v1.POST("/incidents", s.createIncident) + v1.PATCH("/incidents/:id", s.patchIncident) + v1.DELETE("/incidents/:id", s.deleteIncident) + v1.POST("/incidents/:id/updates", s.addIncidentUpdate) + + v1.GET("/maintenance", s.listWindows) + v1.POST("/maintenance", s.createWindow) + v1.DELETE("/maintenance/:id", s.deleteWindow) + + v1.GET("/status-pages", s.listStatusPages) + v1.POST("/status-pages", s.createStatusPage) + v1.PATCH("/status-pages/:id", s.patchStatusPage) + v1.DELETE("/status-pages/:id", s.deleteStatusPage) + v1.GET("/status-pages/:slug/public", s.publicStatus) + + v1.GET("/badge/:kind/:file", s.badge) + v1.GET("/metrics", s.metrics) + v1.POST("/import/csv", s.importCSV) + v1.GET("/status", s.getStatus) v1.GET("/widgets/types", s.widgetTypes) v1.GET("/widgets/:id/data", s.widgetData) diff --git a/apps/backend/internal/api/api_test.go b/apps/backend/internal/api/api_test.go index b601af0..0e6539d 100644 --- a/apps/backend/internal/api/api_test.go +++ b/apps/backend/internal/api/api_test.go @@ -3,6 +3,7 @@ package api import ( "context" "encoding/json" + "io" "net/http" "net/http/httptest" "path/filepath" @@ -635,6 +636,128 @@ func TestSystemIngest(t *testing.T) { } } +func TestOpsLayer(t *testing.T) { + srv := testServer(t) + defer srv.Close() + + // Monitor that flips up->down should auto-open an incident, and + // recovery should auto-resolve it. + up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) })) + defer up.Close() + _, mv := do(t, "POST", srv.URL+"/api/monitors", + `{"name":"Flip","type":"http","url":"`+up.URL+`","timeoutS":1}`) + id := mv["id"].(string) + do(t, "POST", srv.URL+"/api/monitors/"+id+"/check", "") // up + do(t, "PATCH", srv.URL+"/api/monitors/"+id, `{"url":"http://127.0.0.1:1"}`) + do(t, "POST", srv.URL+"/api/monitors/"+id+"/check", "") // down -> incident + + res, _ := do(t, "GET", srv.URL+"/api/incidents?status=open", "") + if res.StatusCode != http.StatusOK { + t.Fatalf("incidents: %d", res.StatusCode) + } + req, _ := http.NewRequest("GET", srv.URL+"/api/incidents?status=open", nil) + r, _ := http.DefaultClient.Do(req) + var incs []map[string]any + _ = json.NewDecoder(r.Body).Decode(&incs) + r.Body.Close() + if len(incs) != 1 || incs[0]["monitorId"] != id { + t.Fatalf("auto incident: %v", incs) + } + incID := incs[0]["id"].(string) + + do(t, "PATCH", srv.URL+"/api/monitors/"+id, `{"url":"`+up.URL+`"}`) + do(t, "POST", srv.URL+"/api/monitors/"+id+"/check", "") // up -> resolved + res, inc := do(t, "GET", srv.URL+"/api/incidents?status=open", "") + req, _ = http.NewRequest("GET", srv.URL+"/api/incidents?status=open", nil) + r, _ = http.DefaultClient.Do(req) + incs = nil + _ = json.NewDecoder(r.Body).Decode(&incs) + r.Body.Close() + if len(incs) != 0 { + t.Fatalf("incident should auto-resolve, got %v", incs) + } + + // Manual incident lifecycle: create -> update -> resolve. + res, inc = do(t, "POST", srv.URL+"/api/incidents", + `{"title":"disk full","severity":"critical","message":"nas raid degraded"}`) + if res.StatusCode != http.StatusCreated || inc["status"] != "open" { + t.Fatalf("create incident: %v", inc) + } + incID = inc["id"].(string) + res, inc = do(t, "PATCH", srv.URL+"/api/incidents/"+incID, `{"status":"resolved","message":"swapped drive"}`) + if inc["status"] != "resolved" { + t.Fatalf("resolve: %v", inc) + } + ups, _ := inc["updates"].([]any) + if len(ups) < 2 { + t.Fatalf("updates timeline: %v", inc["updates"]) + } + + // Maintenance window suppresses a transition's side-effects. + _, mw := do(t, "POST", srv.URL+"/api/maintenance", + `{"title":"upgrade","startsAt":"`+time.Now().Add(-time.Hour).UTC().Format(time.RFC3339)+ + `","endsAt":"`+time.Now().Add(time.Hour).UTC().Format(time.RFC3339)+`"}`) + if mw["active"] != true { + t.Fatalf("window should be active: %v", mw) + } + do(t, "PATCH", srv.URL+"/api/monitors/"+id, `{"url":"http://127.0.0.1:1"}`) + do(t, "POST", srv.URL+"/api/monitors/"+id+"/check", "") // down under maintenance + req, _ = http.NewRequest("GET", srv.URL+"/api/incidents?status=open", nil) + r, _ = http.DefaultClient.Do(req) + incs = nil + _ = json.NewDecoder(r.Body).Decode(&incs) + r.Body.Close() + if len(incs) != 0 { + t.Fatalf("maintenance must suppress auto-incidents: %v", incs) + } + + // Status page + public view shows maintenance state. + res, sp := do(t, "POST", srv.URL+"/api/status-pages", `{"title":"Ops","slug":"ops"}`) + if res.StatusCode != http.StatusCreated || sp["slug"] != "ops" { + t.Fatalf("status page: %v", sp) + } + res, pub := do(t, "GET", srv.URL+"/api/status-pages/ops/public", "") + if res.StatusCode != http.StatusOK || pub["overall"] != "maintenance" { + t.Fatalf("public status: %v", pub) + } + mons, _ := pub["monitors"].([]any) + if len(mons) != 1 { + t.Fatalf("public monitors: %v", pub["monitors"]) + } + if m0, _ := mons[0].(map[string]any); m0["status"] != "maintenance" { + t.Fatalf("monitor should read maintenance: %v", m0) + } + + // Badge + metrics smoke. + res, _ = do(t, "GET", srv.URL+"/api/badge/monitor/"+id+".svg", "") + if res.StatusCode != http.StatusOK { + t.Fatalf("badge: %d", res.StatusCode) + } + res, _ = do(t, "GET", srv.URL+"/api/metrics", "") + if res.StatusCode != http.StatusOK { + t.Fatalf("metrics: %d", res.StatusCode) + } + req, _ = http.NewRequest("GET", srv.URL+"/api/metrics", nil) + r, _ = http.DefaultClient.Do(req) + body, _ := io.ReadAll(r.Body) + r.Body.Close() + if !strings.Contains(string(body), "dash_monitors_total") { + t.Fatalf("metrics body: %s", body) + } + + // CSV import. + req, _ = http.NewRequest("POST", srv.URL+"/api/import/csv?kind=monitors", + strings.NewReader("name,type,url\nA,http,https://a.example\nB,tcp,https://b.example:9\n")) + req.Header.Set("Content-Type", "text/csv") + r, _ = http.DefaultClient.Do(req) + var out map[string]any + _ = json.NewDecoder(r.Body).Decode(&out) + r.Body.Close() + if out["created"].(float64) != 2 { + t.Fatalf("csv import: %v", out) + } +} + func TestMidpoint(t *testing.T) { f := func(v float64) *float64 { return &v } if got := midpoint(nil, nil); got != 1024 { diff --git a/apps/backend/internal/api/badge.go b/apps/backend/internal/api/badge.go new file mode 100644 index 0000000..9120d77 --- /dev/null +++ b/apps/backend/internal/api/badge.go @@ -0,0 +1,92 @@ +package api + +import ( + "fmt" + "net/http" + "strconv" + "strings" + + "github.com/gin-gonic/gin" +) + +// Stateless SVG shields rendered from live rows: /api/badge//.svg +// kinds: monitor (status+uptime), domain (days to expiry), system (status+cpu). + +var badgeColors = map[string]string{ + "up": "#3fb950", + "down": "#f85149", + "pending": "#8b949e", + "expired": "#f85149", + "expiring": "#d29922", + "ok": "#3fb950", + "unknown": "#8b949e", +} + +func badgeSVG(c *gin.Context, label, value, color string) { + // Fixed-width font assumption is deliberate — shields embed anywhere. + lw := 6*len(label) + 12 + vw := 6*len(value) + 12 + c.Header("Content-Type", "image/svg+xml") + c.Header("Cache-Control", "no-cache") + c.String(http.StatusOK, fmt.Sprintf(` + + + + + + + + +%s +%s +`, + lw+vw, label, value, lw+vw, lw, lw, vw, color, lw+vw, 6, label, lw+6, value)) +} + +func (s *Server) badge(c *gin.Context) { + kind := c.Param("kind") + // Route is /badge//; the conventional suffix is optional. + id := strings.TrimSuffix(c.Param("file"), ".svg") + + switch kind { + case "monitor": + var name, status string + err := s.db.QueryRow(`SELECT name, status FROM monitors WHERE id = ?`, id).Scan(&name, &status) + if err != nil { + badgeSVG(c, "monitor", "unknown", badgeColors["unknown"]) + return + } + uptime := s.monitorStats(id).Uptime24h + badgeSVG(c, name, fmt.Sprintf("%s · %.1f%%", status, uptime), badgeColors[status]) + case "domain": + var name string + var expiry *string + err := s.db.QueryRow(`SELECT name, expiry_date FROM domains WHERE id = ?`, id).Scan(&name, &expiry) + if err != nil { + badgeSVG(c, "domain", "unknown", badgeColors["unknown"]) + return + } + label, color := "unknown", badgeColors["unknown"] + if days := daysUntil(expiry); days != nil { + switch { + case *days < 0: + label, color = "expired", badgeColors["expired"] + case *days <= 30: + label, color = strconv.Itoa(*days)+"d left", badgeColors["expiring"] + default: + label, color = strconv.Itoa(*days)+"d left", badgeColors["ok"] + } + } + badgeSVG(c, name, label, color) + case "system": + var name, status string + err := s.db.QueryRow(`SELECT name, status FROM systems WHERE id = ?`, id).Scan(&name, &status) + if err != nil { + badgeSVG(c, "system", "unknown", badgeColors["unknown"]) + return + } + badgeSVG(c, name, status, badgeColors[status]) + default: + fail(c, http.StatusNotFound, "unknown badge kind") + } +} diff --git a/apps/backend/internal/api/bulk.go b/apps/backend/internal/api/bulk.go new file mode 100644 index 0000000..3bea87f --- /dev/null +++ b/apps/backend/internal/api/bulk.go @@ -0,0 +1,96 @@ +package api + +import ( + "encoding/csv" + "fmt" + "net/http" + "strconv" + "strings" + "time" + + "github.com/gin-gonic/gin" +) + +// POST /api/import/csv?kind=monitors|domains — header-driven CSV bulk import. +// Monitors: name,type,url,hostname,port,interval — name + (url|hostname) needed. +// Domains: name — one per row. + +func (s *Server) importCSV(c *gin.Context) { + kind := c.DefaultQuery("kind", "monitors") + if kind != "monitors" && kind != "domains" { + fail(c, http.StatusBadRequest, "kind must be monitors or domains") + return + } + rd := csv.NewReader(c.Request.Body) + rd.FieldsPerRecord = -1 // tolerate ragged rows; header drives positions + recs, err := rd.ReadAll() + if err != nil || len(recs) < 2 { + fail(c, http.StatusBadRequest, "csv with a header row and at least one entry required") + return + } + head := map[string]int{} + for i, h := range recs[0] { + head[strings.ToLower(strings.TrimSpace(h))] = i + } + cell := func(rec []string, col string) string { + if i, ok := head[col]; ok && i < len(rec) { + return strings.TrimSpace(rec[i]) + } + return "" + } + + var created, skipped int + var errs []string + for i, rec := range recs[1:] { + row := i + 2 // header is row 1 + name := cell(rec, "name") + if name == "" { + skipped++ + continue + } + if kind == "domains" { + if _, err := s.db.Exec(`INSERT INTO domains (id, name) VALUES (?, ?)`, + newID("d"), strings.ToLower(name)); err != nil { + if strings.Contains(err.Error(), "UNIQUE") { + skipped++ + continue + } + errs = append(errs, fmt.Sprintf("row %d: %v", row, err)) + continue + } + created++ + continue + } + + // monitors + typ := orDefault(cell(rec, "type"), "http") + if !validMonitorType(typ) { + errs = append(errs, fmt.Sprintf("row %d: bad type %q", row, typ)) + continue + } + url, host := cell(rec, "url"), cell(rec, "hostname") + if url == "" && host == "" { + errs = append(errs, fmt.Sprintf("row %d: url or hostname required", row)) + continue + } + port, _ := strconv.Atoi(cell(rec, "port")) + interval, _ := strconv.Atoi(cell(rec, "interval")) + if interval <= 0 { + interval = 60 + } + token := "" + if typ == "push" { + token = newID("push") + } + if _, err := s.db.Exec(`INSERT INTO monitors + (id, name, type, url, hostname, port, interval_s, push_token, position) + VALUES (?,?,?,?,?,?,?,?,?)`, + newID("m"), name, typ, url, host, port, interval, token, + float64(time.Now().UnixNano())/1e9); err != nil { + errs = append(errs, fmt.Sprintf("row %d: %v", row, err)) + continue + } + created++ + } + c.JSON(http.StatusOK, gin.H{"created": created, "skipped": skipped, "errors": errs}) +} diff --git a/apps/backend/internal/api/metrics.go b/apps/backend/internal/api/metrics.go new file mode 100644 index 0000000..a228c29 --- /dev/null +++ b/apps/backend/internal/api/metrics.go @@ -0,0 +1,75 @@ +package api + +import ( + "fmt" + "net/http" + "strings" + + "github.com/gin-gonic/gin" +) + +// /api/metrics — Prometheus text exposition over the tables we already have. +// No registry, no deps: the counts are one-line SQL each. + +func promEscape(s string) string { + return strings.NewReplacer(`\`, `\\`, `"`, `\"`, "\n", `\n`).Replace(s) +} + +func (s *Server) metrics(c *gin.Context) { + var b strings.Builder + count := func(q string) int { + var n int + _ = s.db.QueryRow(q).Scan(&n) + return n + } + gauge := func(name, help string, v int) { + fmt.Fprintf(&b, "# HELP %s %s\n# TYPE %s gauge\n%s %d\n", name, help, name, name, v) + } + + gauge("dash_monitors_total", "Configured monitors", count(`SELECT COUNT(*) FROM monitors`)) + gauge("dash_monitors_up", "Monitors currently up", count(`SELECT COUNT(*) FROM monitors WHERE status='up'`)) + gauge("dash_monitors_down", "Monitors currently down", count(`SELECT COUNT(*) FROM monitors WHERE status='down'`)) + gauge("dash_domains_total", "Tracked domains", count(`SELECT COUNT(*) FROM domains`)) + gauge("dash_domains_expired", "Domains past expiry", count(`SELECT COUNT(*) FROM domains WHERE expiry_date IS NOT NULL AND datetime(expiry_date) < datetime('now')`)) + gauge("dash_domains_expiring_30d", "Domains expiring within 30 days", + count(`SELECT COUNT(*) FROM domains WHERE expiry_date IS NOT NULL + AND datetime(expiry_date) >= datetime('now') + AND datetime(expiry_date) <= datetime('now','+30 days')`)) + gauge("dash_systems_total", "Registered systems", count(`SELECT COUNT(*) FROM systems`)) + gauge("dash_systems_up", "Systems currently reporting", count(`SELECT COUNT(*) FROM systems WHERE status='up'`)) + gauge("dash_systems_down", "Systems gone silent", count(`SELECT COUNT(*) FROM systems WHERE status='down'`)) + gauge("dash_incidents_open", "Open or acknowledged incidents", count(`SELECT COUNT(*) FROM incidents WHERE status IN ('open','ack')`)) + gauge("dash_heartbeats_total", "Recorded monitor heartbeats", count(`SELECT COUNT(*) FROM heartbeats`)) + + // Per-monitor up/down gauge — label cardinality is user-controlled and small. + b.WriteString("# TYPE dash_monitor_up gauge\n") + if rows, err := s.db.Query(`SELECT name, status FROM monitors`); err == nil { + for rows.Next() { + var name, st string + if rows.Scan(&name, &st) == nil { + v := 0 + if st == "up" { + v = 1 + } + fmt.Fprintf(&b, "dash_monitor_up{name=%q} %d\n", promEscape(name), v) + } + } + rows.Close() + } + b.WriteString("# TYPE dash_system_up gauge\n") + if rows, err := s.db.Query(`SELECT name, status FROM systems`); err == nil { + for rows.Next() { + var name, st string + if rows.Scan(&name, &st) == nil { + v := 0 + if st == "up" { + v = 1 + } + fmt.Fprintf(&b, "dash_system_up{name=%q} %d\n", promEscape(name), v) + } + } + rows.Close() + } + + c.Data(http.StatusOK, "text/plain; version=0.0.4; charset=utf-8", []byte(b.String())) +} diff --git a/apps/backend/internal/api/monitors.go b/apps/backend/internal/api/monitors.go index 3cefb15..7e69426 100644 --- a/apps/backend/internal/api/monitors.go +++ b/apps/backend/internal/api/monitors.go @@ -498,10 +498,15 @@ func (s *Server) writeHeartbeat(m *monitor.Monitor, res *monitor.Result) { } // onMonitorTransition fires on real state flips (pending excluded upstream) -// — the notification seam for up/down events. +// — the notification + auto-incident seam for up/down events. An active +// maintenance window covering this monitor suppresses both. func (s *Server) onMonitorTransition(m *monitor.Monitor, prev, next string) { s.log.Info("monitor transition", zap.String("monitor", m.Name), zap.String("from", prev), zap.String("to", next)) + if s.inMaintenance(m.ID) { + return + } + s.autoIncident(m.ID, m.Name, next) target := m.URL if target == "" { target = m.Hostname diff --git a/apps/backend/internal/api/ops.go b/apps/backend/internal/api/ops.go new file mode 100644 index 0000000..73035ec --- /dev/null +++ b/apps/backend/internal/api/ops.go @@ -0,0 +1,717 @@ +package api + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + "time" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" +) + +// ---------- incidents ---------- + +type Incident struct { + ID string `json:"id"` + Title string `json:"title"` + Severity string `json:"severity"` + Status string `json:"status"` + MonitorID *string `json:"monitorId"` + Updates []IncidentUpdate `json:"updates,omitempty"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` +} + +type IncidentUpdate struct { + ID int64 `json:"id"` + Status string `json:"status"` + Message string `json:"message"` + At string `json:"at"` +} + +var incidentStatuses = map[string]bool{"open": true, "ack": true, "resolved": true, "closed": true} +var incidentSeverities = map[string]bool{"minor": true, "major": true, "critical": true} + +func scanIncident(row interface{ Scan(...any) error }) (*Incident, error) { + var in Incident + err := row.Scan(&in.ID, &in.Title, &in.Severity, &in.Status, &in.MonitorID, + &in.CreatedAt, &in.UpdatedAt) + return &in, err +} + +const incidentCols = `id, title, severity, status, monitor_id, created_at, updated_at` + +func (s *Server) loadIncident(id string) (*Incident, error) { + return scanIncident(s.db.QueryRow(`SELECT `+incidentCols+` FROM incidents WHERE id = ?`, id)) +} + +func (s *Server) incidentUpdates(id string) []IncidentUpdate { + rows, err := s.db.Query(`SELECT id, status, message, at FROM incident_updates + WHERE incident_id = ? ORDER BY at`, id) + if err != nil { + return nil + } + defer rows.Close() + out := []IncidentUpdate{} + for rows.Next() { + var u IncidentUpdate + if rows.Scan(&u.ID, &u.Status, &u.Message, &u.At) == nil { + out = append(out, u) + } + } + return out +} + +func (s *Server) listIncidents(c *gin.Context) { + openOnly := c.Query("status") == "open" + q := `SELECT ` + incidentCols + ` FROM incidents` + if openOnly { + q += ` WHERE status IN ('open','ack')` + } + q += ` ORDER BY created_at DESC LIMIT 200` + rows, err := s.db.Query(q) + if err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + defer rows.Close() + out := []Incident{} + for rows.Next() { + in, err := scanIncident(rows) + if err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + in.Updates = s.incidentUpdates(in.ID) + out = append(out, *in) + } + c.JSON(http.StatusOK, out) +} + +type incidentInput struct { + Title *string `json:"title"` + Severity *string `json:"severity"` + Status *string `json:"status"` + MonitorID *string `json:"monitorId"` + Message *string `json:"message"` // initial update body / transition note +} + +func (s *Server) createIncident(c *gin.Context) { + var in incidentInput + if err := c.ShouldBindJSON(&in); err != nil || in.Title == nil || *in.Title == "" { + fail(c, http.StatusBadRequest, "title required") + return + } + sev := strOr2(in.Severity, "major") + st := strOr2(in.Status, "open") + if !incidentSeverities[sev] || !incidentStatuses[st] { + fail(c, http.StatusBadRequest, "invalid severity or status") + return + } + id := newID("inc") + _, err := s.db.Exec(`INSERT INTO incidents (id, title, severity, status, monitor_id) + VALUES (?,?,?,?,?)`, id, *in.Title, sev, st, in.MonitorID) + if err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + if msg := strOr(in.Message); msg != "" { + _, _ = s.db.Exec(`INSERT INTO incident_updates (incident_id, status, message) VALUES (?,?,?)`, + id, st, msg) + } + inc, err := s.loadIncident(id) + if err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + inc.Updates = s.incidentUpdates(id) + c.JSON(http.StatusCreated, inc) +} + +// patchIncident moves status/severity/title; a status change also appends an +// incident_updates row so the timeline stays honest. +func (s *Server) patchIncident(c *gin.Context) { + id := c.Param("id") + cur, err := s.loadIncident(id) + if notFound(err) { + fail(c, http.StatusNotFound, "incident not found") + return + } + var in incidentInput + if err := c.ShouldBindJSON(&in); err != nil { + fail(c, http.StatusBadRequest, "invalid body") + return + } + if in.Severity != nil && !incidentSeverities[*in.Severity] { + fail(c, http.StatusBadRequest, "invalid severity") + return + } + if in.Status != nil && !incidentStatuses[*in.Status] { + fail(c, http.StatusBadRequest, "invalid status") + return + } + set := func(col string, p *string) { + if p != nil { + _, _ = s.db.Exec(`UPDATE incidents SET `+col+` = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`, *p, id) + } + } + set("title", in.Title) + set("severity", in.Severity) + set("status", in.Status) + if in.Status != nil && *in.Status != cur.Status { + _, _ = s.db.Exec(`INSERT INTO incident_updates (incident_id, status, message) VALUES (?,?,?)`, + id, *in.Status, strOr(in.Message)) + } + inc, err := s.loadIncident(id) + if err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + inc.Updates = s.incidentUpdates(id) + c.JSON(http.StatusOK, inc) +} + +func (s *Server) addIncidentUpdate(c *gin.Context) { + id := c.Param("id") + if _, err := s.loadIncident(id); notFound(err) { + fail(c, http.StatusNotFound, "incident not found") + return + } + var in incidentInput + if err := c.ShouldBindJSON(&in); err != nil { + fail(c, http.StatusBadRequest, "invalid body") + return + } + st := strOr2(in.Status, "") + if st != "" && !incidentStatuses[st] { + fail(c, http.StatusBadRequest, "invalid status") + return + } + if st == "" { + st = "note" + } + _, err := s.db.Exec(`INSERT INTO incident_updates (incident_id, status, message) VALUES (?,?,?)`, + id, st, strOr(in.Message)) + if err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + if in.Status != nil { + _, _ = s.db.Exec(`UPDATE incidents SET status = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`, *in.Status, id) + } + inc, _ := s.loadIncident(id) + inc.Updates = s.incidentUpdates(id) + c.JSON(http.StatusOK, inc) +} + +func (s *Server) deleteIncident(c *gin.Context) { + res, err := s.db.Exec(`DELETE FROM incidents WHERE id = ?`, c.Param("id")) + if err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + if n, _ := res.RowsAffected(); n == 0 { + fail(c, http.StatusNotFound, "incident not found") + return + } + c.Status(http.StatusNoContent) +} + +// ---------- maintenance windows ---------- + +type MaintWindow struct { + ID string `json:"id"` + Title string `json:"title"` + StartsAt string `json:"startsAt"` + EndsAt string `json:"endsAt"` + MonitorIDs []string `json:"monitorIds"` // empty = all monitors + Active bool `json:"active"` + CreatedAt string `json:"createdAt"` +} + +func scanWindow(row interface{ Scan(...any) error }) (*MaintWindow, error) { + var w MaintWindow + var ids string + err := row.Scan(&w.ID, &w.Title, &w.StartsAt, &w.EndsAt, &ids, &w.CreatedAt) + if err != nil { + return nil, err + } + _ = json.Unmarshal([]byte(ids), &w.MonitorIDs) + if w.MonitorIDs == nil { + w.MonitorIDs = []string{} + } + now := time.Now().UTC() + if st, err1 := time.Parse(time.RFC3339Nano, w.StartsAt); err1 == nil { + if en, err2 := time.Parse(time.RFC3339Nano, w.EndsAt); err2 == nil { + w.Active = !now.Before(st) && now.Before(en) + } + } + return &w, nil +} + +const windowCols = `id, title, starts_at, ends_at, monitor_ids, created_at` + +func (s *Server) listWindows(c *gin.Context) { + rows, err := s.db.Query(`SELECT ` + windowCols + ` FROM maintenance_windows ORDER BY starts_at DESC LIMIT 100`) + if err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + defer rows.Close() + out := []MaintWindow{} + for rows.Next() { + w, err := scanWindow(rows) + if err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + out = append(out, *w) + } + c.JSON(http.StatusOK, out) +} + +type windowInput struct { + Title *string `json:"title"` + StartsAt *string `json:"startsAt"` + EndsAt *string `json:"endsAt"` + MonitorIDs []string `json:"monitorIds"` +} + +func validWindow(in *windowInput) bool { + if in.Title == nil || in.StartsAt == nil || in.EndsAt == nil || *in.Title == "" { + return false + } + st, err1 := time.Parse(time.RFC3339Nano, *in.StartsAt) + en, err2 := time.Parse(time.RFC3339Nano, *in.EndsAt) + return err1 == nil && err2 == nil && en.After(st) +} + +func (s *Server) createWindow(c *gin.Context) { + var in windowInput + if err := c.ShouldBindJSON(&in); err != nil || !validWindow(&in) { + fail(c, http.StatusBadRequest, "title, startsAt, endsAt (RFC3339, end after start) required") + return + } + ids, _ := json.Marshal(in.MonitorIDs) + if in.MonitorIDs == nil { + ids = []byte("[]") + } + id := newID("mw") + if _, err := s.db.Exec(`INSERT INTO maintenance_windows (id, title, starts_at, ends_at, monitor_ids) + VALUES (?,?,?,?,?)`, id, *in.Title, *in.StartsAt, *in.EndsAt, string(ids)); err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + w, err := scanWindow(s.db.QueryRow(`SELECT `+windowCols+` FROM maintenance_windows WHERE id = ?`, id)) + if err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + c.JSON(http.StatusCreated, w) +} + +func (s *Server) deleteWindow(c *gin.Context) { + res, err := s.db.Exec(`DELETE FROM maintenance_windows WHERE id = ?`, c.Param("id")) + if err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + if n, _ := res.RowsAffected(); n == 0 { + fail(c, http.StatusNotFound, "window not found") + return + } + c.Status(http.StatusNoContent) +} + +// inMaintenance reports whether a monitor is covered by an active window — +// suppresses auto-incidents and transition alerts. +func (s *Server) inMaintenance(monitorID string) bool { + rows, err := s.db.Query(`SELECT monitor_ids FROM maintenance_windows + WHERE datetime(starts_at) <= datetime('now') AND datetime(ends_at) > datetime('now')`) + if err != nil { + return false + } + defer rows.Close() + for rows.Next() { + var raw string + if rows.Scan(&raw) != nil { + continue + } + var ids []string + _ = json.Unmarshal([]byte(raw), &ids) + if len(ids) == 0 { + return true + } + for _, id := range ids { + if id == monitorID { + return true + } + } + } + return false +} + +// ---------- status pages ---------- + +type StatusPage struct { + ID string `json:"id"` + Slug string `json:"slug"` + Title string `json:"title"` + Description string `json:"description"` + MonitorIDs []string `json:"monitorIds"` + SystemIDs []string `json:"systemIds"` + CreatedAt string `json:"createdAt"` +} + +func scanStatusPage(row interface{ Scan(...any) error }) (*StatusPage, error) { + var p StatusPage + var mids, sids string + err := row.Scan(&p.ID, &p.Slug, &p.Title, &p.Description, &mids, &sids, &p.CreatedAt) + if err != nil { + return nil, err + } + _ = json.Unmarshal([]byte(mids), &p.MonitorIDs) + _ = json.Unmarshal([]byte(sids), &p.SystemIDs) + if p.MonitorIDs == nil { + p.MonitorIDs = []string{} + } + if p.SystemIDs == nil { + p.SystemIDs = []string{} + } + return &p, nil +} + +const statusPageCols = `id, slug, title, description, monitor_ids, system_ids, created_at` + +func (s *Server) listStatusPages(c *gin.Context) { + rows, err := s.db.Query(`SELECT ` + statusPageCols + ` FROM status_pages ORDER BY title`) + if err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + defer rows.Close() + out := []StatusPage{} + for rows.Next() { + p, err := scanStatusPage(rows) + if err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + out = append(out, *p) + } + c.JSON(http.StatusOK, out) +} + +type statusPageInput struct { + Slug *string `json:"slug"` + Title *string `json:"title"` + Description *string `json:"description"` + MonitorIDs []string `json:"monitorIds"` + SystemIDs []string `json:"systemIds"` +} + +// validSlug keeps the public URL safe: lowercase letters, digits, dashes. +func validSlug(slug string) bool { + if slug == "" || len(slug) > 64 { + return false + } + for _, r := range slug { + if !(r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '-') { + return false + } + } + return true +} + +func (s *Server) createStatusPage(c *gin.Context) { + var in statusPageInput + if err := c.ShouldBindJSON(&in); err != nil || in.Title == nil || *in.Title == "" { + fail(c, http.StatusBadRequest, "title required") + return + } + slug := strOr(in.Slug) + if slug == "" { + slug = slugify(*in.Title) + } + if !validSlug(slug) { + fail(c, http.StatusBadRequest, "invalid slug") + return + } + mids, _ := json.Marshal(in.MonitorIDs) + sids, _ := json.Marshal(in.SystemIDs) + id := newID("sp") + _, err := s.db.Exec(`INSERT INTO status_pages (id, slug, title, description, monitor_ids, system_ids) + VALUES (?,?,?,?,?,?)`, id, slug, *in.Title, strOr(in.Description), string(mids), string(sids)) + if err != nil { + if strings.Contains(err.Error(), "UNIQUE") { + fail(c, http.StatusConflict, "slug already in use") + return + } + fail(c, http.StatusInternalServerError, err.Error()) + return + } + p, err := scanStatusPage(s.db.QueryRow(`SELECT `+statusPageCols+` FROM status_pages WHERE id = ?`, id)) + if err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + c.JSON(http.StatusCreated, p) +} + +func (s *Server) patchStatusPage(c *gin.Context) { + id := c.Param("id") + if _, err := scanStatusPage(s.db.QueryRow( + `SELECT `+statusPageCols+` FROM status_pages WHERE id = ?`, id)); notFound(err) { + fail(c, http.StatusNotFound, "status page not found") + return + } + var in statusPageInput + if err := c.ShouldBindJSON(&in); err != nil { + fail(c, http.StatusBadRequest, "invalid body") + return + } + if in.Slug != nil { + if !validSlug(*in.Slug) { + fail(c, http.StatusBadRequest, "invalid slug") + return + } + _, _ = s.db.Exec(`UPDATE status_pages SET slug = ? WHERE id = ?`, *in.Slug, id) + } + if in.Title != nil && *in.Title != "" { + _, _ = s.db.Exec(`UPDATE status_pages SET title = ? WHERE id = ?`, *in.Title, id) + } + if in.Description != nil { + _, _ = s.db.Exec(`UPDATE status_pages SET description = ? WHERE id = ?`, *in.Description, id) + } + if in.MonitorIDs != nil { + b, _ := json.Marshal(in.MonitorIDs) + _, _ = s.db.Exec(`UPDATE status_pages SET monitor_ids = ? WHERE id = ?`, string(b), id) + } + if in.SystemIDs != nil { + b, _ := json.Marshal(in.SystemIDs) + _, _ = s.db.Exec(`UPDATE status_pages SET system_ids = ? WHERE id = ?`, string(b), id) + } + p, err := scanStatusPage(s.db.QueryRow(`SELECT `+statusPageCols+` FROM status_pages WHERE id = ?`, id)) + if err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + c.JSON(http.StatusOK, p) +} + +func (s *Server) deleteStatusPage(c *gin.Context) { + res, err := s.db.Exec(`DELETE FROM status_pages WHERE id = ?`, c.Param("id")) + if err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + if n, _ := res.RowsAffected(); n == 0 { + fail(c, http.StatusNotFound, "status page not found") + return + } + c.Status(http.StatusNoContent) +} + +func slugify(s string) string { + var b strings.Builder + for _, r := range strings.ToLower(s) { + switch { + case r >= 'a' && r <= 'z' || r >= '0' && r <= '9': + b.WriteRune(r) + case r == ' ' || r == '-' || r == '_': + b.WriteRune('-') + } + } + return strings.Trim(b.String(), "-") +} + +// ---------- public status ---------- + +// publicStatus renders one status page's live view: monitors with uptime, +// systems with status, open incidents, active/upcoming maintenance. +func (s *Server) publicStatus(c *gin.Context) { + p, err := scanStatusPage(s.db.QueryRow( + `SELECT `+statusPageCols+` FROM status_pages WHERE slug = ?`, c.Param("slug"))) + if notFound(err) { + fail(c, http.StatusNotFound, "status page not found") + return + } + if err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + + pick := func(ids []string) (string, []any) { + if len(ids) == 0 { + return "", nil + } + marks := strings.TrimSuffix(strings.Repeat("?,", len(ids)), ",") + args := make([]any, len(ids)) + for i, id := range ids { + args[i] = id + } + return marks, args + } + + type monStatus struct { + ID string `json:"id"` + Name string `json:"name"` + Status string `json:"status"` // up|down|pending|maintenance + Uptime24h float64 `json:"uptime24h"` + } + mons := []monStatus{} + mq := `SELECT id, name, status FROM monitors WHERE active = 1` + marks, args := pick(p.MonitorIDs) + if marks != "" { + mq += ` AND id IN (` + marks + `)` + } + mq += ` ORDER BY name` + if rows, err := s.db.Query(mq, args...); err == nil { + for rows.Next() { + var m monStatus + if rows.Scan(&m.ID, &m.Name, &m.Status) == nil { + if s.inMaintenance(m.ID) { + m.Status = "maintenance" + } + m.Uptime24h = s.monitorStats(m.ID).Uptime24h + mons = append(mons, m) + } + } + rows.Close() + } + + type sysStatus struct { + ID string `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + } + systems := []sysStatus{} + sq := `SELECT id, name, status FROM systems` + marks, args = pick(p.SystemIDs) + if marks != "" { + sq += ` WHERE id IN (` + marks + `)` + } + sq += ` ORDER BY name` + if rows, err := s.db.Query(sq, args...); err == nil { + for rows.Next() { + var sm sysStatus + if rows.Scan(&sm.ID, &sm.Name, &sm.Status) == nil { + systems = append(systems, sm) + } + } + rows.Close() + } + + incidents := []Incident{} + if rows, err := s.db.Query(`SELECT ` + incidentCols + ` FROM incidents + WHERE status IN ('open','ack','resolved') + ORDER BY created_at DESC LIMIT 20`); err == nil { + for rows.Next() { + in, err := scanIncident(rows) + if err == nil { + in.Updates = s.incidentUpdates(in.ID) + incidents = append(incidents, *in) + } + } + rows.Close() + } + + maintenance := []MaintWindow{} + if rows, err := s.db.Query(`SELECT ` + windowCols + ` FROM maintenance_windows + WHERE datetime(ends_at) > datetime('now') ORDER BY starts_at LIMIT 10`); err == nil { + for rows.Next() { + if w, err := scanWindow(rows); err == nil { + maintenance = append(maintenance, *w) + } + } + rows.Close() + } + + // Overall state: down wins over maintenance wins over pending. + overall := "up" + for _, m := range mons { + switch m.Status { + case "down": + overall = "down" + case "maintenance": + if overall == "up" { + overall = "maintenance" + } + case "pending": + if overall == "up" { + overall = "pending" + } + } + } + for _, sm := range systems { + if sm.Status == "down" { + overall = "down" + } + } + + c.JSON(http.StatusOK, gin.H{ + "title": p.Title, + "slug": p.Slug, + "description": p.Description, + "overall": overall, + "monitors": mons, + "systems": systems, + "incidents": incidents, + "maintenance": maintenance, + }) +} + +// ---------- auto-incident hook ---------- + +// autoIncident opens an incident when a monitor goes down and resolves the +// matching open incident when it recovers. Suppressed during maintenance. +func (s *Server) autoIncident(monitorID, name, next string) { + if s.inMaintenance(monitorID) { + return + } + if next == "down" { + // Don't stack duplicates on a flap loop. + var open string + if err := s.db.QueryRow(`SELECT id FROM incidents + WHERE monitor_id = ? AND status IN ('open','ack')`, monitorID).Scan(&open); err == nil { + return + } + id := newID("inc") + title := fmt.Sprintf("Monitor %q is down", name) + if _, err := s.db.Exec(`INSERT INTO incidents (id, title, severity, status, monitor_id) + VALUES (?,?,'major','open',?)`, id, title, monitorID); err != nil { + s.log.Error("auto incident", zap.Error(err)) + return + } + _, _ = s.db.Exec(`INSERT INTO incident_updates (incident_id, status, message) + VALUES (?,'open','Opened automatically — monitor reported down.')`, id) + return + } + if next == "up" { + res, err := s.db.Exec(`UPDATE incidents SET status = 'resolved', + updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') + WHERE monitor_id = ? AND status IN ('open','ack')`, monitorID) + if err != nil { + return + } + if n, _ := res.RowsAffected(); n > 0 { + rows, _ := s.db.Query(`SELECT id FROM incidents WHERE monitor_id = ? AND status = 'resolved'`, monitorID) + var ids []string + if rows != nil { + for rows.Next() { + var id string + if rows.Scan(&id) == nil { + ids = append(ids, id) + } + } + rows.Close() + } + for _, id := range ids { + _, _ = s.db.Exec(`INSERT INTO incident_updates (incident_id, status, message) + VALUES (?,'resolved','Resolved automatically — monitor recovered.')`, id) + } + } + } +} diff --git a/apps/backend/internal/api/transfer.go b/apps/backend/internal/api/transfer.go index 19580d7..4002cfa 100644 --- a/apps/backend/internal/api/transfer.go +++ b/apps/backend/internal/api/transfer.go @@ -23,10 +23,49 @@ func failImport(c *gin.Context, err error) { } // Export is the portable backup shape: version + settings + board tree. +// v2 adds monitoring entities alongside — additive, so v1 files still import. type Export struct { Version int `json:"version"` Settings map[string]json.RawMessage `json:"settings"` Sections []Section `json:"sections"` + Monitors []json.RawMessage `json:"monitors,omitempty"` + Domains []json.RawMessage `json:"domains,omitempty"` + Systems []json.RawMessage `json:"systems,omitempty"` +} + +// dumpRows serializes each row of a table to JSON via the driver's column +// metadata — schema drift can't silently drop columns from the backup. +func (s *Server) dumpRows(table string) ([]json.RawMessage, error) { + rows, err := s.db.Query(`SELECT * FROM ` + table) // table is a fixed literal + if err != nil { + return nil, err + } + defer rows.Close() + cols, err := rows.Columns() + if err != nil { + return nil, err + } + out := []json.RawMessage{} + for rows.Next() { + vals := make([]any, len(cols)) + ptrs := make([]any, len(cols)) + for i := range vals { + ptrs[i] = &vals[i] + } + if err := rows.Scan(ptrs...); err != nil { + return nil, err + } + obj := map[string]any{} + for i, col := range cols { + obj[col] = vals[i] + } + b, err := json.Marshal(obj) + if err != nil { + return nil, err + } + out = append(out, b) + } + return out, rows.Err() } func (s *Server) exportBoard(c *gin.Context) { @@ -40,7 +79,20 @@ func (s *Server) exportBoard(c *gin.Context) { fail(c, http.StatusInternalServerError, err.Error()) return } - c.JSON(http.StatusOK, Export{Version: 1, Settings: settings, Sections: sections}) + ex := Export{Version: 2, Settings: settings, Sections: sections} + for _, t := range []struct { + name string + dst *[]json.RawMessage + }{ + {"monitors", &ex.Monitors}, + {"domains", &ex.Domains}, + {"systems", &ex.Systems}, + } { + if rows, err := s.dumpRows(t.name); err == nil { + *t.dst = rows + } + } + c.JSON(http.StatusOK, ex) } // importBoard replaces board state inside one transaction. Ids from the diff --git a/apps/backend/internal/db/migrations/0006_ops.sql b/apps/backend/internal/db/migrations/0006_ops.sql new file mode 100644 index 0000000..d1dd8d9 --- /dev/null +++ b/apps/backend/internal/db/migrations/0006_ops.sql @@ -0,0 +1,45 @@ +-- +goose Up +CREATE TABLE incidents ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + severity TEXT NOT NULL DEFAULT 'major', -- minor | major | critical + status TEXT NOT NULL DEFAULT 'open', -- open | ack | resolved | closed + monitor_id TEXT REFERENCES monitors(id) ON DELETE SET NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) +); +CREATE INDEX idx_incidents_status ON incidents(status); + +CREATE TABLE incident_updates ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + incident_id TEXT NOT NULL REFERENCES incidents(id) ON DELETE CASCADE, + status TEXT NOT NULL, + message TEXT NOT NULL DEFAULT '', + at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) +); +CREATE INDEX idx_incident_updates_inc ON incident_updates(incident_id); + +CREATE TABLE maintenance_windows ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + starts_at TEXT NOT NULL, + ends_at TEXT NOT NULL, + monitor_ids TEXT NOT NULL DEFAULT '[]', -- JSON array; empty covers all + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) +); + +CREATE TABLE status_pages ( + id TEXT PRIMARY KEY, + slug TEXT NOT NULL UNIQUE, + title TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + monitor_ids TEXT NOT NULL DEFAULT '[]', -- JSON array; empty = all monitors + system_ids TEXT NOT NULL DEFAULT '[]', + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) +); + +-- +goose Down +DROP TABLE IF EXISTS status_pages; +DROP TABLE IF EXISTS maintenance_windows; +DROP TABLE IF EXISTS incident_updates; +DROP TABLE IF EXISTS incidents; diff --git a/apps/frontend/src/App.tsx b/apps/frontend/src/App.tsx index 0912a47..e3d40f0 100644 --- a/apps/frontend/src/App.tsx +++ b/apps/frontend/src/App.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from "react"; import { Link, Route, useLocation } from "wouter"; -import { Activity, Globe, LayoutGrid, Moon, Plus, Search, Server, Settings, Sun } from "lucide-react"; +import { Activity, AlertTriangle, Globe, LayoutGrid, Megaphone, Moon, Plus, Search, Server, Settings, Sun } from "lucide-react"; import { BoardProvider, useBoard } from "@/board/store"; import { Board } from "@/board/Board"; import { ServiceDialog } from "@/components/ServiceDialog"; @@ -13,6 +13,9 @@ import { DomainsPage } from "@/pages/DomainsPage"; import { DomainDetailPage } from "@/pages/DomainDetailPage"; import { SystemsPage } from "@/pages/SystemsPage"; import { SystemDetailPage } from "@/pages/SystemDetailPage"; +import { IncidentsPage } from "@/pages/IncidentsPage"; +import { StatusPagesPage } from "@/pages/StatusPagesPage"; +import { StatusPublicPage } from "@/pages/StatusPublicPage"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; import type { Item } from "@/types"; @@ -30,6 +33,8 @@ const NAV = [ { href: "/monitors", label: "Monitors", icon: Activity }, { href: "/domains", label: "Domains", icon: Globe }, { href: "/systems", label: "Systems", icon: Server }, + { href: "/incidents", label: "Incidents", icon: AlertTriangle }, + { href: "/status", label: "Status", icon: Megaphone }, ]; function Shell() { @@ -150,6 +155,15 @@ function Shell() { {(p) => } + + + + + + + + {(p) => } + diff --git a/apps/frontend/src/components/CommandPalette.tsx b/apps/frontend/src/components/CommandPalette.tsx index ccfba49..1ddec58 100644 --- a/apps/frontend/src/components/CommandPalette.tsx +++ b/apps/frontend/src/components/CommandPalette.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { useLocation } from "wouter"; -import { Activity, ArrowRight, Download, Globe, LayoutGrid, Moon, Plus, Server, Settings, Sun } from "lucide-react"; +import { Activity, AlertTriangle, ArrowRight, Download, Globe, LayoutGrid, Megaphone, Moon, Plus, Server, Settings, Sun } from "lucide-react"; import { useBoard } from "@/board/store"; import { hostOf, IconImg } from "@/board/primitives"; import { rendererLabels, rendererList } from "@/renderers"; @@ -46,6 +46,8 @@ export function CommandPalette({ { id: "nav-monitors", group: "Actions", glyph: Activity, label: "Go to Monitors", run: () => navigate("/monitors") }, { id: "nav-domains", group: "Actions", glyph: Globe, label: "Go to Domains", run: () => navigate("/domains") }, { id: "nav-systems", group: "Actions", glyph: Server, label: "Go to Systems", run: () => navigate("/systems") }, + { id: "nav-incidents", group: "Actions", glyph: AlertTriangle, label: "Go to Incidents", run: () => navigate("/incidents") }, + { id: "nav-status", group: "Actions", glyph: Megaphone, label: "Go to Status pages", run: () => navigate("/status") }, { id: "add-service", group: "Actions", glyph: Plus, label: "Add service", hint: "new", run: onAddService }, { id: "add-widget", group: "Actions", glyph: Plus, label: "Add widget", hint: "new", run: onAddWidget }, { id: "settings", group: "Actions", glyph: Settings, label: "Open settings", run: onSettings }, diff --git a/apps/frontend/src/pages/IncidentsPage.tsx b/apps/frontend/src/pages/IncidentsPage.tsx new file mode 100644 index 0000000..3c9e788 --- /dev/null +++ b/apps/frontend/src/pages/IncidentsPage.tsx @@ -0,0 +1,198 @@ +import { useCallback, useEffect, useState } from "react"; +import { Plus, Trash2 } from "lucide-react"; +import { api } from "@/api"; +import type { Incident } from "@/types"; +import { Button } from "@/components/ui/button"; +import { + Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { cn } from "@/lib/utils"; + +const SEV_COLOR = { + critical: "text-down", + major: "text-down", + minor: "text-text-faint", +} satisfies Record, string>; + +type Status = NonNullable; +const NEXT: Partial> = { + open: [{ label: "Acknowledge", to: "ack" }, { label: "Resolve", to: "resolved" }], + ack: [{ label: "Resolve", to: "resolved" }], + resolved: [{ label: "Close", to: "closed" }], +}; + +export function IncidentsPage() { + const [incidents, setIncidents] = useState([]); + const [loaded, setLoaded] = useState(false); + const [showAll, setShowAll] = useState(false); + const [dlgOpen, setDlgOpen] = useState(false); + const [title, setTitle] = useState(""); + const [sev, setSev] = useState("major"); + const [msg, setMsg] = useState(""); + const [busy, setBusy] = useState(false); + const [expanded, setExpanded] = useState(null); + const [note, setNote] = useState(""); + + const load = useCallback(async () => { + const { data } = await api.GET("/api/incidents", showAll ? {} : { params: { query: { status: "open" } } }); + if (data) setIncidents(data); + setLoaded(true); + }, [showAll]); + + useEffect(() => { + void load(); + const t = setInterval(() => void load(), 30_000); + return () => clearInterval(t); + }, [load]); + + async function create() { + if (!title.trim()) return; + setBusy(true); + // The select only offers the three severities, but its value is a + // plain string — narrow at the boundary instead of asserting. + const severity = sev === "critical" || sev === "minor" ? sev : "major"; + await api.POST("/api/incidents", { body: { title: title.trim(), severity, message: msg || undefined } }); + setBusy(false); + setDlgOpen(false); + setTitle(""); setMsg(""); + void load(); + } + + async function transition(id: string | undefined, to: Status, message?: string) { + if (!id) return; + await api.PATCH("/api/incidents/{id}", { params: { path: { id } }, body: { status: to, message } }); + await load(); + } + + async function addNote(id: string | undefined) { + if (!id || !note.trim()) return; + await api.POST("/api/incidents/{id}/updates", { params: { path: { id } }, body: { message: note.trim() } }); + setNote(""); + await load(); + } + + return ( +
+
+
+

Incidents

+

Auto-opened on monitor outages, or tracked manually.

+
+
+ + +
+
+ + {loaded && incidents.length === 0 ? ( +
+

No open incidents.

+
+ ) : ( +
+ {incidents.map((inc) => ( +
+
+ + {inc.severity} + + + {inc.status} + + {inc.createdAt ? new Date(inc.createdAt).toLocaleString() : ""} + + {(inc.status ? (NEXT[inc.status] ?? []) : []).map((n) => ( + + ))} + +
+ {expanded === inc.id && ( +
+
+ {(inc.updates ?? []).map((u) => ( +
+ {u.status} + {u.message} + + {u.at ? new Date(u.at).toLocaleString() : ""} + +
+ ))} + {(inc.updates ?? []).length === 0 && ( +

No updates yet.

+ )} +
+
+ setNote(e.target.value)} + placeholder="Add a note…" className="h-8 text-[12px]" + onKeyDown={(e) => { if (e.key === "Enter") void addNote(inc.id); }} /> + +
+
+ )} +
+ ))} +
+ )} + + + + + New incident + Track an outage or degradation manually. + +
+
+ + setTitle(e.target.value)} + placeholder="NAS offline" autoFocus /> +
+
+
+ + +
+
+ + setMsg(e.target.value)} placeholder="First observed…" /> +
+
+
+ + + + +
+
+
+ ); +} diff --git a/apps/frontend/src/pages/StatusPagesPage.tsx b/apps/frontend/src/pages/StatusPagesPage.tsx new file mode 100644 index 0000000..2afea4b --- /dev/null +++ b/apps/frontend/src/pages/StatusPagesPage.tsx @@ -0,0 +1,245 @@ +import { useCallback, useEffect, useState } from "react"; +import { Link } from "wouter"; +import { ExternalLink, Plus, Trash2 } from "lucide-react"; +import { api } from "@/api"; +import type { MaintenanceWindow, MonitorView, StatusPage } from "@/types"; +import { Button } from "@/components/ui/button"; +import { + Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { cn } from "@/lib/utils"; + +// Admin for public status pages + maintenance windows. The public view +// itself lives at /status/:slug. + +export function StatusPagesPage() { + const [pages, setPages] = useState([]); + const [windows, setWindows] = useState([]); + const [monitors, setMonitors] = useState([]); + const [loaded, setLoaded] = useState(false); + + const [pageOpen, setPageOpen] = useState(false); + const [pTitle, setPTitle] = useState(""); + const [pSlug, setPSlug] = useState(""); + const [pDesc, setPDesc] = useState(""); + const [pMonitors, setPMonitors] = useState([]); + + const [winOpen, setWinOpen] = useState(false); + const [wTitle, setWTitle] = useState(""); + const [wStart, setWStart] = useState(""); + const [wEnd, setWEnd] = useState(""); + const [busy, setBusy] = useState(false); + const [err, setErr] = useState(""); + + const load = useCallback(async () => { + const [{ data: p }, { data: w }, { data: m }] = await Promise.all([ + api.GET("/api/status-pages"), + api.GET("/api/maintenance"), + api.GET("/api/monitors"), + ]); + if (p) setPages(p); + if (w) setWindows(w); + if (m) setMonitors(m); + setLoaded(true); + }, []); + + useEffect(() => { + void load(); + const t = setInterval(() => void load(), 30_000); + return () => clearInterval(t); + }, [load]); + + async function createPage() { + if (!pTitle.trim()) return; + setBusy(true); setErr(""); + const { error } = await api.POST("/api/status-pages", { + body: { + title: pTitle.trim(), + slug: pSlug.trim() || undefined, + description: pDesc.trim(), + monitorIds: pMonitors, + }, + }); + setBusy(false); + if (error) { setErr("Could not create — slug may be taken."); return; } + setPageOpen(false); setPTitle(""); setPSlug(""); setPDesc(""); setPMonitors([]); + void load(); + } + + async function createWindow() { + if (!wTitle.trim() || !wStart || !wEnd) return; + setBusy(true); setErr(""); + const { error } = await api.POST("/api/maintenance", { + body: { + title: wTitle.trim(), + startsAt: new Date(wStart).toISOString(), + endsAt: new Date(wEnd).toISOString(), + }, + }); + setBusy(false); + if (error) { setErr("Invalid window — check times."); return; } + setWinOpen(false); setWTitle(""); setWStart(""); setWEnd(""); + void load(); + } + + return ( +
+
+
+

Status pages

+

Public live views at /status/<slug>.

+
+ +
+ + {loaded && pages.length === 0 ? ( +
+

No status pages yet.

+
+ ) : ( +
+ {pages.map((p) => ( +
+ {p.title} + /status/{p.slug} + + + + +
+ ))} +
+ )} + +
+
+

Maintenance windows

+

Active windows mute alerts + auto-incidents and mark covered monitors.

+
+ +
+ + {loaded && windows.length === 0 ? ( +
+

No maintenance scheduled.

+
+ ) : ( +
+ {windows.map((w) => ( +
+ + {w.title} + + {new Date(w.startsAt ?? "").toLocaleString()} → {new Date(w.endsAt ?? "").toLocaleString()} + + + {w.active ? "active" : "scheduled"} + + +
+ ))} +
+ )} + + + + + New status page + Public live view over selected monitors. + +
+
+
+ + setPTitle(e.target.value)} placeholder="Homelab status" autoFocus /> +
+
+ + setPSlug(e.target.value)} placeholder="auto" className="font-mono text-[12px]" /> +
+
+
+ + setPDesc(e.target.value)} placeholder="optional" /> +
+
+ +
+ {monitors.map((m) => ( + + ))} + {monitors.length === 0 &&

No monitors yet — empty selection shows all.

} +
+

Leave all unchecked to include every active monitor.

+
+ {err ?

{err}

: null} +
+ + + + +
+
+ + + + + Schedule maintenance + Suppresses alerts and auto-incidents for the window. + +
+
+ + setWTitle(e.target.value)} placeholder="Network upgrade" autoFocus /> +
+
+
+ + setWStart(e.target.value)} /> +
+
+ + setWEnd(e.target.value)} /> +
+
+ {err ?

{err}

: null} +
+ + + + +
+
+
+ ); +} diff --git a/apps/frontend/src/pages/StatusPublicPage.tsx b/apps/frontend/src/pages/StatusPublicPage.tsx new file mode 100644 index 0000000..6cfd762 --- /dev/null +++ b/apps/frontend/src/pages/StatusPublicPage.tsx @@ -0,0 +1,128 @@ +import { useCallback, useEffect, useState } from "react"; +import { api } from "@/api"; +import type { PublicStatus } from "@/types"; +import { cn } from "@/lib/utils"; + +// Public status page — clean read-only view; refreshes every 30s. +// Route: /status/:slug -> GET /api/status-pages/:slug/public. + +// status strings arrive as plain `string` — map with functions, not +// open dictionaries, so the lookup type stays honest. +function overallBanner(overall?: string) { + if (overall === "down") return { label: "Major outage", cls: "bg-down" }; + if (overall === "maintenance") return { label: "Under maintenance", cls: "bg-text-faint/60" }; + if (overall === "up") return { label: "All systems operational", cls: "bg-up" }; + return { label: "Checking…", cls: "bg-text-faint/40" }; +} + +function dotCls(status?: string) { + if (status === "up") return "bg-up"; + if (status === "down") return "bg-down"; + if (status === "maintenance") return "bg-text-faint/60"; + return "bg-text-faint/40"; +} + +export function StatusPublicPage({ slug }: { slug: string }) { + const [st, setSt] = useState(null); + const [missing, setMissing] = useState(false); + + const load = useCallback(async () => { + const { data, response } = await api.GET("/api/status-pages/{slug}/public", { params: { path: { slug } } }); + if (response.status === 404) setMissing(true); + if (data) setSt(data); + }, [slug]); + + useEffect(() => { + void load(); + const t = setInterval(() => void load(), 30_000); + return () => clearInterval(t); + }, [load]); + + if (missing) return
Status page not found.
; + if (!st) return
Loading…
; + + const banner = overallBanner(st.overall); + + return ( +
+
+

{st.title}

+ {st.description ?

{st.description}

: null} +
+ +
+ {banner.label} +
+ + {(st.monitors ?? []).length > 0 && ( +
+

Services

+
+ {(st.monitors ?? []).map((m) => ( +
+ + {m.name} + {m.uptime24h?.toFixed(1)}% + {m.status} +
+ ))} +
+
+ )} + + {(st.systems ?? []).length > 0 && ( +
+

Systems

+
+ {(st.systems ?? []).map((s) => ( +
+ + {s.name} + {s.status} +
+ ))} +
+
+ )} + + {(st.maintenance ?? []).length > 0 && ( +
+

Maintenance

+
+ {(st.maintenance ?? []).map((w) => ( +
+ {w.title} + + {new Date(w.startsAt ?? "").toLocaleString()} → {new Date(w.endsAt ?? "").toLocaleString()} + +
+ ))} +
+
+ )} + + {(st.incidents ?? []).length > 0 && ( +
+

Incidents

+
+ {(st.incidents ?? []).map((inc) => ( +
+
+ {inc.title} + {inc.status} +
+ {(inc.updates ?? []).slice(-3).map((u) => ( +

+ {u.status} — {u.message} +

+ ))} +
+ ))} +
+
+ )} + +

powered by dash

+
+ ); +} diff --git a/apps/frontend/src/types.ts b/apps/frontend/src/types.ts index 816bed9..5cd968f 100644 --- a/apps/frontend/src/types.ts +++ b/apps/frontend/src/types.ts @@ -24,6 +24,11 @@ export type System = components["schemas"]["System"]; export type StatSample = components["schemas"]["StatSample"]; /** Stats rows are StatSample plus the server-side timestamp. */ export type SystemStat = StatSample & { ts?: string }; +export type Incident = components["schemas"]["Incident"]; +export type IncidentInput = components["schemas"]["IncidentInput"]; +export type MaintenanceWindow = components["schemas"]["MaintenanceWindow"]; +export type StatusPage = components["schemas"]["StatusPage"]; +export type PublicStatus = components["schemas"]["PublicStatus"]; export type Theme = "dark" | "light"; export type RendererName = "bento" | "cards" | "index" | "console"; diff --git a/openapi.yaml b/openapi.yaml index fec49f4..ef1fa5d 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -674,6 +674,273 @@ paths: "400": $ref: "#/components/responses/BadRequest" + /api/incidents: + get: + operationId: listIncidents + parameters: + - name: status + in: query + description: "`open` filters to open+ack" + schema: { type: string } + responses: + "200": + description: Incidents newest-first, updates embedded + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Incident" + post: + operationId: createIncident + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/IncidentInput" + responses: + "201": + description: Created incident + content: + application/json: + schema: + $ref: "#/components/schemas/Incident" + "400": + $ref: "#/components/responses/BadRequest" + + /api/incidents/{id}: + parameters: + - $ref: "#/components/parameters/id" + patch: + operationId: patchIncident + summary: Update fields; a status change appends a timeline entry + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/IncidentInput" + responses: + "200": + description: Updated incident + content: + application/json: + schema: + $ref: "#/components/schemas/Incident" + "404": + $ref: "#/components/responses/NotFound" + delete: + operationId: deleteIncident + responses: + "204": + description: Deleted + "404": + $ref: "#/components/responses/NotFound" + + /api/incidents/{id}/updates: + parameters: + - $ref: "#/components/parameters/id" + post: + operationId: addIncidentUpdate + summary: Append a timeline note; `status` also moves the incident + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/IncidentInput" + responses: + "200": + description: Incident with updated timeline + content: + application/json: + schema: + $ref: "#/components/schemas/Incident" + "404": + $ref: "#/components/responses/NotFound" + + /api/maintenance: + get: + operationId: listWindows + responses: + "200": + description: Maintenance windows newest-first + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/MaintenanceWindow" + post: + operationId: createWindow + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/MaintenanceWindowInput" + responses: + "201": + description: Created window + content: + application/json: + schema: + $ref: "#/components/schemas/MaintenanceWindow" + "400": + $ref: "#/components/responses/BadRequest" + + /api/maintenance/{id}: + parameters: + - $ref: "#/components/parameters/id" + delete: + operationId: deleteWindow + responses: + "204": + description: Deleted + "404": + $ref: "#/components/responses/NotFound" + + /api/status-pages: + get: + operationId: listStatusPages + responses: + "200": + description: All status pages + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/StatusPage" + post: + operationId: createStatusPage + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/StatusPageInput" + responses: + "201": + description: Created page + content: + application/json: + schema: + $ref: "#/components/schemas/StatusPage" + "409": + description: Slug already in use + "400": + $ref: "#/components/responses/BadRequest" + + /api/status-pages/{id}: + parameters: + - $ref: "#/components/parameters/id" + patch: + operationId: patchStatusPage + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/StatusPageInput" + responses: + "200": + description: Updated page + content: + application/json: + schema: + $ref: "#/components/schemas/StatusPage" + "404": + $ref: "#/components/responses/NotFound" + delete: + operationId: deleteStatusPage + responses: + "204": + description: Deleted + "404": + $ref: "#/components/responses/NotFound" + + /api/status-pages/{slug}/public: + parameters: + - name: slug + in: path + required: true + schema: { type: string } + get: + operationId: publicStatus + summary: Live public view for a status page + responses: + "200": + description: Title, overall state, monitors, systems, incidents, maintenance + content: + application/json: + schema: + $ref: "#/components/schemas/PublicStatus" + "404": + $ref: "#/components/responses/NotFound" + + /api/badge/{kind}/{file}: + parameters: + - name: kind + in: path + required: true + schema: { type: string, enum: [monitor, domain, system] } + - name: file + in: path + required: true + description: "Entity id, conventionally suffixed .svg" + schema: { type: string } + get: + operationId: badge + summary: Stateless SVG shield from live state + responses: + "200": + description: SVG badge + content: + image/svg+xml: {} + "404": + $ref: "#/components/responses/NotFound" + + /api/metrics: + get: + operationId: metrics + summary: Prometheus text exposition + responses: + "200": + description: text/plain metrics + content: + text/plain: {} + + /api/import/csv: + post: + operationId: importCSV + summary: "Bulk import from CSV; ?kind=monitors|domains" + parameters: + - name: kind + in: query + schema: { type: string, enum: [monitors, domains], default: monitors } + requestBody: + required: true + content: + text/csv: + schema: { type: string } + responses: + "200": + description: Import summary + content: + application/json: + schema: + type: object + properties: + created: { type: integer } + skipped: { type: integer } + errors: + type: array + items: { type: string } + "400": + $ref: "#/components/responses/BadRequest" + /api/settings: get: operationId: getSettings @@ -1380,6 +1647,123 @@ components: createdAt: type: string format: date-time + IncidentUpdate: + type: object + properties: + id: { type: integer } + status: { type: string } + message: { type: string } + at: { type: string, format: date-time } + Incident: + type: object + required: [id, title, severity, status] + properties: + id: { type: string } + title: { type: string } + severity: { type: string, enum: [minor, major, critical] } + status: { type: string, enum: [open, ack, resolved, closed] } + monitorId: { type: string, nullable: true } + updates: + type: array + items: + $ref: "#/components/schemas/IncidentUpdate" + createdAt: { type: string, format: date-time } + updatedAt: { type: string, format: date-time } + IncidentInput: + type: object + properties: + title: { type: string } + severity: { type: string, enum: [minor, major, critical] } + status: { type: string, enum: [open, ack, resolved, closed] } + monitorId: { type: string } + message: + type: string + description: Timeline note attached to the create/transition + MaintenanceWindow: + type: object + required: [id, title, startsAt, endsAt] + properties: + id: { type: string } + title: { type: string } + startsAt: { type: string, format: date-time } + endsAt: { type: string, format: date-time } + monitorIds: + type: array + items: { type: string } + description: Empty array covers all monitors + active: { type: boolean } + createdAt: { type: string, format: date-time } + MaintenanceWindowInput: + type: object + required: [title, startsAt, endsAt] + properties: + title: { type: string } + startsAt: { type: string, format: date-time } + endsAt: { type: string, format: date-time } + monitorIds: + type: array + items: { type: string } + StatusPage: + type: object + required: [id, slug, title] + properties: + id: { type: string } + slug: { type: string } + title: { type: string } + description: { type: string } + monitorIds: + type: array + items: { type: string } + description: Empty array means all active monitors + systemIds: + type: array + items: { type: string } + createdAt: { type: string, format: date-time } + StatusPageInput: + type: object + required: [title] + properties: + slug: { type: string } + title: { type: string } + description: { type: string } + monitorIds: + type: array + items: { type: string } + systemIds: + type: array + items: { type: string } + PublicStatus: + type: object + properties: + title: { type: string } + slug: { type: string } + description: { type: string } + overall: { type: string, enum: [up, down, maintenance, pending] } + monitors: + type: array + items: + type: object + properties: + id: { type: string } + name: { type: string } + status: { type: string } + uptime24h: { type: number } + systems: + type: array + items: + type: object + properties: + id: { type: string } + name: { type: string } + status: { type: string } + incidents: + type: array + items: + $ref: "#/components/schemas/Incident" + maintenance: + type: array + items: + $ref: "#/components/schemas/MaintenanceWindow" WidgetField: type: object required: [key, label]