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 +
+ + + + + ); +} diff --git a/apps/frontend/src/components/ServiceDialog.tsx b/apps/frontend/src/components/ServiceDialog.tsx index 300c3ba..63c53ec 100644 --- a/apps/frontend/src/components/ServiceDialog.tsx +++ b/apps/frontend/src/components/ServiceDialog.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useState } from "react"; import { Plus, Trash2, Upload, X } from "lucide-react"; import { useBoard } from "@/board/store"; +import { api } from "@/api"; import type { Item, UrlInput } from "@/types"; import { Button } from "@/components/ui/button"; import { @@ -33,6 +34,7 @@ export function ServiceDialog({ const [icon, setIcon] = useState(""); const [urls, setUrls] = useState([{ url: "", label: "local" }]); const [sectionId, setSectionId] = useState(""); + const [alsoMonitor, setAlsoMonitor] = useState(false); const [busy, setBusy] = useState(false); const [err, setErr] = useState(""); const fileRef = useRef(null); @@ -48,6 +50,7 @@ export function ServiceDialog({ : [{ url: "", label: "local" }], ); setSectionId(item?.sectionId ?? board.sections[0]?.id ?? ""); + setAlsoMonitor(false); }, [open, item, board.sections]); const setUrl = (i: number, patch: Partial) => @@ -81,6 +84,12 @@ export function ServiceDialog({ urls: cleanUrls, }); if (created && file) await board.uploadIcon(created.id, file); + if (alsoMonitor && cleanUrls[0]) { + // Uptime check on the primary URL — appears under /monitors. + await api.POST("/api/monitors", { + body: { name: name.trim(), type: "http", url: cleanUrls[0].url }, + }); + } } onOpenChange(false); } catch (e) { @@ -193,6 +202,17 @@ export function ServiceDialog({

URL or uploaded file; blank shows a letter tile.

+ {!editing && ( + + )} + {err ?

{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; onOpenCh Import { const f = e.target.files?.[0]; if (f) void importJson(f); @@ -98,6 +109,7 @@ export function SettingsDialog({ open, onOpenChange }: { open: boolean; onOpenCh }} /> +

Import 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>({}); + const [monitors, setMonitors] = useState<{ id?: string; name?: string }[]>([]); const [busy, setBusy] = useState(false); const [err, setErr] = useState(""); @@ -42,6 +43,10 @@ export function WidgetDialog({ .then((r) => r.json() as Promise) .then(setTypes) .catch(() => setTypes([])); + fetch("/api/monitors") + .then((r) => r.json() as Promise<{ id?: string; name?: string }[]>) + .then(setMonitors) + .catch(() => setMonitors([])); const cfg = item?.config ?? null; setType(cfg && typeof cfg.type === "string" ? cfg.type : ""); @@ -141,14 +146,28 @@ export function WidgetDialog({ - setValues((v) => ({ ...v, [f.key]: e.target.value }))} - className="font-mono text-[12px]" - /> + {f.key === "monitorId" ? ( + + ) : ( + setValues((v) => ({ ...v, [f.key]: e.target.value }))} + className="font-mono text-[12px]" + /> + )} ))} diff --git a/apps/frontend/src/pages/MonitorDetailPage.tsx b/apps/frontend/src/pages/MonitorDetailPage.tsx new file mode 100644 index 0000000..8053597 --- /dev/null +++ b/apps/frontend/src/pages/MonitorDetailPage.tsx @@ -0,0 +1,146 @@ +import { useCallback, useEffect, useState } from "react"; +import { Link } from "wouter"; +import { ArrowLeft, Pause, Play, RefreshCw, Trash2 } from "lucide-react"; +import { Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"; +import { api } from "@/api"; +import type { Heartbeat, MonitorView } from "@/types"; +import { MonitorDialog } from "@/components/MonitorDialog"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +export function MonitorDetailPage({ id }: { id: string }) { + const [m, setM] = useState(null); + const [hbs, setHbs] = useState([]); + const [hours, setHours] = useState(24); + const [dlgOpen, setDlgOpen] = useState(false); + + const load = useCallback(async () => { + const [{ data: mv }, { data: hb }] = await Promise.all([ + api.GET("/api/monitors/{id}", { params: { path: { id } } }), + api.GET("/api/monitors/{id}/heartbeats", { params: { path: { id }, query: { hours } } }), + ]); + if (mv) setM(mv); + if (hb) setHbs(hb); + }, [id, hours]); + + useEffect(() => { + void load(); + const t = setInterval(() => void load(), 30_000); + return () => clearInterval(t); + }, [load]); + + if (!m) return
Loading…
; + + const chart = hbs.map((h) => ({ + t: new Date(h.checkedAt ?? "").toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }), + ping: h.pingMs, + up: h.status === "up", + })); + + return ( +
+ + Monitors + + +
+
+
+ +

{m.name}

+ {m.status} +
+

+ {m.type} · {m.url || m.hostname}{m.port ? `:${m.port}` : ""} · every {m.intervalS}s +

+
+
+ + + + +
+
+ +
+ {[ + ["Uptime 24h", `${m.stats?.uptime24h?.toFixed(2)}%`], + ["Uptime 30d", `${m.stats?.uptime30d?.toFixed(2)}%`], + ["Avg ping 24h", m.stats?.avgPing24h ? `${m.stats.avgPing24h} ms` : "—"], + ["Checks", String((m.stats?.up ?? 0) + (m.stats?.down ?? 0))], + ].map(([k, v]) => ( +
+
{k}
+
{v}
+
+ ))} +
+ +
+

Response time

+
+ {[24, 168, 720].map((h) => ( + + ))} +
+
+
+ + + + + + + + + +
+ +

Recent checks

+
+ + + {[...hbs].reverse().slice(0, 40).map((h) => ( + + + + + + + ))} + {hbs.length === 0 && ( + + )} + +
+ {new Date(h.checkedAt ?? "").toLocaleString()} + + + {h.status} + + {h.pingMs ? `${h.pingMs}ms` : "—"}{h.msg}
No checks in range.
+
+ + +
+ ); +} diff --git a/apps/frontend/src/pages/MonitorsPage.tsx b/apps/frontend/src/pages/MonitorsPage.tsx new file mode 100644 index 0000000..581e926 --- /dev/null +++ b/apps/frontend/src/pages/MonitorsPage.tsx @@ -0,0 +1,135 @@ +import { useCallback, useEffect, useState } from "react"; +import { Link } from "wouter"; +import { Pause, Play, Plus, RefreshCw, Trash2 } from "lucide-react"; +import { api } from "@/api"; +import type { MonitorView } from "@/types"; +import { MonitorDialog } from "@/components/MonitorDialog"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +function StatusDot({ status }: { status?: string }) { + return ( + + ); +} + +export function MonitorsPage() { + const [monitors, setMonitors] = useState([]); + const [loaded, setLoaded] = useState(false); + const [dlgOpen, setDlgOpen] = useState(false); + const [editing, setEditing] = useState(); + + const load = useCallback(async () => { + const { data } = await api.GET("/api/monitors"); + if (data) setMonitors(data); + setLoaded(true); + }, []); + + useEffect(() => { + void load(); + const t = setInterval(() => void load(), 30_000); + return () => clearInterval(t); + }, [load]); + + async function act(id: string | undefined, fn: () => Promise) { + if (!id) return; + await fn(); + await load(); + } + + return ( +
+
+
+

Monitors

+

Uptime checks on a schedule.

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

No monitors yet.

+

+ HTTP, TCP, ping, DNS, keyword, JSON path, or push — checks run server-side on your interval. +

+
+ ) : ( +
+ + + + + + + + + + + + + + {monitors.map((m) => ( + + + + + + + + + + + ))} + +
StatusNameTypeUptime 24hUptime 30dPingEvery +
+ + + {m.status} + + + {m.name} + {m.type} + {m.stats?.uptime24h?.toFixed(1)}% + + {m.stats?.uptime30d?.toFixed(1)}% + + {m.stats?.avgPing24h ? `${m.stats.avgPing24h}ms` : "—"} + {m.intervalS}s +
+ + + + +
+
+
+ )} + + +
+ ); +} diff --git a/apps/frontend/src/types.ts b/apps/frontend/src/types.ts index b07cc50..6fc2995 100644 --- a/apps/frontend/src/types.ts +++ b/apps/frontend/src/types.ts @@ -11,6 +11,12 @@ export type Status = components["schemas"]["Status"]; export type StatusMap = Record; export type ExportPayload = components["schemas"]["Export"]; +// Monitoring (merge program) — same generated-schema rule applies. +export type Monitor = components["schemas"]["Monitor"]; +export type MonitorInput = components["schemas"]["MonitorInput"]; +export type MonitorView = components["schemas"]["MonitorView"]; +export type Heartbeat = components["schemas"]["Heartbeat"]; + export type Theme = "dark" | "light"; export type RendererName = "bento" | "cards" | "index" | "console"; diff --git a/apps/frontend/src/widgets/index.tsx b/apps/frontend/src/widgets/index.tsx index 848bfe1..2a71a64 100644 --- a/apps/frontend/src/widgets/index.tsx +++ b/apps/frontend/src/widgets/index.tsx @@ -3,6 +3,7 @@ import type { Item } from "@/types"; import { ClockWidget } from "@/widgets/clock"; import { PiholeWidget, AdguardWidget } from "@/widgets/blocker"; import { ImmichWidget } from "@/widgets/immich"; +import { MonitorWidget } from "@/widgets/monitor"; // WidgetHost seam: renderers own the tile chrome; this maps config.type to // the component that fills it. Adding a widget = one fetcher in Go + one @@ -28,6 +29,7 @@ const registry: WidgetRegistry = { pihole: { view: PiholeWidget }, adguard: { view: AdguardWidget }, immich: { view: ImmichWidget }, + monitor: { view: MonitorWidget }, }; export function cfgStr(item: Item, key: string): string { diff --git a/apps/frontend/src/widgets/monitor.tsx b/apps/frontend/src/widgets/monitor.tsx new file mode 100644 index 0000000..674c8a3 --- /dev/null +++ b/apps/frontend/src/widgets/monitor.tsx @@ -0,0 +1,38 @@ +import type { WidgetData } from "@/widgets"; +import type { Item } from "@/types"; +import { cn } from "@/lib/utils"; + +// Board tile for an uptime monitor: status dot, uptime %, avg ping. +// config.monitorId binds it; data comes from the monitor fetcher. +export function MonitorWidget({ item, data, error }: { + item: Item; + data: WidgetData | undefined; + error: string | undefined; +}) { + const status = typeof data?.status === "string" ? data.status : undefined; + const uptime = typeof data?.uptime24 === "number" ? data.uptime24 : undefined; + const ping = typeof data?.ping === "number" ? data.ping : undefined; + const monId = typeof item.config?.monitorId === "string" ? item.config.monitorId : ""; + + return ( + e.stopPropagation()} + > + + + {typeof data?.name === "string" ? data.name : item.name} + + + {error ? "err" : uptime !== undefined ? `${uptime.toFixed(1)}%` : "…"} + {ping ? ` · ${Math.round(ping)}ms` : ""} + + + ); +} diff --git a/openapi.yaml b/openapi.yaml index 5f4ba89..0c35978 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -214,6 +214,164 @@ paths: "404": $ref: "#/components/responses/NotFound" + /api/monitors: + get: + operationId: listMonitors + summary: All monitors with live uptime stats + responses: + "200": + description: Monitors ordered by position + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/MonitorView" + post: + operationId: createMonitor + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/MonitorInput" + responses: + "201": + description: Created monitor + content: + application/json: + schema: + $ref: "#/components/schemas/MonitorView" + "400": + $ref: "#/components/responses/BadRequest" + + /api/monitors/{id}: + get: + operationId: getMonitor + parameters: + - $ref: "#/components/parameters/id" + responses: + "200": + description: Monitor with stats + content: + application/json: + schema: + $ref: "#/components/schemas/MonitorView" + "404": + $ref: "#/components/responses/NotFound" + patch: + operationId: updateMonitor + summary: Sparse update; `active` toggles pause/resume + parameters: + - $ref: "#/components/parameters/id" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/MonitorInput" + responses: + "200": + description: Updated monitor + content: + application/json: + schema: + $ref: "#/components/schemas/MonitorView" + "404": + $ref: "#/components/responses/NotFound" + delete: + operationId: deleteMonitor + parameters: + - $ref: "#/components/parameters/id" + responses: + "204": + description: Monitor and its heartbeats deleted + "404": + $ref: "#/components/responses/NotFound" + + /api/monitors/{id}/heartbeats: + get: + operationId: monitorHeartbeats + parameters: + - $ref: "#/components/parameters/id" + - name: hours + in: query + schema: + type: integer + default: 24 + maximum: 720 + responses: + "200": + description: Check history, oldest first + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Heartbeat" + + /api/monitors/{id}/check: + post: + operationId: checkMonitorNow + summary: Run one check synchronously + parameters: + - $ref: "#/components/parameters/id" + responses: + "200": + description: Monitor after the check + content: + application/json: + schema: + $ref: "#/components/schemas/MonitorView" + "404": + $ref: "#/components/responses/NotFound" + + /api/push/{token}: + get: + operationId: pushMonitorGet + summary: Record an up heartbeat for a push monitor + parameters: + - name: token + in: path + required: true + schema: + type: string + - name: msg + in: query + schema: + type: string + - name: ping + in: query + schema: + type: integer + responses: + "200": + description: Heartbeat recorded + "404": + $ref: "#/components/responses/NotFound" + post: + operationId: pushMonitorPost + summary: Record an up heartbeat for a push monitor + parameters: + - name: token + in: path + required: true + schema: + type: string + - name: msg + in: query + schema: + type: string + - name: ping + in: query + schema: + type: integer + responses: + "200": + description: Heartbeat recorded + "404": + $ref: "#/components/responses/NotFound" + /api/widgets/types: get: operationId: widgetTypes @@ -330,6 +488,40 @@ paths: "400": $ref: "#/components/responses/BadRequest" + /api/import/external: + post: + operationId: importExternal + summary: > + Import a foreign dashboard config — Homepage services.yml, Homarr + JSON export, or Dashy conf.yml. Format is auto-detected; the board + is replaced and existing settings are preserved. + requestBody: + required: true + content: + application/octet-stream: + schema: + type: string + format: binary + application/json: + schema: + type: string + format: binary + text/yaml: + schema: + type: string + format: binary + responses: + "200": + description: Imported board tree + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Section" + "400": + $ref: "#/components/responses/BadRequest" + components: parameters: id: @@ -518,6 +710,150 @@ components: checkedAt: type: string format: date-time + Monitor: + type: object + required: [id, name, type, active, status] + properties: + id: + type: string + name: + type: string + type: + type: string + enum: [http, tcp, ping, dns, keyword, json, push] + url: + type: string + hostname: + type: string + port: + type: integer + method: + type: string + headers: + type: string + description: JSON object of request headers + body: + type: string + keyword: + type: string + keywordInvert: + type: boolean + jsonQuery: + type: string + description: Dot path into a JSON response, e.g. "data.status" + expected: + type: string + dnsType: + type: string + intervalS: + type: integer + timeoutS: + type: integer + retries: + type: integer + active: + type: boolean + status: + type: string + enum: [up, down, pending, paused, maintenance] + pushToken: + type: string + tags: + type: array + items: + type: string + notes: + type: string + position: + type: number + lastCheck: + type: string + format: date-time + nullable: true + createdAt: + type: string + format: date-time + MonitorInput: + type: object + properties: + name: + type: string + type: + type: string + enum: [http, tcp, ping, dns, keyword, json, push] + url: + type: string + hostname: + type: string + port: + type: integer + method: + type: string + headers: + type: string + body: + type: string + keyword: + type: string + keywordInvert: + type: boolean + jsonQuery: + type: string + expected: + type: string + dnsType: + type: string + intervalS: + type: integer + timeoutS: + type: integer + retries: + type: integer + active: + type: boolean + tags: + type: array + items: + type: string + notes: + type: string + UptimeStats: + type: object + properties: + up: + type: integer + down: + type: integer + uptime24h: + type: number + uptime30d: + type: number + avgPing24h: + type: integer + MonitorView: + allOf: + - $ref: "#/components/schemas/Monitor" + - type: object + properties: + stats: + $ref: "#/components/schemas/UptimeStats" + Heartbeat: + type: object + properties: + id: + type: integer + status: + type: string + enum: [up, down] + pingMs: + type: integer + msg: + type: string + certExpiry: + type: integer + checkedAt: + type: string + format: date-time WidgetField: type: object required: [key, label] diff --git a/package-lock.json b/package-lock.json index a3e80b2..02c2125 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,8 +37,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", @@ -1986,6 +1988,32 @@ "npm": ">=9.5.0" } }, + "node_modules/@reduxjs/toolkit": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz", + "integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-rc.3", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", @@ -2382,6 +2410,18 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@tailwindcss/node": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", @@ -2711,6 +2751,69 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -2756,6 +2859,12 @@ "@types/react": "^18.0.0" } }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@vitejs/plugin-react": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", @@ -2950,6 +3059,127 @@ "devOptional": true, "license": "MIT" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -2968,6 +3198,12 @@ } } }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -3005,6 +3241,18 @@ "node": ">=10.13.0" } }, + "node_modules/es-toolkit": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.52.0.tgz", + "integrity": "sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks", + "tests/types", + "tests/browser-compat" + ] + }, "node_modules/esbuild": { "version": "0.28.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", @@ -3057,6 +3305,12 @@ "node": ">=6" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3137,6 +3391,16 @@ "node": ">= 14" } }, + "node_modules/immer": { + "version": "11.1.18", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.18.tgz", + "integrity": "sha512-EQyQtLiYW029lyoczMl/Hh4Xu7cDecSc58JRYpHyL4tIAu3eqd1yJzQX04d2BZHDkzFFvm6qJEJWOtfDSWAXbQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/index-to-position": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz", @@ -3150,6 +3414,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/jiti": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", @@ -3782,6 +4055,36 @@ "react": "^18.3.1" } }, + "node_modules/react-is": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.3.0.tgz", + "integrity": "sha512-UpMYezM4v5/18F28aC66AEsjXIgE02kyEMH6yLdgLXu/UTfa1Ntwck/nNLrbqJsEXW7gPb0coNO9FQse9WTovA==", + "license": "MIT", + "peer": true + }, + "node_modules/react-redux": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz", + "integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, "node_modules/react-refresh": { "version": "0.18.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", @@ -3861,6 +4164,60 @@ } } }, + "node_modules/recharts": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.10.1.tgz", + "integrity": "sha512-QXFrvt6IVcw7eeZCoyXTwkIJAX3Dv1nyVhMicXJ47GsGDDpcN8z6o644DibE9XjpBTThtsomLKnTV6lc+cVFUA==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "^1.9.0 || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^11.1.8", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.2.0", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, + "node_modules/regexparam": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/regexparam/-/regexparam-3.0.0.tgz", + "integrity": "sha512-RSYAtP31mvYLkAHrOlh25pCNQ5hWnT106VukGaaFfuJrZFkGRX5GhUAdPqpSDXxOhA2c4akmRuplv1mRqnBn6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -3871,6 +4228,12 @@ "node": ">=0.10.0" } }, + "node_modules/reselect": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz", + "integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==", + "license": "MIT" + }, "node_modules/rollup": { "version": "4.63.3", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.3.tgz", @@ -3990,6 +4353,12 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -4137,6 +4506,37 @@ } } }, + "node_modules/use-sync-external-store": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.7.0.tgz", + "integrity": "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/vite": { "version": "7.3.6", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", @@ -4212,6 +4612,19 @@ } } }, + "node_modules/wouter": { + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/wouter/-/wouter-3.11.0.tgz", + "integrity": "sha512-xyMLHhytdGhIjXVRcSjeYbXd7TR1lrxVTPLGBWmmyW52ZuWVQ1jXmsgoJ4G5s4tjloJCq7h4i2eREljWUQ4+/A==", + "license": "Unlicense", + "dependencies": { + "regexparam": "^3.0.0", + "use-sync-external-store": "^1.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",