mirror of
https://github.com/Dvorinka/Dash.git
synced 2026-10-28 15:51: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
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user