feat: ops layer — incidents, maintenance, status pages, badges, metrics (merge plan M4)

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>
This commit is contained in:
Tomas DvorakandDevin committed 2026-09-21 19:21:21 +02:00
1 parent 74c987c18a
commit c84a4be876
17 files changed
+2212 -11

No files matched your search

+20
View File
@@ -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)
+123
View File
@@ -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 {
+92
View File
@@ -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/<kind>/<id>.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(`<svg xmlns="http://www.w3.org/2000/svg" width="%d" height="20" role="img" aria-label="%s: %s">
<linearGradient id="s" x2="0" y2="100%%"><stop offset="0" stop-color="#fff" stop-opacity=".7"/><stop offset="1" stop-opacity=".7"/></linearGradient>
<clipPath id="r"><rect width="%d" height="20" rx="3"/></clipPath>
<g clip-path="url(#r)">
<rect width="%d" height="20" fill="#3a3f46"/>
<rect x="%d" width="%d" height="20" fill="%s"/>
<rect width="%d" height="20" fill="url(#s)" fill-opacity=".1"/>
</g>
<g fill="#fff" font-family="DejaVu Sans,Verdana,sans-serif" font-size="11">
<text x="%d" y="14">%s</text>
<text x="%d" y="14">%s</text>
</g></svg>`,
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/<kind>/<file>; 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")
}
}
+96
View File
@@ -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})
}
+75
View File
@@ -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()))
}
+6 -1
View File
@@ -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
+717
View File
@@ -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)
}
}
}
}
+53 -1
View File
@@ -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
@@ -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;