mirror of
https://github.com/Dvorinka/Dash.git
synced 2026-10-28 07:41:27 +00:00
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:
1 parent
74c987c18a
commit
c84a4be876
17 files changed
+2212
-11
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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()))
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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() {
|
||||
<Route path="/systems/:id">
|
||||
{(p) => <SystemDetailPage id={p.id} />}
|
||||
</Route>
|
||||
<Route path="/incidents">
|
||||
<IncidentsPage />
|
||||
</Route>
|
||||
<Route path="/status">
|
||||
<StatusPagesPage />
|
||||
</Route>
|
||||
<Route path="/status/:slug">
|
||||
{(p) => <StatusPublicPage slug={p.slug} />}
|
||||
</Route>
|
||||
|
||||
<ServiceDialog open={svcOpen} onOpenChange={setSvcOpen} item={editing?.kind === "service" ? editing : undefined} />
|
||||
<WidgetDialog open={wdgOpen} onOpenChange={setWdgOpen} item={editing?.kind === "widget" ? editing : undefined} />
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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<NonNullable<Incident["severity"]>, string>;
|
||||
|
||||
type Status = NonNullable<Incident["status"]>;
|
||||
const NEXT: Partial<Record<Status, { label: string; to: Status }[]>> = {
|
||||
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<Incident[]>([]);
|
||||
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<string | null>(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 (
|
||||
<main className="mx-auto w-full max-w-5xl px-7 py-8">
|
||||
<div className="mb-5 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-[15px] font-semibold tracking-tight">Incidents</h1>
|
||||
<p className="text-[12px] text-text-faint">Auto-opened on monitor outages, or tracked manually.</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setShowAll((v) => !v)}>
|
||||
{showAll ? "Open only" : "Show all"}
|
||||
</Button>
|
||||
<Button onClick={() => setDlgOpen(true)}>
|
||||
<Plus size={13} strokeWidth={2.2} /> New incident
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loaded && incidents.length === 0 ? (
|
||||
<div className="rounded-[10px] border border-dashed border-border px-6 py-14 text-center">
|
||||
<p className="text-[13px] text-text-dim">No open incidents.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{incidents.map((inc) => (
|
||||
<div key={inc.id} className="rounded-[10px] border border-border bg-surface px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={cn("font-mono text-[10px] uppercase tracking-[0.08em]",
|
||||
inc.severity ? SEV_COLOR[inc.severity] : "text-text-faint")}>
|
||||
{inc.severity}
|
||||
</span>
|
||||
<button type="button" className="min-w-0 flex-1 truncate text-left text-[13px] font-medium hover:underline"
|
||||
onClick={() => setExpanded(expanded === inc.id ? null : (inc.id ?? null))}>
|
||||
{inc.title}
|
||||
</button>
|
||||
<span className="font-mono text-[10.5px] uppercase text-text-faint">{inc.status}</span>
|
||||
<span className="font-mono text-[10.5px] text-text-faint">
|
||||
{inc.createdAt ? new Date(inc.createdAt).toLocaleString() : ""}
|
||||
</span>
|
||||
{(inc.status ? (NEXT[inc.status] ?? []) : []).map((n) => (
|
||||
<Button key={n.to} variant="outline" size="sm" onClick={() => void transition(inc.id, n.to)}>
|
||||
{n.label}
|
||||
</Button>
|
||||
))}
|
||||
<Button variant="outline" size="icon" aria-label="Delete"
|
||||
onClick={() => {
|
||||
if (inc.id && confirm(`Delete incident "${inc.title}"?`)) {
|
||||
void api.DELETE("/api/incidents/{id}", { params: { path: { id: inc.id } } }).then(load);
|
||||
}
|
||||
}}>
|
||||
<Trash2 size={12} />
|
||||
</Button>
|
||||
</div>
|
||||
{expanded === inc.id && (
|
||||
<div className="mt-3 border-t border-border/60 pt-3">
|
||||
<div className="mb-2.5 flex flex-col gap-1.5">
|
||||
{(inc.updates ?? []).map((u) => (
|
||||
<div key={u.id} className="flex items-baseline gap-2.5 text-[12px]">
|
||||
<span className="shrink-0 font-mono text-[10px] uppercase tracking-wide text-text-faint">{u.status}</span>
|
||||
<span className="flex-1 text-text-dim">{u.message}</span>
|
||||
<span className="shrink-0 font-mono text-[10px] text-text-faint">
|
||||
{u.at ? new Date(u.at).toLocaleString() : ""}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{(inc.updates ?? []).length === 0 && (
|
||||
<p className="text-[11.5px] text-text-faint">No updates yet.</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Input value={note} onChange={(e) => setNote(e.target.value)}
|
||||
placeholder="Add a note…" className="h-8 text-[12px]"
|
||||
onKeyDown={(e) => { if (e.key === "Enter") void addNote(inc.id); }} />
|
||||
<Button variant="outline" size="sm" onClick={() => void addNote(inc.id)}>Post</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Dialog open={dlgOpen} onOpenChange={setDlgOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>New incident</DialogTitle>
|
||||
<DialogDescription>Track an outage or degradation manually.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="inc-title">Title</Label>
|
||||
<Input id="inc-title" value={title} onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="NAS offline" autoFocus />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>Severity</Label>
|
||||
<Select value={sev} onValueChange={setSev}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="minor">minor</SelectItem>
|
||||
<SelectItem value="major">major</SelectItem>
|
||||
<SelectItem value="critical">critical</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="inc-msg">Note (optional)</Label>
|
||||
<Input id="inc-msg" value={msg} onChange={(e) => setMsg(e.target.value)} placeholder="First observed…" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDlgOpen(false)} disabled={busy}>Cancel</Button>
|
||||
<Button onClick={() => void create()} disabled={busy || !title.trim()}>
|
||||
{busy ? "Creating…" : "Create"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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<StatusPage[]>([]);
|
||||
const [windows, setWindows] = useState<MaintenanceWindow[]>([]);
|
||||
const [monitors, setMonitors] = useState<MonitorView[]>([]);
|
||||
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<string[]>([]);
|
||||
|
||||
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 (
|
||||
<main className="mx-auto w-full max-w-5xl px-7 py-8">
|
||||
<div className="mb-5 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-[15px] font-semibold tracking-tight">Status pages</h1>
|
||||
<p className="text-[12px] text-text-faint">Public live views at <code className="font-mono">/status/<slug></code>.</p>
|
||||
</div>
|
||||
<Button onClick={() => setPageOpen(true)}>
|
||||
<Plus size={13} strokeWidth={2.2} /> New page
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loaded && pages.length === 0 ? (
|
||||
<div className="mb-8 rounded-[10px] border border-dashed border-border px-6 py-10 text-center">
|
||||
<p className="text-[13px] text-text-dim">No status pages yet.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mb-8 flex flex-col gap-2">
|
||||
{pages.map((p) => (
|
||||
<div key={p.id} className="flex items-center gap-3 rounded-[10px] border border-border bg-surface px-4 py-3">
|
||||
<span className="min-w-0 flex-1 truncate text-[13px] font-medium">{p.title}</span>
|
||||
<code className="font-mono text-[11px] text-text-faint">/status/{p.slug}</code>
|
||||
<Link href={`/status/${p.slug}`} className="shrink-0">
|
||||
<Button variant="outline" size="icon" aria-label="Open public page"><ExternalLink size={12} /></Button>
|
||||
</Link>
|
||||
<Button variant="outline" size="icon" aria-label="Delete"
|
||||
onClick={() => {
|
||||
if (p.id && confirm(`Delete status page "${p.title}"?`)) {
|
||||
void api.DELETE("/api/status-pages/{id}", { params: { path: { id: p.id } } }).then(load);
|
||||
}
|
||||
}}>
|
||||
<Trash2 size={12} />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-5 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-[15px] font-semibold tracking-tight">Maintenance windows</h2>
|
||||
<p className="text-[12px] text-text-faint">Active windows mute alerts + auto-incidents and mark covered monitors.</p>
|
||||
</div>
|
||||
<Button variant="outline" onClick={() => setWinOpen(true)}>
|
||||
<Plus size={13} strokeWidth={2.2} /> Schedule
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loaded && windows.length === 0 ? (
|
||||
<div className="rounded-[10px] border border-dashed border-border px-6 py-10 text-center">
|
||||
<p className="text-[13px] text-text-dim">No maintenance scheduled.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{windows.map((w) => (
|
||||
<div key={w.id} className="flex items-center gap-3 rounded-[10px] border border-border bg-surface px-4 py-3">
|
||||
<span className={cn("size-1.5 shrink-0 rounded-full", w.active ? "bg-up" : "bg-text-faint/40")} />
|
||||
<span className="min-w-0 flex-1 truncate text-[13px] font-medium">{w.title}</span>
|
||||
<span className="font-mono text-[10.5px] text-text-faint">
|
||||
{new Date(w.startsAt ?? "").toLocaleString()} → {new Date(w.endsAt ?? "").toLocaleString()}
|
||||
</span>
|
||||
<span className={cn("font-mono text-[10px] uppercase", w.active ? "text-up" : "text-text-faint")}>
|
||||
{w.active ? "active" : "scheduled"}
|
||||
</span>
|
||||
<Button variant="outline" size="icon" aria-label="Delete"
|
||||
onClick={() => {
|
||||
if (w.id && confirm(`Delete window "${w.title}"?`)) {
|
||||
void api.DELETE("/api/maintenance/{id}", { params: { path: { id: w.id } } }).then(load);
|
||||
}
|
||||
}}>
|
||||
<Trash2 size={12} />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Dialog open={pageOpen} onOpenChange={setPageOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>New status page</DialogTitle>
|
||||
<DialogDescription>Public live view over selected monitors.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="sp-title">Title</Label>
|
||||
<Input id="sp-title" value={pTitle} onChange={(e) => setPTitle(e.target.value)} placeholder="Homelab status" autoFocus />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="sp-slug">Slug</Label>
|
||||
<Input id="sp-slug" value={pSlug} onChange={(e) => setPSlug(e.target.value)} placeholder="auto" className="font-mono text-[12px]" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="sp-desc">Description</Label>
|
||||
<Input id="sp-desc" value={pDesc} onChange={(e) => setPDesc(e.target.value)} placeholder="optional" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>Monitors</Label>
|
||||
<div className="max-h-40 overflow-y-auto rounded-[7px] border border-border p-2">
|
||||
{monitors.map((m) => (
|
||||
<label key={m.id} className="flex items-center gap-2 px-1 py-1 text-[12px]">
|
||||
<input type="checkbox" className="accent-current"
|
||||
checked={pMonitors.includes(m.id ?? "")}
|
||||
onChange={(e) => setPMonitors((v) =>
|
||||
e.target.checked ? [...v, m.id ?? ""] : v.filter((x) => x !== m.id))} />
|
||||
{m.name}
|
||||
</label>
|
||||
))}
|
||||
{monitors.length === 0 && <p className="px-1 py-1 text-[11.5px] text-text-faint">No monitors yet — empty selection shows all.</p>}
|
||||
</div>
|
||||
<p className="text-[11px] text-text-faint">Leave all unchecked to include every active monitor.</p>
|
||||
</div>
|
||||
{err ? <p className="text-[12px] text-destructive">{err}</p> : null}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setPageOpen(false)} disabled={busy}>Cancel</Button>
|
||||
<Button onClick={() => void createPage()} disabled={busy || !pTitle.trim()}>
|
||||
{busy ? "Creating…" : "Create"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={winOpen} onOpenChange={setWinOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Schedule maintenance</DialogTitle>
|
||||
<DialogDescription>Suppresses alerts and auto-incidents for the window.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="mw-title">Title</Label>
|
||||
<Input id="mw-title" value={wTitle} onChange={(e) => setWTitle(e.target.value)} placeholder="Network upgrade" autoFocus />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="mw-start">Starts</Label>
|
||||
<Input id="mw-start" type="datetime-local" value={wStart} onChange={(e) => setWStart(e.target.value)} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="mw-end">Ends</Label>
|
||||
<Input id="mw-end" type="datetime-local" value={wEnd} onChange={(e) => setWEnd(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
{err ? <p className="text-[12px] text-destructive">{err}</p> : null}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setWinOpen(false)} disabled={busy}>Cancel</Button>
|
||||
<Button onClick={() => void createWindow()} disabled={busy || !wTitle.trim() || !wStart || !wEnd}>
|
||||
{busy ? "Saving…" : "Schedule"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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<PublicStatus | null>(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 <main className="mx-auto max-w-2xl px-7 py-16 text-center text-[13px] text-text-faint">Status page not found.</main>;
|
||||
if (!st) return <main className="mx-auto max-w-2xl px-7 py-16 text-center text-[13px] text-text-faint">Loading…</main>;
|
||||
|
||||
const banner = overallBanner(st.overall);
|
||||
|
||||
return (
|
||||
<main className="mx-auto w-full max-w-2xl px-7 py-10">
|
||||
<div className="mb-6 text-center">
|
||||
<h1 className="text-[17px] font-semibold tracking-tight">{st.title}</h1>
|
||||
{st.description ? <p className="mt-1 text-[12px] text-text-faint">{st.description}</p> : null}
|
||||
</div>
|
||||
|
||||
<div className={cn("mb-8 rounded-[10px] px-5 py-3.5 text-center text-[13px] font-medium text-white", banner.cls)}>
|
||||
{banner.label}
|
||||
</div>
|
||||
|
||||
{(st.monitors ?? []).length > 0 && (
|
||||
<section className="mb-6">
|
||||
<h2 className="mb-2 font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">Services</h2>
|
||||
<div className="overflow-hidden rounded-[10px] border border-border">
|
||||
{(st.monitors ?? []).map((m) => (
|
||||
<div key={m.id} className="flex items-center gap-3 border-b border-border/60 px-4 py-2.5 last:border-0">
|
||||
<span className={cn("size-1.5 shrink-0 rounded-full", dotCls(m.status))} />
|
||||
<span className="min-w-0 flex-1 truncate text-[13px]">{m.name}</span>
|
||||
<span className="font-mono text-[10.5px] text-text-faint">{m.uptime24h?.toFixed(1)}%</span>
|
||||
<span className="w-20 text-right font-mono text-[10.5px] uppercase text-text-faint">{m.status}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{(st.systems ?? []).length > 0 && (
|
||||
<section className="mb-6">
|
||||
<h2 className="mb-2 font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">Systems</h2>
|
||||
<div className="overflow-hidden rounded-[10px] border border-border">
|
||||
{(st.systems ?? []).map((s) => (
|
||||
<div key={s.id} className="flex items-center gap-3 border-b border-border/60 px-4 py-2.5 last:border-0">
|
||||
<span className={cn("size-1.5 shrink-0 rounded-full", dotCls(s.status))} />
|
||||
<span className="min-w-0 flex-1 truncate text-[13px]">{s.name}</span>
|
||||
<span className="font-mono text-[10.5px] uppercase text-text-faint">{s.status}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{(st.maintenance ?? []).length > 0 && (
|
||||
<section className="mb-6">
|
||||
<h2 className="mb-2 font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">Maintenance</h2>
|
||||
<div className="overflow-hidden rounded-[10px] border border-border">
|
||||
{(st.maintenance ?? []).map((w) => (
|
||||
<div key={w.id} className="flex items-center gap-3 border-b border-border/60 px-4 py-2.5 last:border-0">
|
||||
<span className="min-w-0 flex-1 truncate text-[13px]">{w.title}</span>
|
||||
<span className="font-mono text-[10.5px] text-text-faint">
|
||||
{new Date(w.startsAt ?? "").toLocaleString()} → {new Date(w.endsAt ?? "").toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{(st.incidents ?? []).length > 0 && (
|
||||
<section className="mb-6">
|
||||
<h2 className="mb-2 font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">Incidents</h2>
|
||||
<div className="flex flex-col gap-2">
|
||||
{(st.incidents ?? []).map((inc) => (
|
||||
<div key={inc.id} className="rounded-[10px] border border-border px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="min-w-0 flex-1 truncate text-[13px] font-medium">{inc.title}</span>
|
||||
<span className="font-mono text-[10.5px] uppercase text-text-faint">{inc.status}</span>
|
||||
</div>
|
||||
{(inc.updates ?? []).slice(-3).map((u) => (
|
||||
<p key={u.id} className="mt-1.5 text-[11.5px] text-text-faint">
|
||||
<span className="font-mono text-[10px] uppercase">{u.status}</span> — {u.message}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<p className="mt-10 text-center font-mono text-[10px] text-text-faint">powered by dash</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
Reference in new issue
Block a user