diff --git a/ROADMAP.md b/ROADMAP.md index 42d5648..02a5d68 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -34,6 +34,10 @@ or popover logic. | `v0.1.0` | Phase 0 + 1 — working MVP, single image | manual smoke test ✅ | | `v0.2.0` | Phase 2 — widget layer + first integrations | Pi-hole widget live ✅ | | `v0.3.0` | Phase 3 — four renderers, ⌘K palette, icon suggest | palette + switcher live ✅ | +| `v0.4.0` | Phase M1 — uptime monitors + alerts backbone | monitor lifecycle live | +| `v0.5.0` | Phase M2 — domain intelligence | domain lookup + expiry alerts | +| `v0.6.0` | Phase M3 — system monitoring + agent | agent ingest + charts | +| `v0.7.0` | Phase M4 — incidents, status pages, badges, metrics | public status page live | | `v1.0.0` | Phase 4 — OSS polish, docs, importers, CI releases | public repo launch | | `v1.x` | Phase 5 — demand-driven extras only | per-feature | @@ -113,6 +117,78 @@ The product's spine: sections, services, drag-drop, multi-URL. **Exit:** all four renderers work off the same board state; palette navigates everything. — verified: both new renderers render/persist/dnd/collapse, palette filters and executes, icon suggest resolves real CDN assets, 360px + light theme, docker image 43.9 MB. +## Merge program — Beszel fork → Dash + +Source: the local Beszel fork (`~/Desktop/PROG+HTML/Beszel` — PocketBase hub +with custom monitors/domains/status-pages) plus lissy93/domain-locker as +design inspiration. Upstream references cloned to `../_ref/beszel` and +`../_ref/domain-locker` for consultation only. + +**No code moves verbatim.** The fork is PocketBase collections + its own +auth + a WebSocket agent; Dash is Gin + SQLite + goose with no auth. We port +data models, check logic, and lookup pipelines — DB, API, and UI layers are +re-implemented on Dash conventions. + +**Deployment reality.** Monitoring needs a persistent process (schedulers), +non-HTTP egress (WHOIS :43, ICMP, SMTP, raw TCP), and a writable DB. None of +that exists on serverless — Vercel cannot host this product, full stop. The +deploy story stays `docker run` + GHCR image + goreleaser binaries; agents +are a second small binary. + +**UI model.** The app becomes routed: `/` board (default), `/monitors`, +`/domains`, `/systems`, `/status/:slug` (public, no board chrome). Router: +`wouter` (~2 kB — every route we need, nothing more). Charts: `recharts`. +Board widgets surface monitoring entities through the existing widget +registry — monitor uptime, domain expiry, system gauges — so the board +becomes the summary layer over the monitoring pages. + +### Phase M1 — Monitors → v0.4.0 + +Uptime checking: the backbone everything else hangs off. + +- [ ] Schema: `monitors` (name, type, url/hostname/port, method, keyword, json_query, expected_value, interval_s, timeout_s, retries, active, status, tags, notes), `heartbeats` (monitor_id, status, ping_ms, msg, cert_expiry, checked_at — 30-day retention prune) +- [ ] In-process scheduler: due-monitor sweep, bounded worker pool, heartbeat write, status transitions +- [ ] Checkers: `http`/`https`, `tcp`, `ping` (unprivileged ICMP datagram via `x/net/icmp` — distroless has no ping binary), `dns`, `keyword`, `json-query`, `push` (caller-generated ingest URL) +- [ ] API: `/api/monitors` CRUD, `/:id/heartbeats?range=`, pause/resume, check-now +- [ ] Monitors page (status, uptime 24h/30d, ping, interval) + detail (response chart, heartbeat log) +- [ ] Board widget type `monitor` bound to a monitor id +- [ ] ServiceDialog checkbox: create matching http monitor from a service URL +- [ ] The fork's ~30 exotic monitor types were TCP stubs — not ported. Real types only. + +### Phase M2 — Domains → v0.5.0 + +Domain-locker-grade domain intelligence. + +- [ ] Schema: `domains` (core columns + `extra` JSON for the long tail), `domain_history` (domain_id, change_type, field, old, new, at) +- [ ] Lookup pipeline ported from fork `hub/domains/whois/lookup.go`, trimmed: RDAP over HTTPS → native WHOIS TCP:43 + parser; SSL chain via `crypto/tls`; DNS (NS/MX/TXT/A/AAAA) via `net.Resolver`; host geo via ip-api; provider detection via fork `detect/providers.go`; favicon +- [ ] Daily scheduler + manual refresh; field diffs recorded to `domain_history` +- [ ] Alerts: `alert_rules` + `notifications` tables; dispatchers — generic webhook (JSON POST), SMTP via `net/smtp`, Discord/Slack presets. Triggers: domain expiry ≤ N days, SSL expiry ≤ N days, monitor down/recovered (M1), system offline (M3) +- [ ] Domains page + detail (expiry countdown, registrar, SSL, DNS, subdomains) +- [ ] Board widget `domain` (expiry countdown) +- [ ] Subdomain discovery: port `subdomain_discovery.go`, opt-in per domain +- [ ] Not ported (v1): whoisxml API, EURid web scraping, SEO/robots parsing, valuation estimates — demand only + +### Phase M3 — Systems → v0.6.0 + +Beszel-style server monitoring, push-based. + +- [ ] Schema: `systems` (name, token, host, os/arch, last_seen, status), `system_stats` (system_id, ts, cpu, mem, disk, net, load, temps JSON, containers JSON — retention prune) +- [ ] Ingest: `POST /api/systems/ingest` with per-system bearer token; offline when silent > 3× interval +- [ ] `cmd/dash-agent`: Linux-first collector — `/proc` (cpu/mem/net/load/uptime), `/sys` hwmon temps, statfs disk; container stats when `/var/run/docker.sock` present; POST every 10s; systemd unit + Dockerfile +- [ ] Systems page (gauge cards, uptime, last seen) + detail (recharts: cpu%, mem, disk io, net, temps) +- [ ] Board widget `system` (cpu/mem bars) +- [ ] Not ported: the beszel agent protocol (SSH/WS into PocketBase) — our agent is push-JSON. SMART/ZFS/GPU metrics deferred to demand. + +### Phase M4 — Ops layer → v0.7.0 + +- [ ] Incidents: `incidents` + `incident_updates`; manual CRUD + auto-open on monitor down, auto-resolve on recovery; severity + status flow (open → ack → resolved → closed) +- [ ] Maintenance windows: `maintenance_windows`; suppress alerts, show `maintenance` state while active +- [ ] Status pages: `status_pages` + `status_page_monitors`; public `/status/:slug` route + public API endpoint +- [ ] Badges: `GET /badge/:kind/:id.svg` — stateless SVG from live data (status/uptime/response/domain/system) +- [ ] `GET /metrics` Prometheus exposition +- [ ] Bulk: CSV import monitors + domains; JSON export folded into existing `/api/export` +- [ ] ⌘K palette + header nav wired for new pages + ## Phase 4 — Open-source launch → v1.0.0 - [ ] Docs: install, configuration, widget development guide (`docs/`) diff --git a/apps/backend/cmd/dash/main.go b/apps/backend/cmd/dash/main.go index dd82d9a..ed767bd 100644 --- a/apps/backend/cmd/dash/main.go +++ b/apps/backend/cmd/dash/main.go @@ -42,8 +42,11 @@ func main() { } defer func() { _ = sqlDB.Close() }() + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + iconsDir := filepath.Join(dataDir, "icons") - router := api.NewRouter(logger, sqlDB, iconsDir) + router := api.NewRouter(ctx, logger, sqlDB, iconsDir) if err := web.Register(router); err != nil { logger.Fatal("register embedded UI", zap.Error(err)) } @@ -51,9 +54,6 @@ func main() { addr := ":" + envOr("DASH_PORT", "8080") srv := &http.Server{Addr: addr, Handler: router, ReadHeaderTimeout: 10 * time.Second} - ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) - defer stop() - go func() { logger.Info("listening", zap.String("addr", addr), zap.String("data", dataDir)) if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { diff --git a/apps/backend/go.mod b/apps/backend/go.mod index 8c8078a..8c2a3ab 100644 --- a/apps/backend/go.mod +++ b/apps/backend/go.mod @@ -4,8 +4,10 @@ go 1.27 require ( github.com/gin-gonic/gin v1.12.0 + github.com/goccy/go-yaml v1.19.2 github.com/pressly/goose/v3 v3.28.0 go.uber.org/zap v1.28.0 + golang.org/x/net v0.58.0 modernc.org/sqlite v1.59.0 ) @@ -21,7 +23,6 @@ require ( github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.30.1 // indirect github.com/goccy/go-json v0.10.5 // indirect - github.com/goccy/go-yaml v1.19.2 // indirect github.com/google/uuid v1.6.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect @@ -42,7 +43,6 @@ require ( go.uber.org/multierr v1.11.0 // indirect golang.org/x/arch v0.22.0 // indirect golang.org/x/crypto v0.55.0 // indirect - golang.org/x/net v0.58.0 // indirect golang.org/x/sync v0.22.0 // indirect golang.org/x/sys v0.47.0 // indirect golang.org/x/text v0.41.0 // indirect diff --git a/apps/backend/internal/api/api.go b/apps/backend/internal/api/api.go index 9fcf991..0e90731 100644 --- a/apps/backend/internal/api/api.go +++ b/apps/backend/internal/api/api.go @@ -2,6 +2,7 @@ package api import ( + "context" "crypto/rand" "database/sql" "encoding/hex" @@ -12,25 +13,33 @@ import ( "time" "github.com/gin-gonic/gin" + "github.com/tdvorak/dash/internal/widget" "go.uber.org/zap" ) -// Server holds handler dependencies: DB, icon dir, status + widget caches. +// Server holds handler dependencies: DB, icon dir, logger, caches. type Server struct { db *sql.DB iconsDir string + log *zap.Logger status *statusCache widgets *widgetCache } // NewRouter builds the HTTP handler: zap access log, recovery, /api routes. -func NewRouter(logger *zap.Logger, db *sql.DB, iconsDir string) *gin.Engine { +// A non-nil ctx starts the monitor scheduler; nil keeps it off (tests). +func NewRouter(ctx context.Context, logger *zap.Logger, db *sql.DB, iconsDir string) *gin.Engine { s := &Server{ db: db, iconsDir: iconsDir, + log: logger, status: newStatusCache(60 * time.Second), widgets: newWidgetCache(30 * time.Second), } + widget.Register(&monitorWidget{db: db}) + if ctx != nil { + go s.runScheduler(ctx.Done()) + } r := gin.New() r.Use(accessLog(logger), gin.Recovery()) @@ -51,6 +60,15 @@ func NewRouter(logger *zap.Logger, db *sql.DB, iconsDir string) *gin.Engine { v1.POST("/items/:id/icon", s.uploadIcon) v1.GET("/icons/:file", s.getIcon) + v1.GET("/monitors", s.listMonitorsH) + v1.POST("/monitors", s.createMonitor) + v1.GET("/monitors/:id", s.getMonitor) + v1.PATCH("/monitors/:id", s.patchMonitor) + v1.DELETE("/monitors/:id", s.deleteMonitor) + v1.GET("/monitors/:id/heartbeats", s.monitorHeartbeats) + v1.POST("/monitors/:id/check", s.checkNow) + v1.Any("/push/:token", s.pushIngest) + v1.GET("/status", s.getStatus) v1.GET("/widgets/types", s.widgetTypes) v1.GET("/widgets/:id/data", s.widgetData) @@ -58,6 +76,7 @@ func NewRouter(logger *zap.Logger, db *sql.DB, iconsDir string) *gin.Engine { v1.PUT("/settings", s.putSettings) v1.GET("/export", s.exportBoard) v1.POST("/import", s.importBoard) + v1.POST("/import/external", s.importExternal) return r } diff --git a/apps/backend/internal/api/api_test.go b/apps/backend/internal/api/api_test.go index 12576f7..b147fda 100644 --- a/apps/backend/internal/api/api_test.go +++ b/apps/backend/internal/api/api_test.go @@ -22,7 +22,7 @@ func testServer(t *testing.T) *httptest.Server { } t.Cleanup(func() { d.Close() }) gin.SetMode(gin.TestMode) - return httptest.NewServer(NewRouter(zap.NewNop(), d, t.TempDir())) + return httptest.NewServer(NewRouter(nil, zap.NewNop(), d, t.TempDir())) } func do(t *testing.T, method, url, body string) (*http.Response, map[string]any) { @@ -178,6 +178,209 @@ func TestWidgetEndpoints(t *testing.T) { } } +func TestImportExternalHomepage(t *testing.T) { + srv := testServer(t) + defer srv.Close() + + body := `- Media: + - Plex: + href: http://plex.local:32400 + icon: plex + - NoLink: + description: widget-only entry +- Apps: + - Sonarr: + href: https://sonarr.example.com + icon: https://icons.example.com/sonarr.png +` + res, _ := do(t, "POST", srv.URL+"/api/import/external", body) + if res.StatusCode != http.StatusOK { + t.Fatalf("homepage import: %d", res.StatusCode) + } + var secs []Section + resp, err := http.Get(srv.URL + "/api/sections") + if err != nil { + t.Fatalf("get sections: %v", err) + } + defer resp.Body.Close() + if err := json.NewDecoder(resp.Body).Decode(&secs); err != nil { + t.Fatalf("decode: %v", err) + } + if len(secs) != 2 { + t.Fatalf("expected 2 sections, got %+v", secs) + } + media := secs[0] + if media.Name != "Media" || len(media.Items) != 1 { + t.Fatalf("bad Media section: %+v", media) + } + if media.Items[0].Name != "Plex" || len(media.Items[0].URLs) != 1 { + t.Fatalf("bad Plex item: %+v", media.Items[0]) + } + if media.Items[0].Icon == "" { + t.Fatal("expected resolved CDN icon") + } + apps := secs[1] + if apps.Items[0].Icon != "https://icons.example.com/sonarr.png" { + t.Fatalf("icon passthrough failed: %+v", apps.Items[0]) + } +} + +func TestImportExternalDashy(t *testing.T) { + srv := testServer(t) + defer srv.Close() + + body := `appConfig: + title: test +sections: + - name: Media + items: + - title: Plex + url: http://plex.local + icon: favicon + - title: Broken + url: "notaurl" +` + res, _ := do(t, "POST", srv.URL+"/api/import/external", body) + if res.StatusCode != http.StatusOK { + t.Fatalf("dashy import: %d", res.StatusCode) + } + var secs []Section + resp, err := http.Get(srv.URL + "/api/sections") + if err != nil { + t.Fatalf("get sections: %v", err) + } + defer resp.Body.Close() + if err := json.NewDecoder(resp.Body).Decode(&secs); err != nil { + t.Fatalf("decode: %v", err) + } + if len(secs) != 1 || len(secs[0].Items) != 1 { + t.Fatalf("bad sections: %+v", secs) + } + if secs[0].Items[0].Icon != "http://plex.local/favicon.ico" { + t.Fatalf("favicon resolution failed: %+v", secs[0].Items[0]) + } +} + +func TestImportExternalHomarr(t *testing.T) { + srv := testServer(t) + defer srv.Close() + + body := `{"apps":[{"name":"Plex","url":"http://plex.local","icon":"hl-plex","categoryId":"c1"},{"name":"Sonarr","url":"http://sonarr.local","categoryId":"c1"}],"categories":[{"id":"c1","name":"Media"}]}` + res, _ := do(t, "POST", srv.URL+"/api/import/external", body) + if res.StatusCode != http.StatusOK { + t.Fatalf("homarr import: %d", res.StatusCode) + } + var secs []Section + resp, err := http.Get(srv.URL + "/api/sections") + if err != nil { + t.Fatalf("get sections: %v", err) + } + defer resp.Body.Close() + if err := json.NewDecoder(resp.Body).Decode(&secs); err != nil { + t.Fatalf("decode: %v", err) + } + if len(secs) != 1 || secs[0].Name != "Media" || len(secs[0].Items) != 2 { + t.Fatalf("bad sections: %+v", secs) + } +} + +func TestImportExternalRejectsGarbage(t *testing.T) { + srv := testServer(t) + defer srv.Close() + res, body := do(t, "POST", srv.URL+"/api/import/external", `{"foo": 1}`) + if res.StatusCode != http.StatusBadRequest { + t.Fatalf("expected 400, got %d: %v", res.StatusCode, body) + } +} + +func TestMonitorLifecycle(t *testing.T) { + srv := testServer(t) + defer srv.Close() + + // Target that returns 200. + up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer up.Close() + + res, mv := do(t, "POST", srv.URL+"/api/monitors", `{"name":"Site","type":"http","url":"`+up.URL+`","intervalS":60}`) + if res.StatusCode != http.StatusCreated { + t.Fatalf("create: %d %v", res.StatusCode, mv) + } + id := mv["id"].(string) + + // check-now -> up, heartbeat recorded. + res, mv = do(t, "POST", srv.URL+"/api/monitors/"+id+"/check", "") + if res.StatusCode != http.StatusOK || mv["status"] != "up" { + t.Fatalf("check-now: %d %v", res.StatusCode, mv) + } + resp, err := http.Get(srv.URL + "/api/monitors/" + id + "/heartbeats") + if err != nil { + t.Fatalf("heartbeats: %v", err) + } + var hbs []map[string]any + _ = json.NewDecoder(resp.Body).Decode(&hbs) + resp.Body.Close() + if len(hbs) != 1 || hbs[0]["status"] != "up" { + t.Fatalf("expected 1 up heartbeat, got %v", hbs) + } + + // pause -> paused + inactive + res, mv = do(t, "PATCH", srv.URL+"/api/monitors/"+id, `{"active":false}`) + if res.StatusCode != http.StatusOK || mv["status"] != "paused" || mv["active"] != false { + t.Fatalf("pause: %v", mv) + } + + // delete + res, _ = do(t, "DELETE", srv.URL+"/api/monitors/"+id, "") + if res.StatusCode != http.StatusNoContent { + t.Fatalf("delete: %d", res.StatusCode) + } + res, _ = do(t, "GET", srv.URL+"/api/monitors/"+id, "") + if res.StatusCode != http.StatusNotFound { + t.Fatalf("expected 404 after delete, got %d", res.StatusCode) + } +} + +func TestMonitorCheckDown(t *testing.T) { + srv := testServer(t) + defer srv.Close() + res, mv := do(t, "POST", srv.URL+"/api/monitors", `{"name":"Dead","type":"http","url":"http://127.0.0.1:1","timeoutS":1}`) + if res.StatusCode != http.StatusCreated { + t.Fatalf("create: %v", mv) + } + id := mv["id"].(string) + res, mv = do(t, "POST", srv.URL+"/api/monitors/"+id+"/check", "") + if res.StatusCode != http.StatusOK || mv["status"] != "down" { + t.Fatalf("expected down, got %v", mv) + } +} + +func TestPushMonitor(t *testing.T) { + srv := testServer(t) + defer srv.Close() + res, mv := do(t, "POST", srv.URL+"/api/monitors", `{"name":"Backup","type":"push","intervalS":60}`) + if res.StatusCode != http.StatusCreated { + t.Fatalf("create: %v", mv) + } + token, _ := mv["pushToken"].(string) + if token == "" { + t.Fatal("no push token issued") + } + res, _ = do(t, "GET", srv.URL+"/api/push/"+token+"?msg=done", "") + if res.StatusCode != http.StatusOK { + t.Fatalf("push: %d", res.StatusCode) + } + res, mv = do(t, "GET", srv.URL+"/api/monitors/"+mv["id"].(string), "") + if mv["status"] != "up" { + t.Fatalf("expected up after push, got %v", mv["status"]) + } + res, _ = do(t, "GET", srv.URL+"/api/push/bogus", "") + if res.StatusCode != http.StatusNotFound { + t.Fatalf("bad token: %d", res.StatusCode) + } +} + func TestMidpoint(t *testing.T) { f := func(v float64) *float64 { return &v } if got := midpoint(nil, nil); got != 1024 { diff --git a/apps/backend/internal/api/importers.go b/apps/backend/internal/api/importers.go new file mode 100644 index 0000000..4fb3c3c --- /dev/null +++ b/apps/backend/internal/api/importers.go @@ -0,0 +1,322 @@ +package api + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + "github.com/gin-gonic/gin" + "github.com/goccy/go-yaml" +) + +// diBase mirrors the frontend's dashboard-icons CDN root (ServiceDialog). +const diBase = "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png" + +// importExternal ingests a foreign dashboard config — Homepage services.yml, +// Homarr JSON export, or Dashy conf.yml — and replaces the board with it. +// Format is sniffed from the body; settings are left untouched. +func (s *Server) importExternal(c *gin.Context) { + body, err := io.ReadAll(io.LimitReader(c.Request.Body, 8<<20)) + if err != nil || len(body) == 0 { + fail(c, http.StatusBadRequest, "empty or unreadable body") + return + } + sections, err := parseExternal(body) + if err != nil { + fail(c, http.StatusBadRequest, err.Error()) + return + } + if err := s.replaceBoard(sections, nil); err != nil { + failImport(c, err) + return + } + board, err := s.board() + if err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + c.JSON(http.StatusOK, board) +} + +// parseExternal sniffs the config format: JSON decodes as Homarr; a YAML +// sequence is Homepage's grouped services list; a YAML mapping carrying +// `sections` is a Dashy conf.yml. +func parseExternal(body []byte) ([]Section, error) { + var j any + if json.Unmarshal(body, &j) == nil { + return parseHomarr(j) + } + var y any + if err := yaml.Unmarshal(body, &y); err != nil { + return nil, errors.New("unreadable file — expected JSON or YAML") + } + switch root := y.(type) { + case []any: + return parseHomepage(root) + case map[string]any: + if _, ok := root["sections"]; ok { + return parseDashy(body) + } + } + return nil, errors.New("unrecognized config — expected a Homepage services.yml, Homarr JSON export, or Dashy conf.yml") +} + +// ---------- Homepage ---------- + +// parseHomepage walks a services.yml: a sequence of single-key maps +// (group name -> sequence of single-key service maps). Bare single-key +// entries at the top level land in an "Imported" section. +func parseHomepage(groups []any) ([]Section, error) { + var secs []Section + loose := Section{Name: "Imported"} + for _, g := range groups { + gm, ok := g.(map[string]any) + if !ok { + continue + } + for name, v := range gm { + switch val := v.(type) { + case []any: + sec := Section{Name: name, Items: homepageItems(val)} + if len(sec.Items) > 0 { + secs = append(secs, sec) + } + case map[string]any: + if it, ok := homepageService(name, val); ok { + loose.Items = append(loose.Items, it) + } + } + } + } + if len(loose.Items) > 0 { + secs = append([]Section{loose}, secs...) + } + if len(secs) == 0 { + return nil, errors.New("homepage: no importable services found") + } + return assignIDs(secs), nil +} + +func homepageItems(list []any) []Item { + var items []Item + for _, raw := range list { + m, ok := raw.(map[string]any) + if !ok { + continue + } + for name, fields := range m { + fm, _ := fields.(map[string]any) + if it, ok := homepageService(name, fm); ok { + items = append(items, it) + } + } + } + return items +} + +// homepageService maps one service entry. Entries without a reachable URL +// (pure widget config, bare ping targets) are skipped rather than failing +// the whole import. +func homepageService(name string, fields map[string]any) (Item, bool) { + u := strOf(fields, "href", "siteMonitor", "ping") + if strings.TrimSpace(name) == "" || !validURL(u) { + return Item{}, false + } + return importedItem(name, resolveIcon(strOf(fields, "icon"), u), u), true +} + +// ---------- Homarr ---------- + +// parseHomarr tolerates Homarr's shifting export shapes: `categories[].apps`, +// a flat `apps` array grouped by category id, or board `sections[].items`. +func parseHomarr(root any) ([]Section, error) { + m, ok := root.(map[string]any) + if !ok { + return nil, errors.New("homarr: expected a JSON object") + } + var secs []Section + seen := map[string]bool{} + catNames := map[string]string{} + + for _, c := range arrOf(m["categories"]) { + cm, _ := c.(map[string]any) + if name := strOf(cm, "name"); name != "" { + catNames[strOf(cm, "id")] = name + if s := sectionFromApps(name, arrOf(cm["apps"]), seen); len(s.Items) > 0 { + secs = append(secs, s) + } + } + } + + if apps := arrOf(m["apps"]); len(apps) > 0 { + var order []string + byCat := map[string][]any{} + for _, a := range apps { + am, _ := a.(map[string]any) + cat := strOf(am, "categoryId", "category") + if _, ok := byCat[cat]; !ok { + order = append(order, cat) + } + byCat[cat] = append(byCat[cat], a) + } + for _, cat := range order { + name := catNames[cat] + if name == "" { + name = "Imported" + } + if s := sectionFromApps(name, byCat[cat], seen); len(s.Items) > 0 { + secs = append(secs, s) + } + } + } + + for _, raw := range arrOf(m["sections"]) { + sm, _ := raw.(map[string]any) + if s := sectionFromApps(strOf(sm, "name"), arrOf(sm["items"]), seen); len(s.Items) > 0 { + secs = append(secs, s) + } + } + + if len(secs) == 0 { + return nil, errors.New("homarr: no apps found") + } + return assignIDs(secs), nil +} + +func sectionFromApps(name string, apps []any, seen map[string]bool) Section { + sec := Section{Name: orDefault(name, "Imported")} + for _, a := range apps { + am, _ := a.(map[string]any) + it, ok := homarrItem(am) + if !ok || seen[it.Name+"|"+it.URLs[0].URL] { + continue + } + seen[it.Name+"|"+it.URLs[0].URL] = true + sec.Items = append(sec.Items, it) + } + return sec +} + +func homarrItem(m map[string]any) (Item, bool) { + name := strOf(m, "name", "title", "label") + u := strOf(m, "url", "href", "appUrl", "externalUrl", "internalUrl") + if name == "" || !validURL(u) { + return Item{}, false + } + return importedItem(name, resolveIcon(strOf(m, "icon", "iconUrl"), u), u), true +} + +// ---------- Dashy ---------- + +type dashyConf struct { + Sections []struct { + Name string `yaml:"name"` + Items []struct { + Title string `yaml:"title"` + URL string `yaml:"url"` + Icon string `yaml:"icon"` + } `yaml:"items"` + } `yaml:"sections"` +} + +func parseDashy(body []byte) ([]Section, error) { + var conf dashyConf + if err := yaml.Unmarshal(body, &conf); err != nil { + return nil, fmt.Errorf("dashy: malformed conf.yml") + } + var secs []Section + for _, ds := range conf.Sections { + sec := Section{Name: orDefault(ds.Name, "Imported")} + for _, di := range ds.Items { + if di.Title == "" || !validURL(di.URL) { + continue + } + sec.Items = append(sec.Items, importedItem(di.Title, resolveIcon(di.Icon, di.URL), di.URL)) + } + secs = append(secs, sec) + } + if len(secs) == 0 { + return nil, errors.New("dashy: no sections found") + } + return assignIDs(secs), nil +} + +// ---------- shared ---------- + +// assignIDs stamps fresh ids and 1024-spaced positions on an imported tree. +func assignIDs(secs []Section) []Section { + for si := range secs { + secs[si].ID = newID("s") + secs[si].Position = float64(si+1) * 1024 + for ii := range secs[si].Items { + it := &secs[si].Items[ii] + it.ID = newID("i") + it.SectionID = secs[si].ID + it.Kind = orDefault(it.Kind, "service") + it.Position = float64(ii+1) * 1024 + for ui := range it.URLs { + it.URLs[ui].ID = newID("u") + it.URLs[ui].Position = float64(ui+1) * 1024 + } + } + } + return secs +} + +func importedItem(name, icon, u string) Item { + return Item{Kind: "service", Name: name, Icon: icon, URLs: []URL{{URL: u}}} +} + +// resolveIcon maps a foreign icon reference to a usable URL: passthrough for +// absolute URLs, favicon derivation for Dashy's `favicon`, the dashboard-icons +// CDN for prefixed or bare slugs, and "" (letter tile) for local file paths +// that only existed on the old dashboard. +func resolveIcon(icon, itemURL string) string { + icon = strings.TrimSpace(icon) + switch { + case icon == "": + return "" + case strings.HasPrefix(icon, "http://"), strings.HasPrefix(icon, "https://"): + return icon + case icon == "favicon", icon == "generalfavicon": + return faviconOf(itemURL) + case strings.ContainsAny(icon, "/.\\"): + return "" + } + lower := strings.ToLower(icon) + for _, p := range []string{"si-", "sh-", "hl-", "di-", "mdi-"} { + if rest, ok := strings.CutPrefix(lower, p); ok { + if rest == "" { + return "" + } + return diBase + "/" + rest + ".png" + } + } + return diBase + "/" + lower + ".png" +} + +func faviconOf(raw string) string { + u, err := url.Parse(raw) + if err != nil || u.Host == "" { + return "" + } + return u.Scheme + "://" + u.Host + "/favicon.ico" +} + +func arrOf(v any) []any { + a, _ := v.([]any) + return a +} + +func strOf(m map[string]any, keys ...string) string { + for _, k := range keys { + if s, ok := m[k].(string); ok && s != "" { + return s + } + } + return "" +} diff --git a/apps/backend/internal/api/monitors.go b/apps/backend/internal/api/monitors.go new file mode 100644 index 0000000..38915c7 --- /dev/null +++ b/apps/backend/internal/api/monitors.go @@ -0,0 +1,549 @@ +package api + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "net/http" + "strconv" + "time" + + "github.com/gin-gonic/gin" + "github.com/tdvorak/dash/internal/monitor" + "github.com/tdvorak/dash/internal/widget" + "go.uber.org/zap" +) + +// ---------- model ---------- + +const monitorCols = `id, name, type, url, hostname, port, method, headers, body, + keyword, keyword_invert, json_query, expected, dns_type, interval_s, timeout_s, + retries, active, status, push_token, tags, notes, position, last_check, created_at` + +func scanMonitor(row interface{ Scan(...any) error }) (*monitor.Monitor, error) { + var m monitor.Monitor + var tags string + var invert, active int + err := row.Scan(&m.ID, &m.Name, &m.Type, &m.URL, &m.Hostname, &m.Port, &m.Method, + &m.Headers, &m.Body, &m.Keyword, &invert, &m.JSONQuery, &m.Expected, &m.DNSType, + &m.IntervalS, &m.TimeoutS, &m.Retries, &active, &m.Status, &m.PushToken, + &tags, &m.Notes, &m.Position, &m.LastCheck, &m.CreatedAt) + if err != nil { + return nil, err + } + m.KeywordInvert = invert != 0 + m.Active = active != 0 + _ = json.Unmarshal([]byte(tags), &m.Tags) + if m.Tags == nil { + m.Tags = []string{} + } + return &m, nil +} + +func (s *Server) loadMonitor(id string) (*monitor.Monitor, error) { + row := s.db.QueryRow(`SELECT `+monitorCols+` FROM monitors WHERE id = ?`, id) + return scanMonitor(row) +} + +func (s *Server) loadMonitorByToken(token string) (*monitor.Monitor, error) { + row := s.db.QueryRow(`SELECT `+monitorCols+` FROM monitors WHERE push_token = ? AND push_token != ''`, token) + return scanMonitor(row) +} + +func (s *Server) listMonitors() ([]monitor.Monitor, error) { + rows, err := s.db.Query(`SELECT ` + monitorCols + ` FROM monitors ORDER BY position, name`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []monitor.Monitor + for rows.Next() { + m, err := scanMonitor(rows) + if err != nil { + return nil, err + } + out = append(out, *m) + } + return out, rows.Err() +} + +// ---------- uptime stats ---------- + +type uptimeStats struct { + Up int `json:"up"` + Down int `json:"down"` + Uptime24h float64 `json:"uptime24h"` + Uptime30d float64 `json:"uptime30d"` + AvgPing24h int `json:"avgPing24h"` +} + +func (s *Server) monitorStats(id string) uptimeStats { + var st uptimeStats + pct := func(hours int) float64 { + var up, down int + _ = s.db.QueryRow(`SELECT COALESCE(SUM(status='up'),0), COALESCE(SUM(status='down'),0) + FROM heartbeats WHERE monitor_id = ? AND checked_at >= datetime('now', ?)`, + id, "-"+strconv.Itoa(hours)+" hours").Scan(&up, &down) + if up+down == 0 { + return 100 + } + return float64(up) / float64(up+down) * 100 + } + st.Uptime24h = pct(24) + st.Uptime30d = pct(720) + _ = s.db.QueryRow(`SELECT COALESCE(AVG(NULLIF(ping_ms,0)),0) FROM heartbeats + WHERE monitor_id = ? AND checked_at >= datetime('now','-24 hours')`, id).Scan(&st.AvgPing24h) + _ = s.db.QueryRow(`SELECT COALESCE(SUM(status='up'),0), COALESCE(SUM(status='down'),0) + FROM heartbeats WHERE monitor_id = ?`, id).Scan(&st.Up, &st.Down) + return st +} + +// monitorView is the API shape: the row plus live uptime stats. +type monitorView struct { + *monitor.Monitor + Stats uptimeStats `json:"stats"` +} + +// ---------- handlers ---------- + +func (s *Server) listMonitorsH(c *gin.Context) { + ms, err := s.listMonitors() + if err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + out := make([]monitorView, 0, len(ms)) + for i := range ms { + out = append(out, monitorView{Monitor: &ms[i], Stats: s.monitorStats(ms[i].ID)}) + } + c.JSON(http.StatusOK, out) +} + +// monitorInput is the create/update payload — all fields optional on PATCH. +type monitorInput struct { + Name *string `json:"name"` + Type *string `json:"type"` + URL *string `json:"url"` + Hostname *string `json:"hostname"` + Port *int `json:"port"` + Method *string `json:"method"` + Headers *string `json:"headers"` + Body *string `json:"body"` + Keyword *string `json:"keyword"` + KeywordInvert *bool `json:"keywordInvert"` + JSONQuery *string `json:"jsonQuery"` + Expected *string `json:"expected"` + DNSType *string `json:"dnsType"` + IntervalS *int `json:"intervalS"` + TimeoutS *int `json:"timeoutS"` + Retries *int `json:"retries"` + Active *bool `json:"active"` + Tags []string `json:"tags"` + Notes *string `json:"notes"` +} + +func validMonitorType(t string) bool { + for _, v := range monitor.Types() { + if v == t { + return true + } + } + return false +} + +func strOr(p *string) string { + if p == nil { + return "" + } + return *p +} + +func strOr2(p *string, def string) string { + if p == nil || *p == "" { + return def + } + return *p +} + +func intOr(p *int) int { + if p == nil { + return 0 + } + return *p +} + +func intOr2(p *int, def int) int { + if p == nil || *p <= 0 { + return def + } + return *p +} + +func boolOr(p *bool) bool { return p != nil && *p } + +func boolOr2(p *bool, def bool) bool { + if p == nil { + return def + } + return *p +} + +func (s *Server) createMonitor(c *gin.Context) { + var in monitorInput + if err := c.ShouldBindJSON(&in); err != nil || in.Name == nil || in.Type == nil { + fail(c, http.StatusBadRequest, "name and type required") + return + } + if !validMonitorType(*in.Type) { + fail(c, http.StatusBadRequest, "invalid type") + return + } + id := newID("m") + token := "" + if *in.Type == "push" { + token = newID("push") + } + tags, _ := json.Marshal(in.Tags) + if in.Tags == nil { + tags = []byte("[]") + } + _, err := s.db.Exec(`INSERT INTO monitors + (id, name, type, url, hostname, port, method, headers, body, keyword, + keyword_invert, json_query, expected, dns_type, interval_s, timeout_s, + retries, active, push_token, tags, notes, position) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, + id, *in.Name, *in.Type, strOr(in.URL), strOr(in.Hostname), intOr(in.Port), + strOr2(in.Method, "GET"), strOr2(in.Headers, "{}"), strOr(in.Body), + strOr(in.Keyword), boolOr(in.KeywordInvert), strOr(in.JSONQuery), + strOr(in.Expected), strOr2(in.DNSType, "A"), intOr2(in.IntervalS, 60), + intOr2(in.TimeoutS, 10), intOr(in.Retries), boolOr2(in.Active, true), + token, string(tags), strOr(in.Notes), float64(time.Now().UnixNano())/1e9) + if err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + m, err := s.loadMonitor(id) + if err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + c.JSON(http.StatusCreated, monitorView{Monitor: m, Stats: s.monitorStats(id)}) +} + +func (s *Server) getMonitor(c *gin.Context) { + m, err := s.loadMonitor(c.Param("id")) + if notFound(err) { + fail(c, http.StatusNotFound, "monitor not found") + return + } + if err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + c.JSON(http.StatusOK, monitorView{Monitor: m, Stats: s.monitorStats(m.ID)}) +} + +// patchMonitor applies a sparse update; `active` flips pause/resume. +func (s *Server) patchMonitor(c *gin.Context) { + id := c.Param("id") + if _, err := s.loadMonitor(id); notFound(err) { + fail(c, http.StatusNotFound, "monitor not found") + return + } + var in monitorInput + if err := c.ShouldBindJSON(&in); err != nil { + fail(c, http.StatusBadRequest, "invalid body") + return + } + if in.Type != nil && !validMonitorType(*in.Type) { + fail(c, http.StatusBadRequest, "invalid type") + return + } + setStr := func(col string, p *string) { + if p != nil { + _, _ = s.db.Exec(`UPDATE monitors SET `+col+` = ? WHERE id = ?`, *p, id) + } + } + setStr("name", in.Name) + setStr("type", in.Type) + setStr("url", in.URL) + setStr("hostname", in.Hostname) + setStr("method", in.Method) + setStr("headers", in.Headers) + setStr("body", in.Body) + setStr("keyword", in.Keyword) + setStr("json_query", in.JSONQuery) + setStr("expected", in.Expected) + setStr("dns_type", in.DNSType) + setStr("notes", in.Notes) + setInt := func(col string, p *int) { + if p != nil { + _, _ = s.db.Exec(`UPDATE monitors SET `+col+` = ? WHERE id = ?`, *p, id) + } + } + setInt("port", in.Port) + setInt("interval_s", in.IntervalS) + setInt("timeout_s", in.TimeoutS) + setInt("retries", in.Retries) + if in.KeywordInvert != nil { + _, _ = s.db.Exec(`UPDATE monitors SET keyword_invert = ? WHERE id = ?`, *in.KeywordInvert, id) + } + if in.Tags != nil { + tags, _ := json.Marshal(in.Tags) + _, _ = s.db.Exec(`UPDATE monitors SET tags = ? WHERE id = ?`, string(tags), id) + } + if in.Active != nil { + // pause/resume flips the stored status back to pending for a fresh probe + status := "pending" + if !*in.Active { + status = "paused" + } + _, _ = s.db.Exec(`UPDATE monitors SET active = ?, status = ? WHERE id = ?`, *in.Active, status, id) + } + m, err := s.loadMonitor(id) + if err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + c.JSON(http.StatusOK, monitorView{Monitor: m, Stats: s.monitorStats(id)}) +} + +func (s *Server) deleteMonitor(c *gin.Context) { + res, err := s.db.Exec(`DELETE FROM monitors 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, "monitor not found") + return + } + c.Status(http.StatusNoContent) +} + +// heartbeats returns the recent check history for charts/logs. +func (s *Server) monitorHeartbeats(c *gin.Context) { + hours, _ := strconv.Atoi(c.DefaultQuery("hours", "24")) + if hours <= 0 || hours > 24*30 { + hours = 24 + } + rows, err := s.db.Query(`SELECT id, status, ping_ms, msg, cert_expiry, checked_at + FROM heartbeats WHERE monitor_id = ? AND checked_at >= datetime('now', ?) + ORDER BY checked_at`, c.Param("id"), "-"+strconv.Itoa(hours)+" hours") + if err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + defer rows.Close() + type hb struct { + ID int `json:"id"` + Status string `json:"status"` + PingMs int `json:"pingMs"` + Msg string `json:"msg"` + CertExpiry int `json:"certExpiry"` + CheckedAt string `json:"checkedAt"` + } + out := []hb{} + for rows.Next() { + var h hb + if err := rows.Scan(&h.ID, &h.Status, &h.PingMs, &h.Msg, &h.CertExpiry, &h.CheckedAt); err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + out = append(out, h) + } + c.JSON(http.StatusOK, out) +} + +// checkNow runs one check synchronously and returns the refreshed view. +func (s *Server) checkNow(c *gin.Context) { + m, err := s.loadMonitor(c.Param("id")) + if notFound(err) { + fail(c, http.StatusNotFound, "monitor not found") + return + } + if err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + s.runCheck(m) + m, err = s.loadMonitor(m.ID) + if err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + c.JSON(http.StatusOK, monitorView{Monitor: m, Stats: s.monitorStats(m.ID)}) +} + +// pushIngest is the push-monitor endpoint: any hit records an up heartbeat. +// ?msg= and ?ping= are optional annotations. +func (s *Server) pushIngest(c *gin.Context) { + m, err := s.loadMonitorByToken(c.Param("token")) + if err != nil { + fail(c, http.StatusNotFound, "unknown push token") + return + } + msg := c.Query("msg") + ping, _ := strconv.Atoi(c.Query("ping")) + s.writeHeartbeat(m, &monitor.Result{Up: true, PingMs: ping, Msg: msg, CertExpiry: -1}) + _, _ = s.db.Exec(`UPDATE monitors SET status = 'up', last_check = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`, m.ID) + c.JSON(http.StatusOK, gin.H{"status": "ok"}) +} + +// ---------- scheduler ---------- + +// runScheduler sweeps for due monitors every tick; each check runs in a +// bounded worker. Push monitors go stale -> down when silent too long. +func (s *Server) runScheduler(done <-chan struct{}) { + tick := time.NewTicker(10 * time.Second) + prune := time.NewTicker(time.Hour) + defer tick.Stop() + defer prune.Stop() + for { + select { + case <-done: + return + case <-tick.C: + s.sweep() + case <-prune.C: + if _, err := s.db.Exec(`DELETE FROM heartbeats WHERE checked_at < datetime('now','-30 days')`); err != nil { + s.log.Error("heartbeat prune", zap.Error(err)) + } + } + } +} + +var checkSem = make(chan struct{}, 8) + +func (s *Server) sweep() { + rows, err := s.db.Query(`SELECT ` + monitorCols + ` FROM monitors + WHERE active = 1 AND type != 'push' + AND (last_check IS NULL OR datetime(last_check, '+' || interval_s || ' seconds') <= datetime('now'))`) + if err != nil { + s.log.Error("monitor sweep", zap.Error(err)) + return + } + var due []monitor.Monitor + for rows.Next() { + if m, err := scanMonitor(rows); err == nil { + due = append(due, *m) + } + } + rows.Close() + + for i := range due { + m := &due[i] + checkSem <- struct{}{} + go func() { + defer func() { <-checkSem }() + s.runCheck(m) + }() + } + + // Push monitors: mark down when no push arrived within 2x interval. + if _, err := s.db.Exec(`UPDATE monitors SET status = 'down', + last_check = strftime('%Y-%m-%dT%H:%M:%fZ','now') + WHERE type = 'push' AND active = 1 + AND last_check IS NOT NULL + AND datetime(last_check, '+' || (2 * interval_s) || ' seconds') <= datetime('now') + AND status != 'down'`); err != nil { + s.log.Error("push stale sweep", zap.Error(err)) + } +} + +// runCheck executes one monitor check (with retries) and persists the result. +func (s *Server) runCheck(m *monitor.Monitor) *monitor.Result { + var res *monitor.Result + for attempt := 0; attempt <= m.Retries; attempt++ { + res = monitor.Check(context.Background(), m) + if res.Up || attempt == m.Retries { + break + } + time.Sleep(2 * time.Second) + } + s.writeHeartbeat(m, res) + prev := m.Status + next := "down" + if res.Up { + next = "up" + } + if _, err := s.db.Exec(`UPDATE monitors SET status = ?, last_check = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`, + next, m.ID); err != nil { + s.log.Error("monitor update", zap.Error(err)) + } + if prev != next && prev != "pending" { + s.onMonitorTransition(m, prev, next) + } + return res +} + +func (s *Server) writeHeartbeat(m *monitor.Monitor, res *monitor.Result) { + status := "down" + if res.Up { + status = "up" + } + if _, err := s.db.Exec(`INSERT INTO heartbeats (monitor_id, status, ping_ms, msg, cert_expiry) + VALUES (?,?,?,?,?)`, m.ID, status, res.PingMs, truncate(res.Msg, 300), res.CertExpiry); err != nil { + s.log.Error("heartbeat write", zap.Error(err)) + } +} + +// onMonitorTransition is the alert seam — M2 wires notifications here. +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)) +} + +func truncate(s string, n int) string { + if len(s) > n { + return s[:n] + } + return s +} + +// ---------- widget fetcher ---------- + +// monitorWidget serves board tiles bound to a monitor via config.monitorId. +type monitorWidget struct{ db *sql.DB } + +func (w *monitorWidget) Meta() widget.Type { + return widget.Type{ + Type: "monitor", Name: "Monitor", + Description: "Live status of an uptime monitor", + Fields: []widget.Field{ + {Key: "monitorId", Label: "Monitor", Required: true}, + }, + } +} + +func (w *monitorWidget) Fetch(_ context.Context, cfg json.RawMessage) (any, error) { + var c struct { + MonitorID string `json:"monitorId"` + } + if err := json.Unmarshal(cfg, &c); err != nil || c.MonitorID == "" { + return nil, errors.New("config.monitorId required") + } + row := w.db.QueryRow(`SELECT `+monitorCols+` FROM monitors WHERE id = ?`, c.MonitorID) + m, err := scanMonitor(row) + if err != nil { + return nil, errors.New("monitor not found") + } + var up, down int + var avgPing float64 + _ = w.db.QueryRow(`SELECT COALESCE(SUM(status='up'),0), COALESCE(SUM(status='down'),0), + COALESCE(AVG(NULLIF(ping_ms,0)),0) + FROM heartbeats WHERE monitor_id = ? AND checked_at >= datetime('now','-24 hours')`, + m.ID).Scan(&up, &down, &avgPing) + uptime := 100.0 + if up+down > 0 { + uptime = float64(up) / float64(up+down) * 100 + } + return map[string]any{ + "name": m.Name, + "status": m.Status, + "uptime24": uptime, + "ping": int(avgPing), + "type": m.Type, + }, nil +} diff --git a/apps/backend/internal/api/transfer.go b/apps/backend/internal/api/transfer.go index 25da740..19580d7 100644 --- a/apps/backend/internal/api/transfer.go +++ b/apps/backend/internal/api/transfer.go @@ -2,11 +2,26 @@ package api import ( "encoding/json" + "errors" + "fmt" "net/http" "github.com/gin-gonic/gin" ) +// errBadInput marks payload validation failures so import handlers can map +// them to 400 while genuine DB failures stay 500. +var errBadInput = errors.New("invalid import payload") + +// failImport maps replaceBoard errors: bad input -> 400, the rest -> 500. +func failImport(c *gin.Context, err error) { + if errors.Is(err, errBadInput) { + fail(c, http.StatusBadRequest, err.Error()) + return + } + fail(c, http.StatusInternalServerError, err.Error()) +} + // Export is the portable backup shape: version + settings + board tree. type Export struct { Version int `json:"version"` @@ -36,75 +51,10 @@ func (s *Server) importBoard(c *gin.Context) { fail(c, http.StatusBadRequest, "invalid export payload") return } - - tx, err := s.db.Begin() - if err != nil { - fail(c, http.StatusInternalServerError, err.Error()) + if err := s.replaceBoard(in.Sections, in.Settings); err != nil { + failImport(c, err) return } - defer func() { _ = tx.Rollback() }() - - for _, q := range []string{`DELETE FROM urls`, `DELETE FROM items`, `DELETE FROM sections`} { - if _, err := tx.Exec(q); err != nil { - fail(c, http.StatusInternalServerError, err.Error()) - return - } - } - for _, sec := range in.Sections { - if sec.ID == "" || sec.Name == "" { - fail(c, http.StatusBadRequest, "section missing id or name") - return - } - if _, err := tx.Exec(`INSERT INTO sections (id, name, position, collapsed) VALUES (?, ?, ?, ?)`, - sec.ID, sec.Name, sec.Position, sec.Collapsed); err != nil { - fail(c, http.StatusInternalServerError, err.Error()) - return - } - for _, it := range sec.Items { - if it.ID == "" || it.Name == "" { - fail(c, http.StatusBadRequest, "item missing id or name") - return - } - var cfg any - if it.Config != nil { - cfg = string(*it.Config) - } - if _, err := tx.Exec( - `INSERT INTO items (id, section_id, kind, name, icon, position, config) VALUES (?, ?, ?, ?, ?, ?, ?)`, - it.ID, sec.ID, orDefault(it.Kind, "service"), it.Name, it.Icon, it.Position, cfg); err != nil { - fail(c, http.StatusInternalServerError, err.Error()) - return - } - for _, u := range it.URLs { - if !validURL(u.URL) { - fail(c, http.StatusBadRequest, "invalid url: "+u.URL) - return - } - uid := u.ID - if uid == "" { - uid = newID("u") - } - if _, err := tx.Exec(`INSERT INTO urls (id, item_id, url, label, position) VALUES (?, ?, ?, ?, ?)`, - uid, it.ID, u.URL, u.Label, u.Position); err != nil { - fail(c, http.StatusInternalServerError, err.Error()) - return - } - } - } - } - for k, v := range in.Settings { - if _, err := tx.Exec( - `INSERT INTO settings (key, value) VALUES (?, ?) - ON CONFLICT(key) DO UPDATE SET value = excluded.value`, k, string(v)); err != nil { - fail(c, http.StatusInternalServerError, err.Error()) - return - } - } - if err := tx.Commit(); err != nil { - fail(c, http.StatusInternalServerError, err.Error()) - return - } - sections, err := s.board() if err != nil { fail(c, http.StatusInternalServerError, err.Error()) @@ -113,6 +63,66 @@ func (s *Server) importBoard(c *gin.Context) { c.JSON(http.StatusOK, sections) } +// replaceBoard swaps all board rows inside one transaction — shared by the +// Dash export import and the external-config importers. +func (s *Server) replaceBoard(sections []Section, settings map[string]json.RawMessage) error { + tx, err := s.db.Begin() + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + + for _, q := range []string{`DELETE FROM urls`, `DELETE FROM items`, `DELETE FROM sections`} { + if _, err := tx.Exec(q); err != nil { + return err + } + } + for _, sec := range sections { + if sec.ID == "" || sec.Name == "" { + return fmt.Errorf("%w: section missing id or name", errBadInput) + } + if _, err := tx.Exec(`INSERT INTO sections (id, name, position, collapsed) VALUES (?, ?, ?, ?)`, + sec.ID, sec.Name, sec.Position, sec.Collapsed); err != nil { + return err + } + for _, it := range sec.Items { + if it.ID == "" || it.Name == "" { + return fmt.Errorf("%w: item missing id or name", errBadInput) + } + var cfg any + if it.Config != nil { + cfg = string(*it.Config) + } + if _, err := tx.Exec( + `INSERT INTO items (id, section_id, kind, name, icon, position, config) VALUES (?, ?, ?, ?, ?, ?, ?)`, + it.ID, sec.ID, orDefault(it.Kind, "service"), it.Name, it.Icon, it.Position, cfg); err != nil { + return err + } + for _, u := range it.URLs { + if !validURL(u.URL) { + return fmt.Errorf("%w: invalid url: %s", errBadInput, u.URL) + } + uid := u.ID + if uid == "" { + uid = newID("u") + } + if _, err := tx.Exec(`INSERT INTO urls (id, item_id, url, label, position) VALUES (?, ?, ?, ?, ?)`, + uid, it.ID, u.URL, u.Label, u.Position); err != nil { + return err + } + } + } + } + for k, v := range settings { + if _, err := tx.Exec( + `INSERT INTO settings (key, value) VALUES (?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value`, k, string(v)); err != nil { + return err + } + } + return tx.Commit() +} + func orDefault(v, def string) string { if v == "" { return def diff --git a/apps/backend/internal/db/migrations/0003_monitors.sql b/apps/backend/internal/db/migrations/0003_monitors.sql new file mode 100644 index 0000000..0b23e70 --- /dev/null +++ b/apps/backend/internal/db/migrations/0003_monitors.sql @@ -0,0 +1,45 @@ +-- +goose Up +-- Uptime monitors and their check history (merge phase M1). +CREATE TABLE monitors ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + type TEXT NOT NULL CHECK (type IN ('http','tcp','ping','dns','keyword','json','push')), + url TEXT NOT NULL DEFAULT '', + hostname TEXT NOT NULL DEFAULT '', + port INTEGER NOT NULL DEFAULT 0, + method TEXT NOT NULL DEFAULT 'GET', + headers JSON NOT NULL DEFAULT '{}', + body TEXT NOT NULL DEFAULT '', + keyword TEXT NOT NULL DEFAULT '', + keyword_invert INTEGER NOT NULL DEFAULT 0, + json_query TEXT NOT NULL DEFAULT '', + expected TEXT NOT NULL DEFAULT '', + dns_type TEXT NOT NULL DEFAULT 'A', + interval_s INTEGER NOT NULL DEFAULT 60, + timeout_s INTEGER NOT NULL DEFAULT 10, + retries INTEGER NOT NULL DEFAULT 0, + active INTEGER NOT NULL DEFAULT 1, + status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('up','down','pending','paused','maintenance')), + push_token TEXT NOT NULL DEFAULT '', + tags JSON NOT NULL DEFAULT '[]', + notes TEXT NOT NULL DEFAULT '', + position REAL NOT NULL DEFAULT 0, + last_check TEXT, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); +CREATE INDEX monitors_due ON monitors(active, last_check); + +CREATE TABLE heartbeats ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + monitor_id TEXT NOT NULL REFERENCES monitors(id) ON DELETE CASCADE, + status TEXT NOT NULL CHECK (status IN ('up','down')), + ping_ms INTEGER NOT NULL DEFAULT 0, + msg TEXT NOT NULL DEFAULT '', + cert_expiry INTEGER NOT NULL DEFAULT -1, + checked_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); +CREATE INDEX heartbeats_monitor_time ON heartbeats(monitor_id, checked_at); + +-- +goose Down +DROP TABLE heartbeats; +DROP TABLE monitors; diff --git a/apps/backend/internal/monitor/monitor.go b/apps/backend/internal/monitor/monitor.go new file mode 100644 index 0000000..54165da --- /dev/null +++ b/apps/backend/internal/monitor/monitor.go @@ -0,0 +1,315 @@ +// Package monitor holds the uptime-check domain: the Monitor model mirroring +// the monitors table, plus the check implementations the scheduler invokes. +// No DB access, no HTTP handlers — pure check logic. +package monitor + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "strconv" + "strings" + "time" + + "golang.org/x/net/icmp" + "golang.org/x/net/ipv4" +) + +// Monitor mirrors one row of the monitors table. +type Monitor struct { + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + URL string `json:"url"` + Hostname string `json:"hostname"` + Port int `json:"port"` + Method string `json:"method"` + Headers string `json:"headers"` // JSON object + Body string `json:"body"` + Keyword string `json:"keyword"` + KeywordInvert bool `json:"keywordInvert"` + JSONQuery string `json:"jsonQuery"` + Expected string `json:"expected"` + DNSType string `json:"dnsType"` + IntervalS int `json:"intervalS"` + TimeoutS int `json:"timeoutS"` + Retries int `json:"retries"` + Active bool `json:"active"` + Status string `json:"status"` + PushToken string `json:"pushToken,omitempty"` + Tags []string `json:"tags"` + Notes string `json:"notes"` + Position float64 `json:"position"` + LastCheck *string `json:"lastCheck"` + CreatedAt string `json:"createdAt"` +} + +// Result is the outcome of one check. +type Result struct { + Up bool + PingMs int + Msg string + CertExpiry int // days until TLS cert expiry; -1 when not applicable +} + +// Types lists the check kinds the add-monitor dialog offers. +func Types() []string { + return []string{"http", "tcp", "ping", "dns", "keyword", "json", "push"} +} + +// Check dispatches to the checker for m.Type. Retries happen in the caller. +func Check(ctx context.Context, m *Monitor) *Result { + timeout := time.Duration(m.TimeoutS) * time.Second + if timeout <= 0 { + timeout = 10 * time.Second + } + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + switch m.Type { + case "http", "keyword", "json": + return checkHTTP(ctx, m) + case "tcp": + return checkTCP(ctx, m) + case "ping": + return checkPing(ctx, m) + case "dns": + return checkDNS(ctx, m) + default: + return &Result{Msg: "unknown monitor type: " + m.Type} + } +} + +func hostOf(m *Monitor) string { + if m.Hostname != "" { + return m.Hostname + } + return m.URL +} + +func checkHTTP(ctx context.Context, m *Monitor) *Result { + method := m.Method + if method == "" { + method = http.MethodGet + } + var body io.Reader + if m.Body != "" { + body = strings.NewReader(m.Body) + } + req, err := http.NewRequestWithContext(ctx, method, m.URL, body) + if err != nil { + return &Result{Msg: "bad url: " + m.URL} + } + var hdrs map[string]string + if json.Unmarshal([]byte(m.Headers), &hdrs) == nil { + for k, v := range hdrs { + req.Header.Set(k, v) + } + } + if m.Body != "" && req.Header.Get("Content-Type") == "" { + req.Header.Set("Content-Type", "application/json") + } + + start := time.Now() + resp, err := http.DefaultClient.Do(req) + ping := int(time.Since(start).Milliseconds()) + if err != nil { + return &Result{Msg: err.Error()} + } + defer resp.Body.Close() + + res := &Result{PingMs: ping} + if resp.TLS != nil && len(resp.TLS.PeerCertificates) > 0 { + res.CertExpiry = int(time.Until(resp.TLS.PeerCertificates[0].NotAfter).Hours() / 24) + } else { + res.CertExpiry = -1 + } + + switch m.Type { + case "keyword", "json": + raw, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20)) + if err != nil { + res.Msg = "read body: " + err.Error() + return res + } + if m.Type == "keyword" { + found := strings.Contains(string(raw), m.Keyword) + if m.KeywordInvert { + found = !found + } + res.Up = found + res.Msg = fmt.Sprintf("keyword %q %s", m.Keyword, map[bool]string{true: "found", false: "absent"}[found]) + return res + } + return checkJSONBody(res, raw, m) + default: + res.Up = resp.StatusCode < 400 + res.Msg = fmt.Sprintf("HTTP %d", resp.StatusCode) + return res + } +} + +// checkJSONBody walks a dot path ("a.b.0.c") into decoded JSON and compares +// the leaf to m.Expected. +func checkJSONBody(res *Result, raw []byte, m *Monitor) *Result { + var v any + if err := json.Unmarshal(raw, &v); err != nil { + res.Msg = "invalid JSON: " + err.Error() + return res + } + for _, key := range strings.Split(m.JSONQuery, ".") { + switch n := v.(type) { + case map[string]any: + v = n[key] + case []any: + i, err := strconv.Atoi(key) + if err != nil || i < 0 || i >= len(n) { + res.Msg = "json path missing: " + m.JSONQuery + return res + } + v = n[i] + default: + res.Msg = "json path missing: " + m.JSONQuery + return res + } + } + got := fmt.Sprint(v) + res.Up = got == m.Expected + res.Msg = fmt.Sprintf("%s = %q (want %q)", m.JSONQuery, got, m.Expected) + return res +} + +func checkTCP(ctx context.Context, m *Monitor) *Result { + host := hostOf(m) + if host == "" { + return &Result{Msg: "hostname or url required"} + } + addr := net.JoinHostPort(host, strconv.Itoa(m.Port)) + start := time.Now() + conn, err := (&net.Dialer{}).DialContext(ctx, "tcp", addr) + if err != nil { + return &Result{Msg: err.Error()} + } + _ = conn.Close() + return &Result{Up: true, PingMs: int(time.Since(start).Milliseconds()), Msg: "port open"} +} + +// checkPing sends one ICMP echo. Raw sockets need CAP_NET_RAW; the udp4 +// datagram path works unprivileged on Linux via ping_group_range. +func checkPing(ctx context.Context, m *Monitor) *Result { + host := hostOf(m) + if host == "" { + return &Result{Msg: "hostname required"} + } + dst, err := net.ResolveIPAddr("ip4", host) + if err != nil { + if dst, err = net.ResolveIPAddr("ip6", host); err != nil { + return &Result{Msg: "resolve: " + err.Error()} + } + } + + conn, err := icmp.ListenPacket("ip4:icmp", "0.0.0.0") + if err != nil { + conn, err = icmp.ListenPacket("udp4", "0.0.0.0") + } + if err != nil { + return &Result{Msg: "icmp unavailable (needs cap_net_raw or ping_group_range): " + err.Error()} + } + defer conn.Close() + _ = conn.SetDeadline(time.Now().Add(4 * time.Second)) + + wm := icmp.Message{Type: ipv4.ICMPTypeEcho, Code: 0, Body: &icmp.Echo{ + ID: 1, + Seq: 1, + Data: []byte("dash"), + }} + wb, err := wm.Marshal(nil) + if err != nil { + return &Result{Msg: err.Error()} + } + start := time.Now() + var dstAddr net.Addr = dst + if _, err := conn.WriteTo(wb, dstAddr); err != nil { + return &Result{Msg: err.Error()} + } + rb := make([]byte, 1500) + for { + n, _, err := conn.ReadFrom(rb) + if err != nil { + return &Result{Msg: "no reply: " + err.Error()} + } + rm, err := icmp.ParseMessage(ipv4.ICMPTypeEchoReply.Protocol(), rb[:n]) + if err != nil { + continue + } + if rm.Type == ipv4.ICMPTypeEchoReply { + return &Result{Up: true, PingMs: int(time.Since(start).Milliseconds()), Msg: "echo reply"} + } + } +} + +func checkDNS(ctx context.Context, m *Monitor) *Result { + host := hostOf(m) + if host == "" { + return &Result{Msg: "hostname required"} + } + r := net.DefaultResolver + start := time.Now() + var answers []string + var err error + switch strings.ToUpper(m.DNSType) { + case "AAAA": + var ips []net.IP + ips, err = r.LookupIP(ctx, "ip6", host) + for _, ip := range ips { + answers = append(answers, ip.String()) + } + case "MX": + var mxs []*net.MX + mxs, err = r.LookupMX(ctx, host) + for _, mx := range mxs { + answers = append(answers, mx.Host) + } + case "NS": + var nss []*net.NS + nss, err = r.LookupNS(ctx, host) + for _, ns := range nss { + answers = append(answers, ns.Host) + } + case "TXT": + answers, err = r.LookupTXT(ctx, host) + case "CNAME": + var c string + c, err = r.LookupCNAME(ctx, host) + if c != "" { + answers = []string{c} + } + default: // A + var ips []net.IP + ips, err = r.LookupIP(ctx, "ip4", host) + for _, ip := range ips { + answers = append(answers, ip.String()) + } + } + ping := int(time.Since(start).Milliseconds()) + if err != nil { + return &Result{Msg: err.Error()} + } + if len(answers) == 0 { + return &Result{Msg: "no records"} + } + res := &Result{Up: true, PingMs: ping, Msg: strings.Join(answers, ", ")} + if m.Expected != "" { + for _, a := range answers { + if strings.Contains(a, m.Expected) { + return res + } + } + res.Up = false + res.Msg = fmt.Sprintf("no record contains %q (got %s)", m.Expected, res.Msg) + } + return res +} diff --git a/apps/backend/internal/widget/adguard.go b/apps/backend/internal/widget/adguard.go index 1d0a795..6c319c8 100644 --- a/apps/backend/internal/widget/adguard.go +++ b/apps/backend/internal/widget/adguard.go @@ -11,7 +11,7 @@ import ( // AdGuard Home REST API: GET /control/stats with HTTP basic auth. type adguard struct{} -func init() { register(adguard{}) } +func init() { Register(adguard{}) } func (adguard) Meta() Type { return Type{ diff --git a/apps/backend/internal/widget/clock.go b/apps/backend/internal/widget/clock.go index ebc2646..ff69acb 100644 --- a/apps/backend/internal/widget/clock.go +++ b/apps/backend/internal/widget/clock.go @@ -10,7 +10,7 @@ import ( // it and the add-widget dialog can offer it like any other type. type clock struct{} -func init() { register(clock{}) } +func init() { Register(clock{}) } func (clock) Meta() Type { return Type{ diff --git a/apps/backend/internal/widget/immich.go b/apps/backend/internal/widget/immich.go index 5477b5a..1d3cca2 100644 --- a/apps/backend/internal/widget/immich.go +++ b/apps/backend/internal/widget/immich.go @@ -10,7 +10,7 @@ import ( // Immich REST API: GET /api/statistics with an x-api-key header. type immich struct{} -func init() { register(immich{}) } +func init() { Register(immich{}) } func (immich) Meta() Type { return Type{ diff --git a/apps/backend/internal/widget/pihole.go b/apps/backend/internal/widget/pihole.go index 42f880e..44c50cd 100644 --- a/apps/backend/internal/widget/pihole.go +++ b/apps/backend/internal/widget/pihole.go @@ -11,7 +11,7 @@ import ( // jarvis: v5 only for now — v6 (/api/...) lands when someone runs it. type pihole struct{} -func init() { register(pihole{}) } +func init() { Register(pihole{}) } func (pihole) Meta() Type { return Type{ diff --git a/apps/backend/internal/widget/widget.go b/apps/backend/internal/widget/widget.go index 58518ad..09224ea 100644 --- a/apps/backend/internal/widget/widget.go +++ b/apps/backend/internal/widget/widget.go @@ -40,7 +40,9 @@ type Fetcher interface { var registry = map[string]Fetcher{} -func register(f Fetcher) { registry[f.Meta().Type] = f } +// Register adds a fetcher — used by the api package for DB-backed widgets +// (monitor/domain/system) that can't live in this leaf package. +func Register(f Fetcher) { registry[f.Meta().Type] = f } // Get resolves a config.type to its fetcher. func Get(t string) (Fetcher, bool) { diff --git a/apps/frontend/package.json b/apps/frontend/package.json index b6b6a52..813e551 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -28,8 +28,10 @@ "lucide-react": "^0.545.0", "react": "^18.3.1", "react-dom": "^18.3.1", + "recharts": "^3.10.1", "tailwind-merge": "^3.3.1", - "tw-animate-css": "^1.4.0" + "tw-animate-css": "^1.4.0", + "wouter": "^3.11.0" }, "devDependencies": { "@tailwindcss/vite": "^4.1.13", diff --git a/apps/frontend/src/App.tsx b/apps/frontend/src/App.tsx index 3da0f7b..f36c3f5 100644 --- a/apps/frontend/src/App.tsx +++ b/apps/frontend/src/App.tsx @@ -1,12 +1,16 @@ import { useEffect, useState } from "react"; -import { LayoutGrid, Moon, Plus, Search, Settings, Sun } from "lucide-react"; +import { Link, Route, useLocation } from "wouter"; +import { Activity, LayoutGrid, Moon, Plus, Search, Settings, Sun } from "lucide-react"; import { BoardProvider, useBoard } from "@/board/store"; import { Board } from "@/board/Board"; import { ServiceDialog } from "@/components/ServiceDialog"; import { WidgetDialog } from "@/components/WidgetDialog"; import { SettingsDialog } from "@/components/SettingsDialog"; import { CommandPalette } from "@/components/CommandPalette"; +import { MonitorsPage } from "@/pages/MonitorsPage"; +import { MonitorDetailPage } from "@/pages/MonitorDetailPage"; import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; import type { Item } from "@/types"; export default function App() { @@ -17,8 +21,14 @@ export default function App() { ); } +const NAV = [ + { href: "/", label: "Board", icon: LayoutGrid }, + { href: "/monitors", label: "Monitors", icon: Activity }, +]; + function Shell() { const board = useBoard(); + const [loc] = useLocation(); const [settingsOpen, setSettingsOpen] = useState(false); const [paletteOpen, setPaletteOpen] = useState(false); const [svcOpen, setSvcOpen] = useState(false); @@ -60,6 +70,24 @@ function Shell() { Dash +
{err}
: null} diff --git a/apps/frontend/src/components/SettingsDialog.tsx b/apps/frontend/src/components/SettingsDialog.tsx index c81b73c..8b72b45 100644 --- a/apps/frontend/src/components/SettingsDialog.tsx +++ b/apps/frontend/src/components/SettingsDialog.tsx @@ -28,11 +28,22 @@ export function SettingsDialog({ open, onOpenChange }: { open: boolean; onOpenCh async function importJson(file: File) { setMsg(""); - const res = await fetch("/api/import", { method: "POST", body: file }); + // Dash exports are JSON with {version, sections}; anything else + // (Homepage/Homarr/Dashy) goes to the external importer for sniffing. + let target = "/api/import/external"; + try { + const j: unknown = JSON.parse(await file.text()); + if (j !== null && typeof j === "object" && "version" in j && "sections" in j) { + target = "/api/import"; + } + } catch { /* not JSON — external importer handles YAML */ } + const res = await fetch(target, { method: "POST", body: file }); if (res.ok) { location.reload(); // import replaces state; a reload re-syncs everything } else { - setMsg("Import failed — not a Dash export?"); + // SAFETY: error responses always carry {error: string} per openapi.yaml Error schema. + const body = (await res.json().catch(() => null)) as { error?: string } | null; + setMsg(body?.error ?? "Import failed"); } } @@ -90,7 +101,7 @@ export function SettingsDialog({ open, onOpenChange }: { open: boolean; onOpenChImport accepts Dash exports plus Homepage, Homarr, and Dashy configs.
{msg ?{msg}
: null} diff --git a/apps/frontend/src/components/WidgetDialog.tsx b/apps/frontend/src/components/WidgetDialog.tsx index 99d62e5..d39224c 100644 --- a/apps/frontend/src/components/WidgetDialog.tsx +++ b/apps/frontend/src/components/WidgetDialog.tsx @@ -31,6 +31,7 @@ export function WidgetDialog({ const [name, setName] = useState(""); const [sectionId, setSectionId] = useState(""); const [values, setValues] = useState+ {m.type} · {m.url || m.hostname}{m.port ? `:${m.port}` : ""} · every {m.intervalS}s +
+| + {new Date(h.checkedAt ?? "").toLocaleString()} + | ++ + {h.status} + + | +{h.pingMs ? `${h.pingMs}ms` : "—"} | +{h.msg} | +
| No checks in range. |
Uptime checks on a schedule.
+No monitors yet.
++ HTTP, TCP, ping, DNS, keyword, JSON path, or push — checks run server-side on your interval. +
+| Status | +Name | +Type | +Uptime 24h | +Uptime 30d | +Ping | +Every | ++ |
|---|---|---|---|---|---|---|---|
|
+
+ |
+ + {m.name} + | +{m.type} | ++ {m.stats?.uptime24h?.toFixed(1)}% + | ++ {m.stats?.uptime30d?.toFixed(1)}% + | ++ {m.stats?.avgPing24h ? `${m.stats.avgPing24h}ms` : "—"} + | +{m.intervalS}s | +
+
+
+ |
+