mirror of
https://github.com/Dvorinka/Dash.git
synced 2026-10-28 07:41:27 +00:00
feat: external importers + uptime monitors (merge plan M1)
Two tracks from the Beszel/Domain-Locker merge plan in ROADMAP.md: - Importers: POST /api/import/external sniffs Homepage services.yml, Homarr JSON, and Dashy conf.yml into sections/items; SettingsDialog import now accepts all four formats. - Monitors (M1): monitors + heartbeats schema, pure checkers for http/tcp/ping/dns/keyword/json-query/push, scheduler on app ctx, CRUD + check-now + push ingest API, /monitors + /monitors/:id pages (wouter), monitor board widget, ServiceDialog monitor opt-in. Authored By: TDvorak <[email protected]> Generated with Devin (https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
f428e721fe
commit
1ff1b29ae7
29 files changed
+3050
-98
No files matched your search
+76
@@ -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/`)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
}
|
||||
@@ -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{
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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() {
|
||||
</svg>
|
||||
Dash
|
||||
</div>
|
||||
<nav className="ml-6 flex items-center gap-1" aria-label="Primary">
|
||||
{NAV.map((n) => {
|
||||
const active = n.href === "/" ? loc === "/" : loc.startsWith(n.href);
|
||||
return (
|
||||
<Link
|
||||
key={n.href}
|
||||
href={n.href}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 rounded-[7px] px-2.5 py-1.5 text-[12.5px] transition-colors",
|
||||
active ? "bg-surface-hover text-text" : "text-text-faint hover:text-text",
|
||||
)}
|
||||
>
|
||||
<n.icon size={13} strokeWidth={1.8} />
|
||||
{n.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline" aria-label="Search (⌘K)"
|
||||
@@ -95,7 +123,15 @@ function Shell() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<Board onEditItem={openEditor} />
|
||||
<Route path="/">
|
||||
<Board onEditItem={openEditor} />
|
||||
</Route>
|
||||
<Route path="/monitors">
|
||||
<MonitorsPage />
|
||||
</Route>
|
||||
<Route path="/monitors/:id">
|
||||
{(p) => <MonitorDetailPage id={p.id} />}
|
||||
</Route>
|
||||
|
||||
<ServiceDialog open={svcOpen} onOpenChange={setSvcOpen} item={editing?.kind === "service" ? editing : undefined} />
|
||||
<WidgetDialog open={wdgOpen} onOpenChange={setWdgOpen} item={editing?.kind === "widget" ? editing : undefined} />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { ArrowRight, Download, LayoutGrid, Moon, Plus, Settings, Sun } from "lucide-react";
|
||||
import { useLocation } from "wouter";
|
||||
import { Activity, ArrowRight, Download, LayoutGrid, Moon, Plus, Settings, Sun } from "lucide-react";
|
||||
import { useBoard } from "@/board/store";
|
||||
import { hostOf, IconImg } from "@/board/primitives";
|
||||
import { rendererLabels, rendererList } from "@/renderers";
|
||||
@@ -34,12 +35,15 @@ export function CommandPalette({
|
||||
onSettings(): void;
|
||||
}) {
|
||||
const board = useBoard();
|
||||
const [, navigate] = useLocation();
|
||||
const [query, setQuery] = useState("");
|
||||
const [active, setActive] = useState(0);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const commands = useMemo<Command[]>(() => {
|
||||
const cmds: Command[] = [
|
||||
{ id: "nav-board", group: "Actions", glyph: LayoutGrid, label: "Go to Board", run: () => navigate("/") },
|
||||
{ id: "nav-monitors", group: "Actions", glyph: Activity, label: "Go to Monitors", run: () => navigate("/monitors") },
|
||||
{ id: "add-service", group: "Actions", glyph: Plus, label: "Add service", hint: "new", run: onAddService },
|
||||
{ id: "add-widget", group: "Actions", glyph: Plus, label: "Add widget", hint: "new", run: onAddWidget },
|
||||
{ id: "settings", group: "Actions", glyph: Settings, label: "Open settings", run: onSettings },
|
||||
@@ -73,7 +77,7 @@ export function CommandPalette({
|
||||
}
|
||||
}
|
||||
return cmds;
|
||||
}, [board.sections, board.theme, board.renderer, board.setSetting, onAddService, onAddWidget, onSettings]);
|
||||
}, [board.sections, board.theme, board.renderer, board.setSetting, onAddService, onAddWidget, onSettings, navigate]);
|
||||
|
||||
const q = query.trim().toLowerCase();
|
||||
const filtered = q
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { api } from "@/api";
|
||||
import type { Monitor, MonitorInput } from "@/types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
|
||||
const TYPES = ["http", "tcp", "ping", "dns", "keyword", "json", "push"] as const;
|
||||
type MonType = (typeof TYPES)[number];
|
||||
|
||||
// One dialog for add + edit. The type picker drives which fields matter —
|
||||
// same pattern as ServiceDialog.
|
||||
export function MonitorDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
monitor,
|
||||
onSaved,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (o: boolean) => void;
|
||||
monitor?: Monitor | undefined;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const editing = Boolean(monitor);
|
||||
const [f, setF] = useState<Record<string, string>>({});
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setErr("");
|
||||
setF({
|
||||
name: monitor?.name ?? "",
|
||||
type: monitor?.type ?? "http",
|
||||
url: monitor?.url ?? "",
|
||||
hostname: monitor?.hostname ?? "",
|
||||
port: monitor?.port ? String(monitor.port) : "",
|
||||
method: monitor?.method ?? "GET",
|
||||
keyword: monitor?.keyword ?? "",
|
||||
keywordInvert: monitor?.keywordInvert ? "1" : "",
|
||||
jsonQuery: monitor?.jsonQuery ?? "",
|
||||
expected: monitor?.expected ?? "",
|
||||
dnsType: monitor?.dnsType ?? "A",
|
||||
intervalS: String(monitor?.intervalS ?? 60),
|
||||
timeoutS: String(monitor?.timeoutS ?? 10),
|
||||
retries: String(monitor?.retries ?? 0),
|
||||
notes: monitor?.notes ?? "",
|
||||
});
|
||||
}, [open, monitor]);
|
||||
|
||||
const set = (k: string) => (e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setF((s) => ({ ...s, [k]: e.target.value }));
|
||||
const type = (f.type || "http") as MonType;
|
||||
|
||||
async function submit() {
|
||||
if (!f.name?.trim()) return setErr("Name is required"), undefined;
|
||||
if (type !== "push" && type !== "tcp" && type !== "ping" && type !== "dns" && !f.url?.trim())
|
||||
return setErr("URL is required"), undefined;
|
||||
if ((type === "tcp" || type === "ping" || type === "dns") && !f.hostname?.trim() && !f.url?.trim())
|
||||
return setErr("Hostname is required"), undefined;
|
||||
setBusy(true);
|
||||
const body: MonitorInput = {
|
||||
name: f.name.trim(),
|
||||
type,
|
||||
url: f.url?.trim() ?? "",
|
||||
hostname: f.hostname?.trim() ?? "",
|
||||
port: parseInt(f.port ?? "0") || 0,
|
||||
method: f.method || "GET",
|
||||
keyword: f.keyword ?? "",
|
||||
keywordInvert: f.keywordInvert === "1",
|
||||
jsonQuery: f.jsonQuery ?? "",
|
||||
expected: f.expected ?? "",
|
||||
dnsType: f.dnsType || "A",
|
||||
intervalS: Math.max(10, parseInt(f.intervalS ?? "60") || 60),
|
||||
timeoutS: Math.max(1, parseInt(f.timeoutS ?? "10") || 10),
|
||||
retries: Math.max(0, parseInt(f.retries ?? "0") || 0),
|
||||
notes: f.notes ?? "",
|
||||
};
|
||||
try {
|
||||
if (editing && monitor) {
|
||||
await api.PATCH("/api/monitors/{id}", { params: { path: { id: monitor.id! } }, body });
|
||||
} else {
|
||||
await api.POST("/api/monitors", { body });
|
||||
}
|
||||
onSaved();
|
||||
onOpenChange(false);
|
||||
} catch {
|
||||
setErr("save failed");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editing ? "Edit monitor" : "New monitor"}</DialogTitle>
|
||||
<DialogDescription>Uptime check on a schedule.</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex max-h-[60vh] flex-col gap-4 overflow-y-auto pr-1">
|
||||
<div className="grid grid-cols-[1fr_130px] gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="mon-name">Name</Label>
|
||||
<Input id="mon-name" value={f.name ?? ""} onChange={set("name")} placeholder="Plex" autoFocus />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>Type</Label>
|
||||
<Select value={type} onValueChange={(v) => setF((s) => ({ ...s, type: v }))}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{TYPES.map((t) => <SelectItem key={t} value={t}>{t}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(type === "http" || type === "keyword" || type === "json") && (
|
||||
<>
|
||||
<div className="grid grid-cols-[1fr_110px] gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="mon-url">URL</Label>
|
||||
<Input id="mon-url" value={f.url ?? ""} onChange={set("url")} placeholder="https://plex.local" className="font-mono text-[12px]" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>Method</Label>
|
||||
<Select value={f.method || "GET"} onValueChange={(v) => setF((s) => ({ ...s, method: v }))}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{["GET", "HEAD", "POST"].map((m) => <SelectItem key={m} value={m}>{m}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
{type === "keyword" && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="mon-kw">Keyword</Label>
|
||||
<Input id="mon-kw" value={f.keyword ?? ""} onChange={set("keyword")} placeholder="text that must appear" className="font-mono text-[12px]" />
|
||||
<label className="flex items-center gap-2 text-[12px] text-text-dim">
|
||||
<input type="checkbox" checked={f.keywordInvert === "1"}
|
||||
onChange={(e) => setF((s) => ({ ...s, keywordInvert: e.target.checked ? "1" : "" }))} />
|
||||
Invert (down when found)
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
{type === "json" && (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="mon-jq">JSON path</Label>
|
||||
<Input id="mon-jq" value={f.jsonQuery ?? ""} onChange={set("jsonQuery")} placeholder="data.status" className="font-mono text-[12px]" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="mon-exp">Expected</Label>
|
||||
<Input id="mon-exp" value={f.expected ?? ""} onChange={set("expected")} placeholder="ok" className="font-mono text-[12px]" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{(type === "tcp" || type === "ping" || type === "dns") && (
|
||||
<div className="grid grid-cols-[1fr_110px] gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="mon-host">Hostname</Label>
|
||||
<Input id="mon-host" value={f.hostname ?? ""} onChange={set("hostname")} placeholder="nas.local" className="font-mono text-[12px]" />
|
||||
</div>
|
||||
{type === "tcp" ? (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="mon-port">Port</Label>
|
||||
<Input id="mon-port" value={f.port ?? ""} onChange={set("port")} placeholder="443" className="font-mono text-[12px]" />
|
||||
</div>
|
||||
) : type === "dns" ? (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>Record</Label>
|
||||
<Select value={f.dnsType || "A"} onValueChange={(v) => setF((s) => ({ ...s, dnsType: v }))}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{["A", "AAAA", "CNAME", "MX", "NS", "TXT"].map((t) => <SelectItem key={t} value={t}>{t}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
) : <div />}
|
||||
</div>
|
||||
)}
|
||||
{type === "dns" && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="mon-dnsexp">Expected record contains (optional)</Label>
|
||||
<Input id="mon-dnsexp" value={f.expected ?? ""} onChange={set("expected")} className="font-mono text-[12px]" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{type === "push" && (
|
||||
<p className="rounded-[8px] border border-border bg-surface px-3 py-2 text-[12px] text-text-dim">
|
||||
After saving, your jobs call the shown push URL on a schedule.
|
||||
If it goes quiet for 2× the interval, the monitor flips down.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="mon-int">Interval (s)</Label>
|
||||
<Input id="mon-int" value={f.intervalS ?? "60"} onChange={set("intervalS")} className="font-mono text-[12px]" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="mon-to">Timeout (s)</Label>
|
||||
<Input id="mon-to" value={f.timeoutS ?? "10"} onChange={set("timeoutS")} className="font-mono text-[12px]" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="mon-re">Retries</Label>
|
||||
<Input id="mon-re" value={f.retries ?? "0"} onChange={set("retries")} className="font-mono text-[12px]" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{monitor?.pushToken ? (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>Push URL</Label>
|
||||
<code className="rounded-[8px] border border-border bg-surface px-3 py-2 font-mono text-[11px] text-text-dim">
|
||||
{location.origin}/api/push/{monitor.pushToken}
|
||||
</code>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{err ? <p className="text-[12px] text-destructive">{err}</p> : null}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="items-center">
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={busy}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="button" onClick={() => void submit()} disabled={busy}>
|
||||
{busy ? "Saving…" : editing ? "Save" : "Add monitor"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -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<UrlInput[]>([{ url: "", label: "local" }]);
|
||||
const [sectionId, setSectionId] = useState("");
|
||||
const [alsoMonitor, setAlsoMonitor] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState("");
|
||||
const fileRef = useRef<HTMLInputElement>(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<UrlInput>) =>
|
||||
@@ -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({
|
||||
<p className="text-[11px] text-text-faint">URL or uploaded file; blank shows a letter tile.</p>
|
||||
</div>
|
||||
|
||||
{!editing && (
|
||||
<label className="flex items-center gap-2 text-[12.5px] text-text-dim">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={alsoMonitor}
|
||||
onChange={(e) => setAlsoMonitor(e.target.checked)}
|
||||
/>
|
||||
Also create an uptime monitor for the first URL
|
||||
</label>
|
||||
)}
|
||||
|
||||
{err ? <p className="text-[12px] text-destructive">{err}</p> : null}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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
|
||||
<Upload size={12} /> Import
|
||||
</Button>
|
||||
<input
|
||||
ref={fileRef} type="file" accept="application/json" className="hidden"
|
||||
ref={fileRef} type="file" accept=".json,.yml,.yaml" className="hidden"
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f) void importJson(f);
|
||||
@@ -98,6 +109,7 @@ export function SettingsDialog({ open, onOpenChange }: { open: boolean; onOpenCh
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[11px] text-text-faint">Import accepts Dash exports plus Homepage, Homarr, and Dashy configs.</p>
|
||||
{msg ? <p className="text-[12px] text-destructive">{msg}</p> : null}
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
@@ -31,6 +31,7 @@ export function WidgetDialog({
|
||||
const [name, setName] = useState("");
|
||||
const [sectionId, setSectionId] = useState("");
|
||||
const [values, setValues] = useState<Record<string, string>>({});
|
||||
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<WidgetType[]>)
|
||||
.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({
|
||||
<Label htmlFor={`wdg-${f.key}`}>
|
||||
{f.label}{f.required ? "" : " (optional)"}
|
||||
</Label>
|
||||
<Input
|
||||
id={`wdg-${f.key}`}
|
||||
type={f.secret ? "password" : "text"}
|
||||
value={values[f.key] ?? ""}
|
||||
placeholder={f.placeholder}
|
||||
onChange={(e) => setValues((v) => ({ ...v, [f.key]: e.target.value }))}
|
||||
className="font-mono text-[12px]"
|
||||
/>
|
||||
{f.key === "monitorId" ? (
|
||||
<Select
|
||||
value={values[f.key] ?? ""}
|
||||
onValueChange={(v) => setValues((s) => ({ ...s, [f.key]: v }))}
|
||||
>
|
||||
<SelectTrigger><SelectValue placeholder="Pick a monitor…" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{monitors.map((m) => (
|
||||
<SelectItem key={m.id} value={m.id ?? ""}>{m.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
id={`wdg-${f.key}`}
|
||||
type={f.secret ? "password" : "text"}
|
||||
value={values[f.key] ?? ""}
|
||||
placeholder={f.placeholder}
|
||||
onChange={(e) => setValues((v) => ({ ...v, [f.key]: e.target.value }))}
|
||||
className="font-mono text-[12px]"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
|
||||
@@ -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<MonitorView | null>(null);
|
||||
const [hbs, setHbs] = useState<Heartbeat[]>([]);
|
||||
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 <main className="mx-auto max-w-5xl px-7 py-8 text-[13px] text-text-faint">Loading…</main>;
|
||||
|
||||
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 (
|
||||
<main className="mx-auto w-full max-w-5xl px-7 py-8">
|
||||
<Link href="/monitors" className="mb-4 inline-flex items-center gap-1.5 text-[12px] text-text-faint hover:text-text">
|
||||
<ArrowLeft size={12} /> Monitors
|
||||
</Link>
|
||||
|
||||
<div className="mb-6 flex items-start justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className={cn("size-2 rounded-full", m.status === "up" ? "bg-up" : m.status === "down" ? "bg-down" : "bg-text-faint/40")} />
|
||||
<h1 className="text-[17px] font-semibold tracking-tight">{m.name}</h1>
|
||||
<span className="font-mono text-[10.5px] uppercase tracking-[0.08em] text-text-faint">{m.status}</span>
|
||||
</div>
|
||||
<p className="mt-1 font-mono text-[11.5px] text-text-faint">
|
||||
{m.type} · {m.url || m.hostname}{m.port ? `:${m.port}` : ""} · every {m.intervalS}s
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => void api.POST("/api/monitors/{id}/check", { params: { path: { id } } }).then(load)}>
|
||||
<RefreshCw size={12} /> Check now
|
||||
</Button>
|
||||
<Button variant="outline" size="sm"
|
||||
onClick={() => void api.PATCH("/api/monitors/{id}", { params: { path: { id } }, body: { active: !m.active } }).then(load)}>
|
||||
{m.active ? <Pause size={12} /> : <Play size={12} />} {m.active ? "Pause" : "Resume"}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => setDlgOpen(true)}>Edit</Button>
|
||||
<Button variant="outline" size="sm" aria-label="Delete"
|
||||
onClick={() => {
|
||||
if (confirm(`Delete monitor "${m.name}"?`)) {
|
||||
void api.DELETE("/api/monitors/{id}", { params: { path: { id } } }).then(() => { location.href = "/monitors"; });
|
||||
}
|
||||
}}>
|
||||
<Trash2 size={12} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-6 grid grid-cols-4 gap-3 max-[720px]:grid-cols-2">
|
||||
{[
|
||||
["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]) => (
|
||||
<div key={k} className="rounded-[10px] border border-border bg-surface px-4 py-3">
|
||||
<div className="font-mono text-[10px] uppercase tracking-[0.1em] text-text-faint">{k}</div>
|
||||
<div className="mt-1 font-mono text-[17px] font-medium">{v}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h2 className="font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">Response time</h2>
|
||||
<div className="flex gap-1">
|
||||
{[24, 168, 720].map((h) => (
|
||||
<button key={h} type="button" onClick={() => setHours(h)}
|
||||
className={cn("rounded-[6px] px-2 py-1 font-mono text-[10.5px]",
|
||||
hours === h ? "bg-surface-hover text-text" : "text-text-faint hover:text-text")}>
|
||||
{h === 24 ? "24h" : h === 168 ? "7d" : "30d"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-6 h-56 rounded-[10px] border border-border bg-surface p-3">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={chart} margin={{ top: 4, right: 4, bottom: 0, left: -18 }}>
|
||||
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" vertical={false} />
|
||||
<XAxis dataKey="t" tick={{ fontSize: 10, fill: "var(--text-faint)" }} tickLine={false} axisLine={false} minTickGap={40} />
|
||||
<YAxis tick={{ fontSize: 10, fill: "var(--text-faint)" }} tickLine={false} axisLine={false} unit=" ms" width={62} />
|
||||
<Tooltip
|
||||
contentStyle={{ background: "var(--surface)", border: "1px solid var(--border)", borderRadius: 8, fontSize: 11 }}
|
||||
labelStyle={{ color: "var(--text-faint)" }}
|
||||
/>
|
||||
<Area type="monotone" dataKey="ping" stroke="var(--up)" fill="var(--up)" fillOpacity={0.12} strokeWidth={1.5} dot={false} isAnimationActive={false} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<h2 className="mb-2 font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">Recent checks</h2>
|
||||
<div className="overflow-hidden rounded-[10px] border border-border">
|
||||
<table className="w-full text-[12px]">
|
||||
<tbody>
|
||||
{[...hbs].reverse().slice(0, 40).map((h) => (
|
||||
<tr key={h.id} className="border-b border-border/60 last:border-0">
|
||||
<td className="px-4 py-2 font-mono text-[10.5px] text-text-faint">
|
||||
{new Date(h.checkedAt ?? "").toLocaleString()}
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<span className={cn("font-mono text-[10.5px] uppercase", h.status === "up" ? "text-up" : "text-down")}>
|
||||
{h.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2 font-mono text-[11px] text-text-dim">{h.pingMs ? `${h.pingMs}ms` : "—"}</td>
|
||||
<td className="px-4 py-2 font-mono text-[11px] text-text-faint">{h.msg}</td>
|
||||
</tr>
|
||||
))}
|
||||
{hbs.length === 0 && (
|
||||
<tr><td className="px-4 py-6 text-center text-text-faint">No checks in range.</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<MonitorDialog open={dlgOpen} onOpenChange={setDlgOpen} monitor={m} onSaved={load} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<span
|
||||
className={cn(
|
||||
"size-1.5 shrink-0 rounded-full",
|
||||
status === "up" ? "bg-up" : status === "down" ? "bg-down" : "bg-text-faint/40",
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function MonitorsPage() {
|
||||
const [monitors, setMonitors] = useState<MonitorView[]>([]);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [dlgOpen, setDlgOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<MonitorView | undefined>();
|
||||
|
||||
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<unknown>) {
|
||||
if (!id) return;
|
||||
await fn();
|
||||
await load();
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="mx-auto w-full max-w-5xl px-7 py-8">
|
||||
<div className="mb-5 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-[15px] font-semibold tracking-tight">Monitors</h1>
|
||||
<p className="text-[12px] text-text-faint">Uptime checks on a schedule.</p>
|
||||
</div>
|
||||
<Button onClick={() => { setEditing(undefined); setDlgOpen(true); }}>
|
||||
<Plus size={13} strokeWidth={2.2} /> New monitor
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loaded && monitors.length === 0 ? (
|
||||
<div className="rounded-[10px] border border-dashed border-border px-6 py-14 text-center">
|
||||
<p className="text-[13px] text-text-dim">No monitors yet.</p>
|
||||
<p className="mt-1 text-[11.5px] text-text-faint">
|
||||
HTTP, TCP, ping, DNS, keyword, JSON path, or push — checks run server-side on your interval.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-[10px] border border-border">
|
||||
<table className="w-full text-[13px]">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-surface text-left font-mono text-[10px] uppercase tracking-[0.1em] text-text-faint">
|
||||
<th className="px-4 py-2.5 font-medium">Status</th>
|
||||
<th className="px-4 py-2.5 font-medium">Name</th>
|
||||
<th className="px-4 py-2.5 font-medium">Type</th>
|
||||
<th className="px-4 py-2.5 font-medium">Uptime 24h</th>
|
||||
<th className="px-4 py-2.5 font-medium">Uptime 30d</th>
|
||||
<th className="px-4 py-2.5 font-medium">Ping</th>
|
||||
<th className="px-4 py-2.5 font-medium">Every</th>
|
||||
<th className="px-4 py-2.5" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{monitors.map((m) => (
|
||||
<tr key={m.id} className="border-b border-border/60 last:border-0 hover:bg-surface-hover/60">
|
||||
<td className="px-4 py-3">
|
||||
<span className="flex items-center gap-2 font-mono text-[10.5px] uppercase tracking-[0.06em] text-text-faint">
|
||||
<StatusDot status={m.status} />
|
||||
{m.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 font-medium">
|
||||
<Link href={`/monitors/${m.id}`} className="hover:underline">{m.name}</Link>
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-[11px] text-text-dim">{m.type}</td>
|
||||
<td className="px-4 py-3 font-mono text-[11px] text-text-dim">
|
||||
{m.stats?.uptime24h?.toFixed(1)}%
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-[11px] text-text-dim">
|
||||
{m.stats?.uptime30d?.toFixed(1)}%
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-[11px] text-text-dim">
|
||||
{m.stats?.avgPing24h ? `${m.stats.avgPing24h}ms` : "—"}
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-[11px] text-text-dim">{m.intervalS}s</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button variant="outline" size="icon" aria-label="Check now"
|
||||
onClick={() => void act(m.id, () => api.POST("/api/monitors/{id}/check", { params: { path: { id: m.id! } } }))}>
|
||||
<RefreshCw size={12} />
|
||||
</Button>
|
||||
<Button variant="outline" size="icon" aria-label={m.active ? "Pause" : "Resume"}
|
||||
onClick={() => void act(m.id, () => api.PATCH("/api/monitors/{id}", { params: { path: { id: m.id! } }, body: { active: !m.active } }))}>
|
||||
{m.active ? <Pause size={12} /> : <Play size={12} />}
|
||||
</Button>
|
||||
<Button variant="outline" size="icon" aria-label="Delete"
|
||||
onClick={() => {
|
||||
if (confirm(`Delete monitor "${m.name}"?`)) {
|
||||
void act(m.id, () => api.DELETE("/api/monitors/{id}", { params: { path: { id: m.id! } } }));
|
||||
}
|
||||
}}>
|
||||
<Trash2 size={12} />
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => { setEditing(m); setDlgOpen(true); }}>
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<MonitorDialog open={dlgOpen} onOpenChange={setDlgOpen} monitor={editing} onSaved={load} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,12 @@ export type Status = components["schemas"]["Status"];
|
||||
export type StatusMap = Record<string, Status>;
|
||||
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";
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 (
|
||||
<a
|
||||
href={monId ? `/monitors/${monId}` : "/monitors"}
|
||||
className="flex w-full items-center gap-2.5 text-left"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"size-1.5 shrink-0 rounded-full",
|
||||
error ? "bg-down" : status === "up" ? "bg-up" : status === "down" ? "bg-down" : "bg-text-faint/40",
|
||||
)}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate text-[12.5px] font-medium">
|
||||
{typeof data?.name === "string" ? data.name : item.name}
|
||||
</span>
|
||||
<span className="shrink-0 font-mono text-[10.5px] text-text-faint">
|
||||
{error ? "err" : uptime !== undefined ? `${uptime.toFixed(1)}%` : "…"}
|
||||
{ping ? ` · ${Math.round(ping)}ms` : ""}
|
||||
</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
+336
@@ -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]
|
||||
|
||||
Generated
+414
-1
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user