diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..f6b3c59 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,31 @@ +name: Bug report +description: Something is broken +labels: [bug] +body: + - type: textarea + id: what + attributes: + label: What happened + description: What you did, what you expected, what happened instead. + validations: + required: true + - type: textarea + id: logs + attributes: + label: Logs + description: Relevant server logs or browser console output. + render: text + - type: input + id: version + attributes: + label: Dash version + description: Image tag or release version. + placeholder: v0.7.0 + - type: dropdown + id: install + attributes: + label: Install method + options: + - Docker + - Binary + - Built from source diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..01c8ed4 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,16 @@ +name: Feature request +description: Suggest an addition +labels: [enhancement] +body: + - type: textarea + id: problem + attributes: + label: What problem does this solve + description: Concrete use case, not just "add X". + validations: + required: true + - type: textarea + id: idea + attributes: + label: Proposed shape + description: Optional. How it might look or work. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..fd5c399 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,11 @@ +## Summary + + + +## Test plan + +- [ ] `npm run typecheck` + `npm run lint` + `npm run build` green +- [ ] `go build ./...` + `go vet ./...` + `go test ./...` green +- [ ] Smoke-tested the affected flow manually + + diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..8e426d7 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,71 @@ +name: release + +on: + push: + tags: ["v*"] + +permissions: + contents: write + packages: write + +jobs: + binaries: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + - run: npm ci + - run: npm run build + + # go:embed all:dist reads internal/web/dist at compile time. + - run: rm -rf apps/backend/internal/web/dist && cp -r apps/frontend/dist apps/backend/internal/web/dist + + - uses: actions/setup-go@v5 + with: + go-version-file: apps/backend/go.mod + cache-dependency-path: apps/backend/go.sum + + - uses: goreleaser/goreleaser-action@v6 + with: + distribution: goreleaser + version: "~> v2" + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + docker: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: docker/setup-buildx-action@v3 + + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - uses: docker/metadata-action@v5 + id: meta + with: + images: ghcr.io/${{ github.repository }} + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=raw,value=latest + + - uses: docker/build-push-action@v6 + with: + context: . + file: infra/Dockerfile + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.gitignore b/.gitignore index 6ce78f1..c85899f 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,8 @@ data/* # misc .DS_Store coverage/ +dist/ # local tooling artifacts .playwright-mcp/ +.playwright-cli/ diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..c39d727 --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,61 @@ +version: 2 + +project_name: dash + +before: + hooks: + # The embedded UI must be built and copied into internal/web/dist before + # any Go build. The release workflow does this; fail loudly here if the + # stub is still in place. + - sh -c 'test -d apps/backend/internal/web/dist/assets' + +builds: + - id: dash + dir: apps/backend + binary: dash + main: ./cmd/dash + env: [CGO_ENABLED=0] + goos: [linux, darwin, windows] + goarch: [amd64, arm64] + ldflags: ["-s", "-w", "-X main.version={{.Version}}"] + + - id: dash-agent + dir: apps/backend + binary: dash-agent + main: ./cmd/dash-agent + env: [CGO_ENABLED=0] + goos: [linux] + goarch: [amd64, arm64, arm] + goarm: ["7"] + ldflags: ["-s", "-w", "-X main.version={{.Version}}"] + +archives: + - id: dash + ids: [dash] + formats: [tar.gz] + name_template: "{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}{{ with .Arm }}v{{ . }}{{ end }}" + format_overrides: + - goos: windows + formats: [zip] + files: [README.md, LICENSE] + - id: dash-agent + ids: [dash-agent] + formats: [tar.gz] + name_template: "{{ .ProjectName }}-agent_{{ .Os }}_{{ .Arch }}{{ with .Arm }}v{{ . }}{{ end }}" + files: [README.md, LICENSE] + +checksum: + name_template: "checksums.txt" + +changelog: + use: github + sort: asc + filters: + exclude: + - "^docs:" + - "^chore:" + - "^test:" + +release: + footer: | + Docker: `docker pull ghcr.io/dvorinka/dash:{{ .Tag }}` diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..609579c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,87 @@ +# Changelog + +All notable changes to Dash. Format follows +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow +[Semantic Versioning](https://semver.org/). + +## [Unreleased] + +## [0.7.0] — 2026-09-21 + +### Added + +- Incidents with open/ack/resolve/close lifecycle and per-incident update + timeline; monitors auto-open and auto-resolve incidents on transitions +- Maintenance windows that suppress monitor alerts +- Public status pages at `/status/:slug` (unauthenticated, per-page monitor + and system selection) +- SVG badges: `/api/badge/monitor/:id.svg`, `/api/badge/domain/:id.svg` +- Prometheus metrics at `/api/metrics` +- CSV bulk import (`/api/import/csv`) and monitors/domains/systems in JSON + export +- Generic webhook notifications (`notify_webhook` setting): monitor up/down + and domain events, Slack/Discord/ntfy-compatible payload, `/api/notify/test` + +## [0.6.0] — 2026-09-21 + +### Added + +- System monitoring: `systems` + `system_stats`, bearer-token push ingest + (`/api/systems/ingest`), offline detection +- `dash-agent` binary: Linux collector for CPU, memory, swap, disk, network, + load, uptime, temperatures, and Docker container state +- Systems list and detail pages with Recharts graphs; system board widget + +## [0.5.0] — 2026-09-21 + +### Added + +- Domain watch: RDAP + TCP-WHOIS lookup, WHOIS parsing, DNS records + (NS/MX/TXT/A/AAAA), TLS certificate details, host geo, DNS/email/hosting/ + certificate provider detection +- Daily refresh scheduler, manual refresh, check history +- Domain list and detail pages, expiry and SSL countdowns, domain board widget + +## [0.4.0] — 2026-09-21 + +### Added + +- Uptime monitors: HTTP, TCP, ping, DNS, keyword, JSON-path, and push types; + interval/timeout/retries config, heartbeat history, 24h/30d uptime stats +- Monitor scheduler, check-now endpoint, push ingest (`/api/push/:token`) +- Monitor list and detail pages, monitor board widget, service-to-monitor + creation checkbox +- Importers: Homepage `services.yml`, Homarr JSON, Dashy `conf.yml` + +## [0.3.0] — 2026-09-17 + +### Added + +- Index and Console board renderers (four total), command palette (⌘K), + icon CDN suggestions + +## [0.2.0] — 2026-09-17 + +### Added + +- Widget layer: Go fetcher registry, config-field metadata, 30s TTL cache +- Pi-hole, AdGuard Home, Immich, and clock widgets; per-widget config UI + +## [0.1.0] — 2026-09-17 + +### Added + +- Board MVP: sections, items, multi-URL services, drag-drop, collapse state +- Bento and Cards renderers, dark/light themes, icon upload + letter fallback +- Server-side status pings with up/down chips +- Single Go binary serving the embedded React UI; SQLite via goose migrations; + Docker image on distroless + +[Unreleased]: https://github.com/Dvorinka/Dash/compare/v0.7.0...HEAD +[0.7.0]: https://github.com/Dvorinka/Dash/compare/v0.6.0...v0.7.0 +[0.6.0]: https://github.com/Dvorinka/Dash/compare/v0.5.0...v0.6.0 +[0.5.0]: https://github.com/Dvorinka/Dash/compare/v0.4.0...v0.5.0 +[0.4.0]: https://github.com/Dvorinka/Dash/compare/v0.3.0...v0.4.0 +[0.3.0]: https://github.com/Dvorinka/Dash/compare/v0.2.0...v0.3.0 +[0.2.0]: https://github.com/Dvorinka/Dash/compare/v0.1.0...v0.2.0 +[0.1.0]: https://github.com/Dvorinka/Dash/releases/tag/v0.1.0 diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..4f1a414 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,38 @@ +# Code of Conduct + +This project follows the [Contributor Covenant](https://www.contributor-covenant.org/), +version 2.1. + +## Our pledge + +We pledge to make participation in this project a harassment-free experience +for everyone, regardless of age, body size, visible or invisible disability, +ethnicity, sex characteristics, gender identity and expression, level of +experience, education, socio-economic status, nationality, personal appearance, +race, religion, or sexual identity and orientation. + +## Our standards + +Examples of behavior that contributes to a positive environment: + +- Demonstrating empathy and kindness toward other people +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback +- Accepting responsibility and apologizing to those affected by our mistakes +- Focusing on what is best for the community + +Examples of unacceptable behavior: + +- The use of sexualized language or imagery, and sexual attention or advances +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information without explicit permission +- Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement + +Instances of unacceptable behavior may be reported to the maintainer via +GitHub (@Dvorinka). All complaints will be reviewed and investigated promptly +and fairly. The full enforcement guidelines are at +https://www.contributor-covenant.org/version/2/1/code_of_conduct.html diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..25a48b0 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,60 @@ +# Contributing + +Thanks for considering a contribution. Dash is a small, deliberate codebase — +read [AGENTS.md](AGENTS.md) and [ROADMAP.md](ROADMAP.md) first; the roadmap is +canonical for what gets built and when. + +## Setup + +Requires Go 1.24+ and Node 20+. + +```sh +npm install +npm run generate # regenerate the TS client from openapi.yaml +npm run dev # vite dev server on :5173, proxies /api to :8080 +``` + +```sh +cd apps/backend +DASH_DEV=1 go run ./cmd/dash # API on :8080 +``` + +## Rules of the house + +- `openapi.yaml` is the API contract. Change it first, then + `npm run generate`. Never hand-write API types in the frontend. +- All schema changes go through goose migrations in + `apps/backend/internal/db/migrations` — `NNNN_name.sql` with `-- +goose Up` + and `-- +goose Down`. +- Board renderers share state, drag-drop, and popover primitives. Never + reimplement them per renderer. +- No auth, no YAML-as-source-of-truth, no external database. These are + non-goals for v1. +- Standard library before dependencies; existing helpers before new ones. +- User-visible strings go through `t("key")` from `src/i18n` — add the key to + `en.ts` rather than inlining literals in new UI. + +## Before pushing + +```sh +npm run typecheck +npm run lint +npm run build + +cd apps/backend +go build ./... && go vet ./... && go test ./... +``` + +Plus a real smoke test of whatever you changed. All gates green or no push. + +## Commits and PRs + +- Conventional-ish subjects: `feat:`, `fix:`, `docs:`, `refactor:` — keep them + short and describe the why. +- Small, reviewable PRs. Fill in the template; a screenshot of UI changes is + worth more than a paragraph. + +## Reporting bugs / requesting features + +Use the issue templates. For security issues, see +[SECURITY.md](SECURITY.md) — do not open a public issue. diff --git a/README.md b/README.md index 6aefdcb..4b5185d 100644 --- a/README.md +++ b/README.md @@ -3,21 +3,69 @@ A self-hosted homelab dashboard. CasaOS-grade ease of use, Vercel-grade design. Single container, UI-managed, no YAML, no auth. -**Status:** pre-release. See [ROADMAP.md](ROADMAP.md). +![Dash board](docs/screenshots/board.png) + +## Features + +- **Service board** — sections, drag-and-drop, multi-URL services, icon upload, + four board renderers (Bento, Cards, Index, Console), dark/light themes +- **Uptime monitors** — HTTP, TCP, ping, DNS, keyword, JSON-path, and push + checks on a schedule, with heartbeat history and uptime stats +- **Domain watch** — WHOIS/RDAP, DNS records, TLS certificate expiry, + registrar and provider detection +- **System monitoring** — lightweight `dash-agent` pushes CPU, memory, disk, + network, load, temperatures, and Docker container state +- **Incidents & status pages** — manual and automatic incidents, maintenance + windows, public `/status/:slug` pages, SVG badges +- **Alerts** — generic webhook notifications (Slack, Discord, ntfy compatible) +- **Observability** — Prometheus `/api/metrics`, JSON export, CSV bulk import, + importers for Homepage, Homarr, and Dashy configs ## Run ```sh -docker run -v ./data:/data -p 3000:3000 dash +docker run -d -v ./data:/data -p 3000:3000 ghcr.io/dvorinka/dash:latest ``` -or +or with compose: + +```yaml +services: + dash: + image: ghcr.io/dvorinka/dash:latest + ports: + - "3000:3000" + volumes: + - ./data:/data + restart: unless-stopped +``` + +Then open http://localhost:3000. Everything is configured from the UI. + +## System agent + +Monitor a Linux host: create a system in the UI, copy the token, then: ```sh -docker compose up +dash-agent -url http://dash:3000 -token ``` -Then open http://localhost:3000. +One static binary, no dependencies. See +[docs/installation.md](docs/installation.md) for systemd and Docker setups. + +## Screenshots + +| | | +|---|---| +| ![Monitors](docs/screenshots/monitors.png) | ![Domains](docs/screenshots/domains.png) | +| ![Systems](docs/screenshots/systems.png) | ![Status page](docs/screenshots/status-page.png) | + +## Docs + +- [Installation](docs/installation.md) +- [Configuration](docs/configuration.md) +- [Widget development](docs/widgets.md) +- [Roadmap](ROADMAP.md) · [Changelog](CHANGELOG.md) · [Contributing](CONTRIBUTING.md) ## Develop diff --git a/ROADMAP.md b/ROADMAP.md index 42d5648..8423a74 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -34,6 +34,10 @@ or popover logic. | `v0.1.0` | Phase 0 + 1 — working MVP, single image | manual smoke test ✅ | | `v0.2.0` | Phase 2 — widget layer + first integrations | Pi-hole widget live ✅ | | `v0.3.0` | Phase 3 — four renderers, ⌘K palette, icon suggest | palette + switcher live ✅ | +| `v0.4.0` | Phase M1 — uptime monitors + alerts backbone | monitor lifecycle live | +| `v0.5.0` | Phase M2 — domain intelligence | domain lookup + expiry alerts | +| `v0.6.0` | Phase M3 — system monitoring + agent | agent ingest + charts | +| `v0.7.0` | Phase M4 — incidents, status pages, badges, metrics | public status page live | | `v1.0.0` | Phase 4 — OSS polish, docs, importers, CI releases | public repo launch | | `v1.x` | Phase 5 — demand-driven extras only | per-feature | @@ -113,14 +117,87 @@ 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. + +- [x] Schema: `systems` (name, token, host, os/arch, last_seen, status, `latest` JSON snapshot), `system_stats` (system_id, ts, payload JSON — one column, no sub-field queries; 7d retention prune) +- [x] Ingest: `POST /api/systems/ingest` with per-system bearer token; offline when silent > max(3×interval, 30s); `system.up`/`system.down` webhook events +- [x] `cmd/dash-agent`: Linux collector — `/proc` (cpu/mem/net/load/uptime), `/sys` hwmon temps, statfs disk, docker.sock container list; POST every `-interval` (default 10s); `-once` debug mode +- [x] Agent packaging: systemd unit (`packaging/dash-agent.service`) + `infra/Dockerfile.agent` + goreleaser binaries (Phase 4) +- [x] Systems page (status cards, cpu/mem/disk bars, uptime) + detail (recharts: cpu%, mem%, net rx/tx, load; temps + containers tables) +- [x] Board widget `system` (cpu/mem/disk mini bars) +- [x] Not ported: the beszel agent protocol (SSH/WS into PocketBase) — our agent is push-JSON. SMART/ZFS/GPU metrics + per-container cpu/mem deferred to demand. + +### Phase M4 — Ops layer → v0.7.0 + +- [x] Incidents: `incidents` + `incident_updates`; manual CRUD + auto-open on monitor down, auto-resolve on recovery; severity + status flow (open → ack → resolved → closed) +- [x] Maintenance windows: `maintenance_windows` (monitor_ids empty = all); suppress alerts + auto-incidents, status pages show `maintenance` state while active +- [x] Status pages: `status_pages` (monitor_ids/system_ids JSON columns — a join table for ≤ dozens of ids is ceremony); admin `/status`, public `/status/:slug` + `GET /api/status-pages/:slug/public` +- [x] Badges: `GET /api/badge/:kind/:id.svg` — stateless SVG from live data (monitor status+uptime, domain days-left, system status) +- [x] `GET /api/metrics` Prometheus exposition +- [x] Bulk: `POST /api/import/csv?kind=monitors|domains` header-driven CSV; monitors/domains/systems rows folded into `/api/export` (v2, additive) +- [x] ⌘K palette + header nav wired for new pages + ## Phase 4 — Open-source launch → v1.0.0 -- [ ] Docs: install, configuration, widget development guide (`docs/`) -- [ ] Importers: Homepage `services.yml`, Homarr JSON, Dashy `conf.yml` → sections/items/urls -- [ ] `README.md` hero: screenshots (4 themes), demo GIF, `docker run` one-liner -- [ ] goreleaser → binaries; GHCR image on tag; semver + `CHANGELOG.md` -- [ ] `CONTRIBUTING.md`, issue/PR templates, code of conduct, security policy -- [ ] i18n scaffolding (en default) +- [x] Docs: install, configuration, widget development guide (`docs/`) +- [x] Importers: Homepage `services.yml`, Homarr JSON, Dashy `conf.yml` → sections/items/urls +- [x] `README.md` hero: screenshots, `docker run` one-liner (demo GIF deferred — pngs suffice for v1.0.0) +- [x] goreleaser → binaries; GHCR image on tag; semver + `CHANGELOG.md` +- [x] `CONTRIBUTING.md`, issue/PR templates, code of conduct, security policy +- [x] i18n scaffolding (`src/i18n/` — typed `t()` + `en` dict; full string extraction deferred to first real locale) - [ ] Public repo flip + announcement assets **Exit:** a stranger can `docker run`, import their Homepage config, and have a working board in under two minutes. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..8196235 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,34 @@ +# Security Policy + +## Supported versions + +| Version | Supported | +|---|---| +| latest release | yes | +| older releases | no | + +Dash is pre-1.0; only the latest release receives fixes. + +## Reporting a vulnerability + +Use GitHub's private vulnerability reporting: +**Security → Advisories → Report a vulnerability** on this repository. + +Do not open a public issue for security problems. + +Please include: + +- affected version/commit +- steps to reproduce or a proof of concept +- impact assessment + +You will get an acknowledgement within a few days. If the report is accepted, +a fix ships in the next release and you are credited in the advisory (unless +you prefer otherwise). + +## Scope notes + +Dash is designed for trusted private networks — it ships **without +authentication** by design. Exposure to the public internet is a deployment +choice outside the project's control, but issues that remain exploitable on a +LAN (SSRF in checkers, injection, token leakage, unsafe schemes) are in scope. diff --git a/apps/backend/cmd/dash-agent/main.go b/apps/backend/cmd/dash-agent/main.go new file mode 100644 index 0000000..7a46359 --- /dev/null +++ b/apps/backend/cmd/dash-agent/main.go @@ -0,0 +1,362 @@ +// Command dash-agent collects host metrics and pushes them to a Dash server. +// Linux-first: /proc for cpu/mem/net/load/uptime, /sys hwmon for temps, +// statfs for disk, and the Docker socket for container liveness when present. +// +// env DASH_URL=http://dash:8080 DASH_TOKEN=agent_xxx dash-agent +// +// Flags override env: -url, -token, -interval (seconds), -once. +package main + +import ( + "bytes" + "context" + "encoding/json" + "flag" + "fmt" + "io" + "log" + "net" + "net/http" + "os" + "runtime" + "strconv" + "strings" + "syscall" + "time" +) + +// version is stamped by goreleaser via -X main.version. +var version = "dev" + +func main() { + url := flag.String("url", os.Getenv("DASH_URL"), "dash server base URL") + token := flag.String("token", os.Getenv("DASH_TOKEN"), "system token") + interval := flag.Int("interval", envInt("DASH_INTERVAL", 10), "push interval seconds") + once := flag.Bool("once", false, "collect and print one sample, no push") + flag.Parse() + + c := &collector{} + c.host, _ = os.Hostname() + c.os = runtime.GOOS + c.arch = runtime.GOARCH + c.cpuModel, c.cores = cpuInfo() + c.prevCPU = readCPUTicks() + c.prevNet = readNetBytes() + c.prevAt = time.Now() + + if *once { + // Rates need a delta window — idle briefly so cpu/net are real. + time.Sleep(500 * time.Millisecond) + s := c.sample(*interval) + out, _ := json.MarshalIndent(s, "", " ") + fmt.Println(string(out)) + return + } + if *url == "" || *token == "" { + log.Fatal("dash-agent: -url and -token (or DASH_URL/DASH_TOKEN) required") + } + log.Printf("dash-agent %s: pushing to %s every %ds", version, *url, *interval) + for { + s := c.sample(*interval) + if err := push(*url, *token, s); err != nil { + log.Printf("dash-agent: push: %v", err) + } + time.Sleep(time.Duration(*interval) * time.Second) + } +} + +func envInt(k string, def int) int { + if v, err := strconv.Atoi(os.Getenv(k)); err == nil && v > 0 { + return v + } + return def +} + +// sample mirrors api.StatSample — duplicated here so the agent stays a +// standalone binary with zero imports from the server module. +type sample struct { + Host string `json:"host"` + OS string `json:"os"` + Arch string `json:"arch"` + CPUModel string `json:"cpuModel"` + Cores int `json:"cores"` + IntervalS int `json:"intervalS"` + UptimeS float64 `json:"uptimeS"` + CPU float64 `json:"cpu"` + MemTotal float64 `json:"memTotal"` + MemUsed float64 `json:"memUsed"` + SwapTotal float64 `json:"swapTotal"` + SwapUsed float64 `json:"swapUsed"` + DiskTotal float64 `json:"diskTotal"` + DiskUsed float64 `json:"diskUsed"` + NetRx float64 `json:"netRx"` + NetTx float64 `json:"netTx"` + Load1 float64 `json:"load1"` + Load5 float64 `json:"load5"` + Load15 float64 `json:"load15"` + Temps map[string]float64 `json:"temps,omitempty"` + Containers []containerStat `json:"containers,omitempty"` +} + +type containerStat struct { + Name string `json:"name"` + State string `json:"state"` +} + +type collector struct { + host, os, arch, cpuModel string + cores int + prevCPU cpuTicks + prevNet [2]uint64 // rx, tx bytes + prevAt time.Time +} + +type cpuTicks struct{ idle, total uint64 } + +func (c *collector) sample(interval int) sample { + s := sample{ + Host: c.host, OS: c.os, Arch: c.arch, + CPUModel: c.cpuModel, Cores: c.cores, IntervalS: interval, + } + now := time.Now() + dt := now.Sub(c.prevAt).Seconds() + if dt <= 0 { + dt = float64(interval) + } + + if cur := readCPUTicks(); cur.total > c.prevCPU.total { + busy := float64(cur.total-cur.idle) - float64(c.prevCPU.total-c.prevCPU.idle) + s.CPU = busy / float64(cur.total-c.prevCPU.total) * 100 + c.prevCPU = cur + } + s.MemTotal, s.MemUsed, s.SwapTotal, s.SwapUsed = readMem() + s.DiskTotal, s.DiskUsed = diskUsage("/") + if cur := readNetBytes(); cur[0] >= c.prevNet[0] { + s.NetRx = float64(cur[0]-c.prevNet[0]) / dt + s.NetTx = float64(cur[1]-c.prevNet[1]) / dt + c.prevNet = cur + } + c.prevAt = now + s.Load1, s.Load5, s.Load15 = readLoad() + s.UptimeS = readUptime() + s.Temps = readTemps() + s.Containers = readContainers() + return s +} + +func push(url, token string, s sample) error { + body, _ := json.Marshal(s) + req, _ := http.NewRequest(http.MethodPost, + strings.TrimRight(url, "/")+"/api/systems/ingest", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+token) + res, err := (&http.Client{Timeout: 10 * time.Second}).Do(req) + if err != nil { + return err + } + defer res.Body.Close() + io.Copy(io.Discard, res.Body) + if res.StatusCode >= 300 { + return fmt.Errorf("server returned %d", res.StatusCode) + } + return nil +} + +// ---------- /proc + /sys readers (Linux; all no-op-safe elsewhere) ---------- + +// readCPUTicks parses the aggregate cpu line: fields are user nice system +// idle iowait irq softirq steal — everything but idle+iowait counts busy. +func readCPUTicks() cpuTicks { + b, err := os.ReadFile("/proc/stat") + if err != nil { + return cpuTicks{} + } + var t cpuTicks + for _, line := range strings.Split(string(b), "\n") { + if !strings.HasPrefix(line, "cpu ") { + continue + } + f := strings.Fields(line)[1:] + var vals []uint64 + for _, v := range f { + n, _ := strconv.ParseUint(v, 10, 64) + vals = append(vals, n) + t.total += n + } + if len(vals) >= 5 { + t.idle = vals[3] + vals[4] + } + break + } + return t +} + +func cpuInfo() (model string, cores int) { + b, err := os.ReadFile("/proc/cpuinfo") + if err != nil { + return "", runtime.NumCPU() + } + for _, line := range strings.Split(string(b), "\n") { + if strings.HasPrefix(line, "processor") { + cores++ + } + if model == "" && strings.HasPrefix(line, "model name") { + model = strings.TrimSpace(strings.SplitN(line, ":", 2)[1]) + } + } + if cores == 0 { + cores = runtime.NumCPU() + } + return model, cores +} + +// readMem returns total/used bytes for RAM and swap from /proc/meminfo. +func readMem() (memTotal, memUsed, swapTotal, swapUsed float64) { + b, err := os.ReadFile("/proc/meminfo") + if err != nil { + return + } + kb := map[string]float64{} + for _, line := range strings.Split(string(b), "\n") { + f := strings.Fields(line) + if len(f) >= 2 { + kb[strings.TrimSuffix(f[0], ":")], _ = strconv.ParseFloat(f[1], 64) + } + } + memTotal = kb["MemTotal"] * 1024 + memUsed = (kb["MemTotal"] - kb["MemAvailable"]) * 1024 + swapTotal = kb["SwapTotal"] * 1024 + swapUsed = (kb["SwapTotal"] - kb["SwapFree"]) * 1024 + return +} + +func diskUsage(path string) (total, used float64) { + var st syscall.Statfs_t + if err := syscall.Statfs(path, &st); err != nil { + return 0, 0 + } + total = float64(st.Blocks) * float64(st.Bsize) + used = float64(st.Blocks-st.Bfree) * float64(st.Bsize) + return total, used +} + +// readNetBytes sums rx/tx across interfaces, skipping loopback. +func readNetBytes() [2]uint64 { + b, err := os.ReadFile("/proc/net/dev") + if err != nil { + return [2]uint64{} + } + var rx, tx uint64 + for _, line := range strings.Split(string(b), "\n") { + i := strings.Index(line, ":") + if i < 0 || strings.Contains(line[:i], "lo") { + continue + } + f := strings.Fields(line[i+1:]) + if len(f) >= 9 { + r, _ := strconv.ParseUint(f[0], 10, 64) + t, _ := strconv.ParseUint(f[8], 10, 64) + rx += r + tx += t + } + } + return [2]uint64{rx, tx} +} + +func readLoad() (l1, l5, l15 float64) { + b, err := os.ReadFile("/proc/loadavg") + if err != nil { + return + } + f := strings.Fields(string(b)) + if len(f) >= 3 { + l1, _ = strconv.ParseFloat(f[0], 64) + l5, _ = strconv.ParseFloat(f[1], 64) + l15, _ = strconv.ParseFloat(f[2], 64) + } + return +} + +func readUptime() float64 { + b, err := os.ReadFile("/proc/uptime") + if err != nil { + return 0 + } + f := strings.Fields(string(b)) + if len(f) > 0 { + v, _ := strconv.ParseFloat(f[0], 64) + return v + } + return 0 +} + +// readTemps walks /sys/class/hwmon for temp*_input sensors. +func readTemps() map[string]float64 { + out := map[string]float64{} + hwmons, _ := os.ReadDir("/sys/class/hwmon") + for _, hw := range hwmons { + dir := "/sys/class/hwmon/" + hw.Name() + chip, _ := os.ReadFile(dir + "/name") + entries, _ := os.ReadDir(dir) + for _, e := range entries { + name := e.Name() + if !strings.HasPrefix(name, "temp") || !strings.HasSuffix(name, "_input") { + continue + } + b, err := os.ReadFile(dir + "/" + name) + if err != nil { + continue + } + millideg, err := strconv.ParseFloat(strings.TrimSpace(string(b)), 64) + if err != nil || millideg <= 0 || millideg > 150000 { + continue + } + label := strings.TrimSpace(string(chip)) + if lb, err := os.ReadFile(dir + "/" + strings.TrimSuffix(name, "_input") + "_label"); err == nil { + if l := strings.TrimSpace(string(lb)); l != "" { + label = l + } + } + if _, dup := out[label]; !dup { + out[label] = millideg / 1000 + } + } + } + return out +} + +// readContainers lists Docker containers via the unix socket when present. +func readContainers() []containerStat { + if _, err := os.Stat("/var/run/docker.sock"); err != nil { + return nil + } + client := &http.Client{ + Timeout: 3 * time.Second, + Transport: &http.Transport{ + DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { + return (&net.Dialer{}).DialContext(ctx, "unix", "/var/run/docker.sock") + }, + }, + } + res, err := client.Get("http://d/containers/json?all=1") + if err != nil { + return nil + } + defer res.Body.Close() + var raw []struct { + Names []string `json:"Names"` + State string `json:"State"` + } + if json.NewDecoder(res.Body).Decode(&raw) != nil { + return nil + } + out := make([]containerStat, 0, len(raw)) + for _, r := range raw { + name := "" + if len(r.Names) > 0 { + name = strings.TrimPrefix(r.Names[0], "/") + } + out = append(out, containerStat{Name: name, State: r.State}) + } + return out +} diff --git a/apps/backend/cmd/dash/main.go b/apps/backend/cmd/dash/main.go index dd82d9a..ac051c7 100644 --- a/apps/backend/cmd/dash/main.go +++ b/apps/backend/cmd/dash/main.go @@ -19,6 +19,9 @@ import ( "github.com/tdvorak/dash/internal/web" ) +// version is stamped by goreleaser via -X main.version. +var version = "dev" + func main() { dev := os.Getenv("DASH_DEV") == "1" @@ -42,8 +45,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,11 +57,8 @@ 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)) + logger.Info("listening", zap.String("addr", addr), zap.String("data", dataDir), zap.String("version", version)) if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { logger.Fatal("serve", zap.Error(err)) } diff --git a/apps/backend/go.mod b/apps/backend/go.mod index 8c8078a..8c2a3ab 100644 --- a/apps/backend/go.mod +++ b/apps/backend/go.mod @@ -4,8 +4,10 @@ go 1.27 require ( github.com/gin-gonic/gin v1.12.0 + github.com/goccy/go-yaml v1.19.2 github.com/pressly/goose/v3 v3.28.0 go.uber.org/zap v1.28.0 + golang.org/x/net v0.58.0 modernc.org/sqlite v1.59.0 ) @@ -21,7 +23,6 @@ require ( github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.30.1 // indirect github.com/goccy/go-json v0.10.5 // indirect - github.com/goccy/go-yaml v1.19.2 // indirect github.com/google/uuid v1.6.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect @@ -42,7 +43,6 @@ require ( go.uber.org/multierr v1.11.0 // indirect golang.org/x/arch v0.22.0 // indirect golang.org/x/crypto v0.55.0 // indirect - golang.org/x/net v0.58.0 // indirect golang.org/x/sync v0.22.0 // indirect golang.org/x/sys v0.47.0 // indirect golang.org/x/text v0.41.0 // indirect diff --git a/apps/backend/internal/api/api.go b/apps/backend/internal/api/api.go index 9fcf991..dc9d797 100644 --- a/apps/backend/internal/api/api.go +++ b/apps/backend/internal/api/api.go @@ -2,6 +2,7 @@ package api import ( + "context" "crypto/rand" "database/sql" "encoding/hex" @@ -12,28 +13,51 @@ import ( "time" "github.com/gin-gonic/gin" + "github.com/tdvorak/dash/internal/domain" + "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 + // lookup performs domain refreshes; swappable so tests don't hit the + // real WHOIS/RDAP/DNS path. + lookup func(ctx context.Context, name string) *domain.Result } -// NewRouter builds the HTTP handler: zap access log, recovery, /api routes. -func NewRouter(logger *zap.Logger, db *sql.DB, iconsDir string) *gin.Engine { +func newServer(logger *zap.Logger, db *sql.DB, iconsDir string) *Server { s := &Server{ db: db, iconsDir: iconsDir, + log: logger, status: newStatusCache(60 * time.Second), widgets: newWidgetCache(30 * time.Second), } + s.lookup = domain.NewLookup().Run + widget.Register(&monitorWidget{db: db}) + widget.Register(&domainWidget{db: db}) + widget.Register(&systemWidget{db: db}) + return s +} +// NewRouter builds the HTTP handler: zap access log, recovery, /api routes. +// 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 := newServer(logger, db, iconsDir) + if ctx != nil { + go s.runScheduler(ctx.Done()) + } + return s.routes() +} + +func (s *Server) routes() *gin.Engine { r := gin.New() - r.Use(accessLog(logger), gin.Recovery()) + r.Use(accessLog(s.log), gin.Recovery()) v1 := r.Group("/api") v1.GET("/healthz", s.healthz) @@ -51,6 +75,52 @@ 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("/domains", s.listDomainsH) + v1.POST("/domains", s.createDomain) + v1.GET("/domains/:id", s.getDomain) + v1.PATCH("/domains/:id", s.patchDomain) + v1.DELETE("/domains/:id", s.deleteDomain) + v1.POST("/domains/:id/refresh", s.refreshDomainH) + v1.GET("/domains/:id/checks", s.domainChecks) + v1.POST("/notify/test", s.notifyTest) + + v1.GET("/systems", s.listSystemsH) + v1.POST("/systems", s.createSystem) + v1.GET("/systems/:id", s.getSystem) + v1.PATCH("/systems/:id", s.patchSystem) + v1.DELETE("/systems/:id", s.deleteSystem) + v1.GET("/systems/:id/stats", s.systemStats) + v1.POST("/systems/ingest", s.ingestSystem) + + v1.GET("/incidents", s.listIncidents) + v1.POST("/incidents", s.createIncident) + v1.PATCH("/incidents/:id", s.patchIncident) + v1.DELETE("/incidents/:id", s.deleteIncident) + v1.POST("/incidents/:id/updates", s.addIncidentUpdate) + + v1.GET("/maintenance", s.listWindows) + v1.POST("/maintenance", s.createWindow) + v1.DELETE("/maintenance/:id", s.deleteWindow) + + v1.GET("/status-pages", s.listStatusPages) + v1.POST("/status-pages", s.createStatusPage) + v1.PATCH("/status-pages/:id", s.patchStatusPage) + v1.DELETE("/status-pages/:id", s.deleteStatusPage) + v1.GET("/status-pages/:slug/public", s.publicStatus) + + v1.GET("/badge/:kind/:file", s.badge) + v1.GET("/metrics", s.metrics) + v1.POST("/import/csv", s.importCSV) + v1.GET("/status", s.getStatus) v1.GET("/widgets/types", s.widgetTypes) v1.GET("/widgets/:id/data", s.widgetData) @@ -58,6 +128,7 @@ func NewRouter(logger *zap.Logger, db *sql.DB, iconsDir string) *gin.Engine { v1.PUT("/settings", s.putSettings) v1.GET("/export", s.exportBoard) v1.POST("/import", s.importBoard) + v1.POST("/import/external", s.importExternal) return r } diff --git a/apps/backend/internal/api/api_test.go b/apps/backend/internal/api/api_test.go index 12576f7..0e6539d 100644 --- a/apps/backend/internal/api/api_test.go +++ b/apps/backend/internal/api/api_test.go @@ -1,7 +1,9 @@ package api import ( + "context" "encoding/json" + "io" "net/http" "net/http/httptest" "path/filepath" @@ -11,6 +13,7 @@ import ( "github.com/gin-gonic/gin" "github.com/tdvorak/dash/internal/db" + "github.com/tdvorak/dash/internal/domain" "go.uber.org/zap" ) @@ -22,7 +25,22 @@ 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())) + s := newServer(zap.NewNop(), d, t.TempDir()) + // Stub the domain lookup — real RDAP/WHOIS/DNS would make tests slow and + // network-dependent. + s.lookup = func(_ context.Context, name string) *domain.Result { + exp := time.Now().Add(90 * 24 * time.Hour).UTC() + return &domain.Result{ + Name: name, + RegistrarName: "Test Registrar", + ExpiryDate: &exp, + IPv4: []string{"93.184.216.34"}, + NameServers: []string{"ns1.example.com"}, + Headers: map[string]string{}, + FaviconURL: "https://www.google.com/s2/favicons?domain=" + name, + } + } + return httptest.NewServer(s.routes()) } func do(t *testing.T, method, url, body string) (*http.Response, map[string]any) { @@ -178,6 +196,568 @@ 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 TestDomainLifecycle(t *testing.T) { + srv := testServer(t) + defer srv.Close() + + // Garbage name -> 400 before any lookup happens. + res, body := do(t, "POST", srv.URL+"/api/domains", `{"name":"not a domain!!"}`) + if res.StatusCode != http.StatusBadRequest { + t.Fatalf("expected 400, got %d: %v", res.StatusCode, body) + } + + // Valid name creates even when lookups fail (unresolvable TLD). + res, dv := do(t, "POST", srv.URL+"/api/domains", `{"name":"nonexistent.invalid","alertDaysBefore":14}`) + if res.StatusCode != http.StatusCreated { + t.Fatalf("create: %d %v", res.StatusCode, dv) + } + if dv["tld"] != "invalid" || dv["name"] != "nonexistent.invalid" { + t.Fatalf("bad domain row: %v", dv) + } + // Stubbed lookup populated registration data + computed countdown. + if dv["registrarName"] != "Test Registrar" || dv["daysUntilExpiry"] == nil { + t.Fatalf("lookup not applied: %v", dv) + } + id := dv["id"].(string) + + // Duplicate name -> 409. + res, _ = do(t, "POST", srv.URL+"/api/domains", `{"name":"nonexistent.invalid"}`) + if res.StatusCode != http.StatusConflict { + t.Fatalf("expected 409, got %d", res.StatusCode) + } + + // Patch alert threshold round-trips. + res, dv = do(t, "PATCH", srv.URL+"/api/domains/"+id, `{"alertDaysBefore":7,"autoRenew":true}`) + if res.StatusCode != http.StatusOK || dv["alertDaysBefore"] != float64(7) || dv["autoRenew"] != true { + t.Fatalf("patch: %v", dv) + } + + // Refresh endpoint answers (lookup result content is network-dependent). + res, dv = do(t, "POST", srv.URL+"/api/domains/"+id+"/refresh", "") + if res.StatusCode != http.StatusOK { + t.Fatalf("refresh: %d %v", res.StatusCode, dv) + } + + // Checks history endpoint responds with a list. + resp, err := http.Get(srv.URL + "/api/domains/" + id + "/checks") + if err != nil { + t.Fatalf("checks: %v", err) + } + var checks []map[string]any + _ = json.NewDecoder(resp.Body).Decode(&checks) + resp.Body.Close() + + res, _ = do(t, "DELETE", srv.URL+"/api/domains/"+id, "") + if res.StatusCode != http.StatusNoContent { + t.Fatalf("delete: %d", res.StatusCode) + } + res, _ = do(t, "GET", srv.URL+"/api/domains/"+id, "") + if res.StatusCode != http.StatusNotFound { + t.Fatalf("expected 404 after delete, got %d", res.StatusCode) + } +} + +func TestDomainWidget(t *testing.T) { + srv := testServer(t) + defer srv.Close() + + res, dv := do(t, "POST", srv.URL+"/api/domains", `{"name":"widget.invalid"}`) + if res.StatusCode != http.StatusCreated { + t.Fatalf("create: %d %v", res.StatusCode, dv) + } + _, a := do(t, "POST", srv.URL+"/api/sections", `{"name":"A"}`) + _, w := do(t, "POST", srv.URL+"/api/items", `{"sectionId":"`+a["id"].(string)+`","kind":"widget","name":"D","config":{"type":"domain","domainId":"`+dv["id"].(string)+`"}}`) + res, data := do(t, "GET", srv.URL+"/api/widgets/"+w["id"].(string)+"/data", "") + inner, _ := data["data"].(map[string]any) + if res.StatusCode != http.StatusOK || inner["name"] != "widget.invalid" { + t.Fatalf("domain widget: %d %v", res.StatusCode, data) + } +} + +func TestNotifyWebhook(t *testing.T) { + srv := testServer(t) + defer srv.Close() + + // Receiver captures the webhook payload. + got := make(chan map[string]any, 1) + hook := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + got <- body + w.WriteHeader(http.StatusOK) + })) + defer hook.Close() + + // Unconfigured -> 400. + res, _ := do(t, "POST", srv.URL+"/api/notify/test", "") + if res.StatusCode != http.StatusBadRequest { + t.Fatalf("expected 400 without webhook, got %d", res.StatusCode) + } + + // Configure + test. + if res, _ := do(t, "PUT", srv.URL+"/api/settings", `{"notify_webhook":"`+hook.URL+`"}`); res.StatusCode != http.StatusOK { + t.Fatalf("put settings: %d", res.StatusCode) + } + res, _ = do(t, "POST", srv.URL+"/api/notify/test", "") + if res.StatusCode != http.StatusOK { + t.Fatalf("notify test: %d", res.StatusCode) + } + select { + case body := <-got: + if body["event"] != "test" || body["text"] == "" { + t.Fatalf("bad payload: %v", body) + } + case <-time.After(3 * time.Second): + t.Fatal("webhook never received the test notification") + } + + // Monitor transition fires monitor.down through the same webhook. + res, mv := do(t, "POST", srv.URL+"/api/monitors", `{"name":"Flip","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) + // pending -> down (no fire), then up -> down needs an up first. + up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) })) + defer up.Close() + res, _ = do(t, "PATCH", srv.URL+"/api/monitors/"+id, `{"url":"`+up.URL+`"}`) + if res.StatusCode != http.StatusOK { + t.Fatalf("patch: %d", res.StatusCode) + } + do(t, "POST", srv.URL+"/api/monitors/"+id+"/check", "") // -> up + do(t, "PATCH", srv.URL+"/api/monitors/"+id, `{"url":"http://127.0.0.1:1","timeoutS":1}`) + do(t, "POST", srv.URL+"/api/monitors/"+id+"/check", "") // -> down, fires webhook + select { + case body := <-got: + if body["event"] != "monitor.down" { + t.Fatalf("expected monitor.down, got %v", body) + } + case <-time.After(3 * time.Second): + t.Fatal("webhook never received monitor.down") + } +} + +func TestSystemIngest(t *testing.T) { + srv := testServer(t) + defer srv.Close() + + // No token -> 401. + res, _ := do(t, "POST", srv.URL+"/api/systems/ingest", `{"cpu":1}`) + if res.StatusCode != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", res.StatusCode) + } + + res, sys := do(t, "POST", srv.URL+"/api/systems", `{"name":"nas"}`) + if res.StatusCode != http.StatusCreated { + t.Fatalf("create: %v", sys) + } + id, token := sys["id"].(string), sys["token"].(string) + if token == "" { + t.Fatal("no agent token issued") + } + + // Bad token -> 401. + req, _ := http.NewRequest("POST", srv.URL+"/api/systems/ingest", + strings.NewReader(`{"cpu":50}`)) + req.Header.Set("Authorization", "Bearer wrong") + if r, err := http.DefaultClient.Do(req); err != nil || r.StatusCode != http.StatusUnauthorized { + t.Fatalf("bad token: %v %v", r.StatusCode, err) + } + + // Real push -> system goes up, latest + stats stored. + push := func() (*http.Response, map[string]any) { + t.Helper() + req, _ := http.NewRequest("POST", srv.URL+"/api/systems/ingest", + strings.NewReader(`{"host":"nas.local","os":"linux","arch":"amd64", + "cpuModel":"Test CPU","cores":4,"cpu":42.5,"memTotal":8000,"memUsed":4000, + "diskTotal":100000,"diskUsed":50000,"netRx":100,"netTx":50,"load1":1.5}`)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+token) + res, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("push: %v", err) + } + defer res.Body.Close() + var out map[string]any + _ = json.NewDecoder(res.Body).Decode(&out) + return res, out + } + if res, out := push(); res.StatusCode != http.StatusOK { + t.Fatalf("ingest: %v", out) + } + + res, sys = do(t, "GET", srv.URL+"/api/systems/"+id, "") + if res.StatusCode != http.StatusOK || sys["status"] != "up" { + t.Fatalf("get: %v", sys) + } + if sys["host"] != "nas.local" || sys["cores"].(float64) != 4 { + t.Fatalf("host fields not stored: %v", sys) + } + latest := sys["latest"].(map[string]any) + if latest["cpu"].(float64) != 42.5 { + t.Fatalf("latest not stored: %v", latest) + } + + res, list := do(t, "GET", srv.URL+"/api/systems/"+id+"/stats?hours=1", "") + if res.StatusCode != http.StatusOK { + t.Fatalf("stats: %v", list) + } + // stats returns an array — decode separately. + req2, _ := http.NewRequest("GET", srv.URL+"/api/systems/"+id+"/stats?hours=1", nil) + r2, _ := http.DefaultClient.Do(req2) + var arr []map[string]any + _ = json.NewDecoder(r2.Body).Decode(&arr) + r2.Body.Close() + if len(arr) != 1 || arr[0]["cpu"].(float64) != 42.5 { + t.Fatalf("stats rows: %v", arr) + } + + // Widget fetcher serves the latest sample. + _, a := do(t, "POST", srv.URL+"/api/sections", `{"name":"A"}`) + _, w := do(t, "POST", srv.URL+"/api/items", `{"sectionId":"`+a["id"].(string)+`","kind":"widget","name":"S","config":{"type":"system","systemId":"`+id+`"}}`) + res, data := do(t, "GET", srv.URL+"/api/widgets/"+w["id"].(string)+"/data", "") + inner, _ := data["data"].(map[string]any) + if res.StatusCode != http.StatusOK || inner["name"] != "nas" || inner["cpu"].(float64) != 42.5 { + t.Fatalf("system widget: %d %v", res.StatusCode, data) + } + + // Rename + token rotation. + res, sys = do(t, "PATCH", srv.URL+"/api/systems/"+id, `{"name":"nas2","rotateToken":true}`) + if sys["name"] != "nas2" || sys["token"] == token { + t.Fatalf("patch: %v", sys) + } + + res, _ = do(t, "DELETE", srv.URL+"/api/systems/"+id, "") + if res.StatusCode != http.StatusNoContent { + t.Fatalf("delete: %d", res.StatusCode) + } +} + +func TestOpsLayer(t *testing.T) { + srv := testServer(t) + defer srv.Close() + + // Monitor that flips up->down should auto-open an incident, and + // recovery should auto-resolve it. + up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) })) + defer up.Close() + _, mv := do(t, "POST", srv.URL+"/api/monitors", + `{"name":"Flip","type":"http","url":"`+up.URL+`","timeoutS":1}`) + id := mv["id"].(string) + do(t, "POST", srv.URL+"/api/monitors/"+id+"/check", "") // up + do(t, "PATCH", srv.URL+"/api/monitors/"+id, `{"url":"http://127.0.0.1:1"}`) + do(t, "POST", srv.URL+"/api/monitors/"+id+"/check", "") // down -> incident + + res, _ := do(t, "GET", srv.URL+"/api/incidents?status=open", "") + if res.StatusCode != http.StatusOK { + t.Fatalf("incidents: %d", res.StatusCode) + } + req, _ := http.NewRequest("GET", srv.URL+"/api/incidents?status=open", nil) + r, _ := http.DefaultClient.Do(req) + var incs []map[string]any + _ = json.NewDecoder(r.Body).Decode(&incs) + r.Body.Close() + if len(incs) != 1 || incs[0]["monitorId"] != id { + t.Fatalf("auto incident: %v", incs) + } + incID := incs[0]["id"].(string) + + do(t, "PATCH", srv.URL+"/api/monitors/"+id, `{"url":"`+up.URL+`"}`) + do(t, "POST", srv.URL+"/api/monitors/"+id+"/check", "") // up -> resolved + res, inc := do(t, "GET", srv.URL+"/api/incidents?status=open", "") + req, _ = http.NewRequest("GET", srv.URL+"/api/incidents?status=open", nil) + r, _ = http.DefaultClient.Do(req) + incs = nil + _ = json.NewDecoder(r.Body).Decode(&incs) + r.Body.Close() + if len(incs) != 0 { + t.Fatalf("incident should auto-resolve, got %v", incs) + } + + // Manual incident lifecycle: create -> update -> resolve. + res, inc = do(t, "POST", srv.URL+"/api/incidents", + `{"title":"disk full","severity":"critical","message":"nas raid degraded"}`) + if res.StatusCode != http.StatusCreated || inc["status"] != "open" { + t.Fatalf("create incident: %v", inc) + } + incID = inc["id"].(string) + res, inc = do(t, "PATCH", srv.URL+"/api/incidents/"+incID, `{"status":"resolved","message":"swapped drive"}`) + if inc["status"] != "resolved" { + t.Fatalf("resolve: %v", inc) + } + ups, _ := inc["updates"].([]any) + if len(ups) < 2 { + t.Fatalf("updates timeline: %v", inc["updates"]) + } + + // Maintenance window suppresses a transition's side-effects. + _, mw := do(t, "POST", srv.URL+"/api/maintenance", + `{"title":"upgrade","startsAt":"`+time.Now().Add(-time.Hour).UTC().Format(time.RFC3339)+ + `","endsAt":"`+time.Now().Add(time.Hour).UTC().Format(time.RFC3339)+`"}`) + if mw["active"] != true { + t.Fatalf("window should be active: %v", mw) + } + do(t, "PATCH", srv.URL+"/api/monitors/"+id, `{"url":"http://127.0.0.1:1"}`) + do(t, "POST", srv.URL+"/api/monitors/"+id+"/check", "") // down under maintenance + req, _ = http.NewRequest("GET", srv.URL+"/api/incidents?status=open", nil) + r, _ = http.DefaultClient.Do(req) + incs = nil + _ = json.NewDecoder(r.Body).Decode(&incs) + r.Body.Close() + if len(incs) != 0 { + t.Fatalf("maintenance must suppress auto-incidents: %v", incs) + } + + // Status page + public view shows maintenance state. + res, sp := do(t, "POST", srv.URL+"/api/status-pages", `{"title":"Ops","slug":"ops"}`) + if res.StatusCode != http.StatusCreated || sp["slug"] != "ops" { + t.Fatalf("status page: %v", sp) + } + res, pub := do(t, "GET", srv.URL+"/api/status-pages/ops/public", "") + if res.StatusCode != http.StatusOK || pub["overall"] != "maintenance" { + t.Fatalf("public status: %v", pub) + } + mons, _ := pub["monitors"].([]any) + if len(mons) != 1 { + t.Fatalf("public monitors: %v", pub["monitors"]) + } + if m0, _ := mons[0].(map[string]any); m0["status"] != "maintenance" { + t.Fatalf("monitor should read maintenance: %v", m0) + } + + // Badge + metrics smoke. + res, _ = do(t, "GET", srv.URL+"/api/badge/monitor/"+id+".svg", "") + if res.StatusCode != http.StatusOK { + t.Fatalf("badge: %d", res.StatusCode) + } + res, _ = do(t, "GET", srv.URL+"/api/metrics", "") + if res.StatusCode != http.StatusOK { + t.Fatalf("metrics: %d", res.StatusCode) + } + req, _ = http.NewRequest("GET", srv.URL+"/api/metrics", nil) + r, _ = http.DefaultClient.Do(req) + body, _ := io.ReadAll(r.Body) + r.Body.Close() + if !strings.Contains(string(body), "dash_monitors_total") { + t.Fatalf("metrics body: %s", body) + } + + // CSV import. + req, _ = http.NewRequest("POST", srv.URL+"/api/import/csv?kind=monitors", + strings.NewReader("name,type,url\nA,http,https://a.example\nB,tcp,https://b.example:9\n")) + req.Header.Set("Content-Type", "text/csv") + r, _ = http.DefaultClient.Do(req) + var out map[string]any + _ = json.NewDecoder(r.Body).Decode(&out) + r.Body.Close() + if out["created"].(float64) != 2 { + t.Fatalf("csv import: %v", out) + } +} + func TestMidpoint(t *testing.T) { f := func(v float64) *float64 { return &v } if got := midpoint(nil, nil); got != 1024 { diff --git a/apps/backend/internal/api/badge.go b/apps/backend/internal/api/badge.go new file mode 100644 index 0000000..9120d77 --- /dev/null +++ b/apps/backend/internal/api/badge.go @@ -0,0 +1,92 @@ +package api + +import ( + "fmt" + "net/http" + "strconv" + "strings" + + "github.com/gin-gonic/gin" +) + +// Stateless SVG shields rendered from live rows: /api/badge//.svg +// kinds: monitor (status+uptime), domain (days to expiry), system (status+cpu). + +var badgeColors = map[string]string{ + "up": "#3fb950", + "down": "#f85149", + "pending": "#8b949e", + "expired": "#f85149", + "expiring": "#d29922", + "ok": "#3fb950", + "unknown": "#8b949e", +} + +func badgeSVG(c *gin.Context, label, value, color string) { + // Fixed-width font assumption is deliberate — shields embed anywhere. + lw := 6*len(label) + 12 + vw := 6*len(value) + 12 + c.Header("Content-Type", "image/svg+xml") + c.Header("Cache-Control", "no-cache") + c.String(http.StatusOK, fmt.Sprintf(` + + + + + + + + +%s +%s +`, + lw+vw, label, value, lw+vw, lw, lw, vw, color, lw+vw, 6, label, lw+6, value)) +} + +func (s *Server) badge(c *gin.Context) { + kind := c.Param("kind") + // Route is /badge//; the conventional suffix is optional. + id := strings.TrimSuffix(c.Param("file"), ".svg") + + switch kind { + case "monitor": + var name, status string + err := s.db.QueryRow(`SELECT name, status FROM monitors WHERE id = ?`, id).Scan(&name, &status) + if err != nil { + badgeSVG(c, "monitor", "unknown", badgeColors["unknown"]) + return + } + uptime := s.monitorStats(id).Uptime24h + badgeSVG(c, name, fmt.Sprintf("%s · %.1f%%", status, uptime), badgeColors[status]) + case "domain": + var name string + var expiry *string + err := s.db.QueryRow(`SELECT name, expiry_date FROM domains WHERE id = ?`, id).Scan(&name, &expiry) + if err != nil { + badgeSVG(c, "domain", "unknown", badgeColors["unknown"]) + return + } + label, color := "unknown", badgeColors["unknown"] + if days := daysUntil(expiry); days != nil { + switch { + case *days < 0: + label, color = "expired", badgeColors["expired"] + case *days <= 30: + label, color = strconv.Itoa(*days)+"d left", badgeColors["expiring"] + default: + label, color = strconv.Itoa(*days)+"d left", badgeColors["ok"] + } + } + badgeSVG(c, name, label, color) + case "system": + var name, status string + err := s.db.QueryRow(`SELECT name, status FROM systems WHERE id = ?`, id).Scan(&name, &status) + if err != nil { + badgeSVG(c, "system", "unknown", badgeColors["unknown"]) + return + } + badgeSVG(c, name, status, badgeColors[status]) + default: + fail(c, http.StatusNotFound, "unknown badge kind") + } +} diff --git a/apps/backend/internal/api/bulk.go b/apps/backend/internal/api/bulk.go new file mode 100644 index 0000000..3bea87f --- /dev/null +++ b/apps/backend/internal/api/bulk.go @@ -0,0 +1,96 @@ +package api + +import ( + "encoding/csv" + "fmt" + "net/http" + "strconv" + "strings" + "time" + + "github.com/gin-gonic/gin" +) + +// POST /api/import/csv?kind=monitors|domains — header-driven CSV bulk import. +// Monitors: name,type,url,hostname,port,interval — name + (url|hostname) needed. +// Domains: name — one per row. + +func (s *Server) importCSV(c *gin.Context) { + kind := c.DefaultQuery("kind", "monitors") + if kind != "monitors" && kind != "domains" { + fail(c, http.StatusBadRequest, "kind must be monitors or domains") + return + } + rd := csv.NewReader(c.Request.Body) + rd.FieldsPerRecord = -1 // tolerate ragged rows; header drives positions + recs, err := rd.ReadAll() + if err != nil || len(recs) < 2 { + fail(c, http.StatusBadRequest, "csv with a header row and at least one entry required") + return + } + head := map[string]int{} + for i, h := range recs[0] { + head[strings.ToLower(strings.TrimSpace(h))] = i + } + cell := func(rec []string, col string) string { + if i, ok := head[col]; ok && i < len(rec) { + return strings.TrimSpace(rec[i]) + } + return "" + } + + var created, skipped int + var errs []string + for i, rec := range recs[1:] { + row := i + 2 // header is row 1 + name := cell(rec, "name") + if name == "" { + skipped++ + continue + } + if kind == "domains" { + if _, err := s.db.Exec(`INSERT INTO domains (id, name) VALUES (?, ?)`, + newID("d"), strings.ToLower(name)); err != nil { + if strings.Contains(err.Error(), "UNIQUE") { + skipped++ + continue + } + errs = append(errs, fmt.Sprintf("row %d: %v", row, err)) + continue + } + created++ + continue + } + + // monitors + typ := orDefault(cell(rec, "type"), "http") + if !validMonitorType(typ) { + errs = append(errs, fmt.Sprintf("row %d: bad type %q", row, typ)) + continue + } + url, host := cell(rec, "url"), cell(rec, "hostname") + if url == "" && host == "" { + errs = append(errs, fmt.Sprintf("row %d: url or hostname required", row)) + continue + } + port, _ := strconv.Atoi(cell(rec, "port")) + interval, _ := strconv.Atoi(cell(rec, "interval")) + if interval <= 0 { + interval = 60 + } + token := "" + if typ == "push" { + token = newID("push") + } + if _, err := s.db.Exec(`INSERT INTO monitors + (id, name, type, url, hostname, port, interval_s, push_token, position) + VALUES (?,?,?,?,?,?,?,?,?)`, + newID("m"), name, typ, url, host, port, interval, token, + float64(time.Now().UnixNano())/1e9); err != nil { + errs = append(errs, fmt.Sprintf("row %d: %v", row, err)) + continue + } + created++ + } + c.JSON(http.StatusOK, gin.H{"created": created, "skipped": skipped, "errors": errs}) +} diff --git a/apps/backend/internal/api/domains.go b/apps/backend/internal/api/domains.go new file mode 100644 index 0000000..7fe17a0 --- /dev/null +++ b/apps/backend/internal/api/domains.go @@ -0,0 +1,517 @@ +package api + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "net/http" + "strconv" + "time" + + "github.com/gin-gonic/gin" + "github.com/tdvorak/dash/internal/domain" + "github.com/tdvorak/dash/internal/widget" + "go.uber.org/zap" +) + +// ---------- model ---------- + +const domainCols = `id, name, tld, active, auto_renew, alert_days_before, tags, notes, + expiry_date, creation_date, updated_date, registrar_name, registrar_id, + registrar_url, registry_domain_id, dnssec, statuses, privacy_enabled, + transfer_lock, registrant_name, registrant_org, registrant_country, + abuse_email, name_servers, mx_records, txt_records, cname, ipv4, ipv6, + ssl_issuer, ssl_valid_from, ssl_valid_to, ssl_subject, ssl_fingerprint, + ssl_key_size, ssl_sig_algo, ssl_alt_names, host_country, host_country_code, + host_region, host_city, host_isp, host_org, host_as, host_lat, host_lon, + dns_provider, email_provider, hosting_provider, ca_provider, headers, + favicon_url, lookup_error, interval_h, last_checked, position, created_at` + +// domainRow mirrors the domains table; JSON text columns decode on scan. +type domainRow struct { + ID string `json:"id"` + Name string `json:"name"` + TLD string `json:"tld"` + Active bool `json:"active"` + AutoRenew bool `json:"autoRenew"` + AlertDaysBefore int `json:"alertDaysBefore"` + Tags []string `json:"tags"` + Notes string `json:"notes"` + ExpiryDate *string `json:"expiryDate"` + CreationDate *string `json:"creationDate"` + UpdatedDate *string `json:"updatedDate"` + RegistrarName string `json:"registrarName"` + RegistrarID string `json:"registrarId"` + RegistrarURL string `json:"registrarUrl"` + RegistryDomainID string `json:"registryDomainId"` + DNSSEC string `json:"dnssec"` + Statuses []string `json:"statuses"` + PrivacyEnabled bool `json:"privacyEnabled"` + TransferLock bool `json:"transferLock"` + RegistrantName string `json:"registrantName"` + RegistrantOrg string `json:"registrantOrg"` + RegistrantCountry string `json:"registrantCountry"` + AbuseEmail string `json:"abuseEmail"` + NameServers []string `json:"nameServers"` + MXRecords []string `json:"mxRecords"` + TXTRecords []string `json:"txtRecords"` + CNAME string `json:"cname"` + IPv4 []string `json:"ipv4"` + IPv6 []string `json:"ipv6"` + SSLIssuer string `json:"sslIssuer"` + SSLValidFrom *string `json:"sslValidFrom"` + SSLValidTo *string `json:"sslValidTo"` + SSLSubject string `json:"sslSubject"` + SSLFingerprint string `json:"sslFingerprint"` + SSLKeySize int `json:"sslKeySize"` + SSLSigAlgo string `json:"sslSigAlgo"` + SSLAltNames []string `json:"sslAltNames"` + HostCountry string `json:"hostCountry"` + HostCountryCode string `json:"hostCountryCode"` + HostRegion string `json:"hostRegion"` + HostCity string `json:"hostCity"` + HostISP string `json:"hostIsp"` + HostOrg string `json:"hostOrg"` + HostAS string `json:"hostAs"` + HostLat float64 `json:"hostLat"` + HostLon float64 `json:"hostLon"` + DNSProvider string `json:"dnsProvider"` + EmailProvider string `json:"emailProvider"` + HostingProvider string `json:"hostingProvider"` + CAProvider string `json:"caProvider"` + Headers map[string]string `json:"headers"` + FaviconURL string `json:"faviconUrl"` + LookupError string `json:"lookupError"` + IntervalH int `json:"intervalH"` + LastChecked *string `json:"lastChecked"` + Position float64 `json:"position"` + CreatedAt string `json:"createdAt"` +} + +func scanDomain(row interface{ Scan(...any) error }) (*domainRow, error) { + var d domainRow + var tags, statuses, ns, mx, txt, v4, v6, altNames, headers string + var active, autoRenew, privacy, lock int + err := row.Scan(&d.ID, &d.Name, &d.TLD, &active, &autoRenew, &d.AlertDaysBefore, + &tags, &d.Notes, &d.ExpiryDate, &d.CreationDate, &d.UpdatedDate, + &d.RegistrarName, &d.RegistrarID, &d.RegistrarURL, &d.RegistryDomainID, + &d.DNSSEC, &statuses, &privacy, &lock, &d.RegistrantName, &d.RegistrantOrg, + &d.RegistrantCountry, &d.AbuseEmail, &ns, &mx, &txt, &d.CNAME, &v4, &v6, + &d.SSLIssuer, &d.SSLValidFrom, &d.SSLValidTo, &d.SSLSubject, &d.SSLFingerprint, + &d.SSLKeySize, &d.SSLSigAlgo, &altNames, &d.HostCountry, &d.HostCountryCode, + &d.HostRegion, &d.HostCity, &d.HostISP, &d.HostOrg, &d.HostAS, &d.HostLat, + &d.HostLon, &d.DNSProvider, &d.EmailProvider, &d.HostingProvider, &d.CAProvider, + &headers, &d.FaviconURL, &d.LookupError, &d.IntervalH, &d.LastChecked, + &d.Position, &d.CreatedAt) + if err != nil { + return nil, err + } + d.Active = active != 0 + d.AutoRenew = autoRenew != 0 + d.PrivacyEnabled = privacy != 0 + d.TransferLock = lock != 0 + d.Tags = decodeList(tags) + d.Statuses = decodeList(statuses) + d.NameServers = decodeList(ns) + d.MXRecords = decodeList(mx) + d.TXTRecords = decodeList(txt) + d.IPv4 = decodeList(v4) + d.IPv6 = decodeList(v6) + d.SSLAltNames = decodeList(altNames) + d.Headers = map[string]string{} + _ = json.Unmarshal([]byte(headers), &d.Headers) + return &d, nil +} + +func decodeList(s string) []string { + var out []string + _ = json.Unmarshal([]byte(s), &out) + if out == nil { + out = []string{} + } + return out +} + +func (s *Server) loadDomain(id string) (*domainRow, error) { + return scanDomain(s.db.QueryRow(`SELECT `+domainCols+` FROM domains WHERE id = ?`, id)) +} + +// domainView adds computed countdowns the cards/detail page render directly. +type domainView struct { + *domainRow + DaysUntilExpiry *int `json:"daysUntilExpiry"` + SSLDaysUntilExpiry *int `json:"sslDaysUntilExpiry"` + Expiring bool `json:"expiring"` + SSLExpiring bool `json:"sslExpiring"` +} + +func daysUntil(iso *string) *int { + if iso == nil { + return nil + } + t, err := time.Parse(time.RFC3339, *iso) + if err != nil { + if t, err = time.Parse("2006-01-02", *iso); err != nil { + return nil + } + } + d := int(time.Until(t).Hours() / 24) + return &d +} + +func viewOf(d *domainRow) domainView { + v := domainView{domainRow: d} + v.DaysUntilExpiry = daysUntil(d.ExpiryDate) + v.SSLDaysUntilExpiry = daysUntil(d.SSLValidTo) + if v.DaysUntilExpiry != nil { + v.Expiring = *v.DaysUntilExpiry <= d.AlertDaysBefore + } + if v.SSLDaysUntilExpiry != nil { + v.SSLExpiring = *v.SSLDaysUntilExpiry <= d.AlertDaysBefore + } + return v +} + +// ---------- handlers ---------- + +func (s *Server) listDomainsH(c *gin.Context) { + rows, err := s.db.Query(`SELECT ` + domainCols + ` FROM domains ORDER BY position, name`) + if err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + defer rows.Close() + out := []domainView{} + for rows.Next() { + if d, err := scanDomain(rows); err == nil { + out = append(out, viewOf(d)) + } + } + c.JSON(http.StatusOK, out) +} + +type domainInput struct { + Name *string `json:"name"` + Active *bool `json:"active"` + AutoRenew *bool `json:"autoRenew"` + AlertDaysBefore *int `json:"alertDaysBefore"` + IntervalH *int `json:"intervalH"` + Tags []string `json:"tags"` + Notes *string `json:"notes"` +} + +func (s *Server) createDomain(c *gin.Context) { + var in domainInput + if err := c.ShouldBindJSON(&in); err != nil || in.Name == nil { + fail(c, http.StatusBadRequest, "name required") + return + } + name := domain.Clean(*in.Name) + if !domain.Valid(name) { + fail(c, http.StatusBadRequest, "invalid domain name") + return + } + id := newID("d") + tags, _ := json.Marshal(in.Tags) + if in.Tags == nil { + tags = []byte("[]") + } + _, err := s.db.Exec(`INSERT INTO domains + (id, name, tld, active, auto_renew, alert_days_before, tags, notes, interval_h, position) + VALUES (?,?,?,?,?,?,?,?,?,?)`, + id, name, tldFor(name), boolOr2(in.Active, true), boolOr(in.AutoRenew), + intOr2(in.AlertDaysBefore, 30), string(tags), strOr(in.Notes), + intOr2(in.IntervalH, 24), float64(time.Now().UnixNano())/1e9) + if err != nil { + fail(c, http.StatusConflict, "domain already tracked or insert failed") + return + } + // First lookup runs inline so the response carries real data. + s.refreshDomain(id) + d, err := s.loadDomain(id) + if err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + c.JSON(http.StatusCreated, viewOf(d)) +} + +func tldFor(name string) string { + for i := len(name) - 1; i >= 0; i-- { + if name[i] == '.' { + return name[i+1:] + } + } + return "" +} + +func (s *Server) getDomain(c *gin.Context) { + d, err := s.loadDomain(c.Param("id")) + if notFound(err) { + fail(c, http.StatusNotFound, "domain not found") + return + } + if err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + c.JSON(http.StatusOK, viewOf(d)) +} + +func (s *Server) patchDomain(c *gin.Context) { + id := c.Param("id") + if _, err := s.loadDomain(id); notFound(err) { + fail(c, http.StatusNotFound, "domain not found") + return + } + var in domainInput + if err := c.ShouldBindJSON(&in); err != nil { + fail(c, http.StatusBadRequest, "invalid body") + return + } + if in.Name != nil { + name := domain.Clean(*in.Name) + if !domain.Valid(name) { + fail(c, http.StatusBadRequest, "invalid domain name") + return + } + _, _ = s.db.Exec(`UPDATE domains SET name = ?, tld = ? WHERE id = ?`, name, tldFor(name), id) + } + if in.Notes != nil { + _, _ = s.db.Exec(`UPDATE domains SET notes = ? WHERE id = ?`, *in.Notes, id) + } + if in.AlertDaysBefore != nil { + _, _ = s.db.Exec(`UPDATE domains SET alert_days_before = ? WHERE id = ?`, *in.AlertDaysBefore, id) + } + if in.IntervalH != nil { + _, _ = s.db.Exec(`UPDATE domains SET interval_h = ? WHERE id = ?`, *in.IntervalH, id) + } + if in.Active != nil { + _, _ = s.db.Exec(`UPDATE domains SET active = ? WHERE id = ?`, *in.Active, id) + } + if in.AutoRenew != nil { + _, _ = s.db.Exec(`UPDATE domains SET auto_renew = ? WHERE id = ?`, *in.AutoRenew, id) + } + if in.Tags != nil { + tags, _ := json.Marshal(in.Tags) + _, _ = s.db.Exec(`UPDATE domains SET tags = ? WHERE id = ?`, string(tags), id) + } + d, err := s.loadDomain(id) + if err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + c.JSON(http.StatusOK, viewOf(d)) +} + +func (s *Server) deleteDomain(c *gin.Context) { + res, err := s.db.Exec(`DELETE FROM domains 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, "domain not found") + return + } + c.Status(http.StatusNoContent) +} + +// refreshDomain re-runs the full lookup and writes a snapshot row. Expiry +// threshold crossings fire notify events on the edge (only when a state +// newly becomes true, not on every refresh). +func (s *Server) refreshDomain(id string) { + row, err := s.loadDomain(id) + if err != nil { + return + } + prev := viewOf(row) + ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) + defer cancel() + r := s.lookup(ctx, row.Name) + s.applyResult(id, r) + + next, err := s.loadDomain(id) + if err != nil { + return + } + nv := viewOf(next) + switch { + case nv.DaysUntilExpiry != nil && *nv.DaysUntilExpiry < 0 && !(prev.DaysUntilExpiry != nil && *prev.DaysUntilExpiry < 0): + s.notify("domain.expired", next.Name, fmt.Sprintf("Domain %s has expired", next.Name)) + case nv.Expiring && !prev.Expiring: + s.notify("domain.expiring", next.Name, + fmt.Sprintf("Domain %s expires in %d days", next.Name, *nv.DaysUntilExpiry)) + } + if nv.SSLExpiring && !prev.SSLExpiring && nv.SSLDaysUntilExpiry != nil { + s.notify("domain.sslExpiring", next.Name, + fmt.Sprintf("TLS certificate for %s expires in %d days", next.Name, *nv.SSLDaysUntilExpiry)) + } +} + +func (s *Server) refreshDomainH(c *gin.Context) { + id := c.Param("id") + if _, err := s.loadDomain(id); notFound(err) { + fail(c, http.StatusNotFound, "domain not found") + return + } + s.refreshDomain(id) + d, err := s.loadDomain(id) + if err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + c.JSON(http.StatusOK, viewOf(d)) +} + +// applyResult persists a lookup Result and appends a domain_checks snapshot. +func (s *Server) applyResult(id string, r *domain.Result) { + enc := func(v []string) string { + b, _ := json.Marshal(v) + return string(b) + } + var expiry, created, updated, sslFrom, sslTo *string + fmtT := func(t *time.Time) *string { + if t == nil { + return nil + } + s := t.UTC().Format(time.RFC3339) + return &s + } + expiry, created, updated = fmtT(r.ExpiryDate), fmtT(r.CreationDate), fmtT(r.UpdatedDate) + sslFrom, sslTo = fmtT(r.SSLValidFrom), fmtT(r.SSLValidTo) + hdr, _ := json.Marshal(r.Headers) + + _, err := s.db.Exec(`UPDATE domains SET + expiry_date=?, creation_date=?, updated_date=?, registrar_name=?, + registrar_id=?, registrar_url=?, registry_domain_id=?, dnssec=?, + statuses=?, privacy_enabled=?, transfer_lock=?, registrant_name=?, + registrant_org=?, registrant_country=?, abuse_email=?, name_servers=?, + mx_records=?, txt_records=?, cname=?, ipv4=?, ipv6=?, ssl_issuer=?, + ssl_valid_from=?, ssl_valid_to=?, ssl_subject=?, ssl_fingerprint=?, + ssl_key_size=?, ssl_sig_algo=?, ssl_alt_names=?, host_country=?, + host_country_code=?, host_region=?, host_city=?, host_isp=?, host_org=?, + host_as=?, host_lat=?, host_lon=?, dns_provider=?, email_provider=?, + hosting_provider=?, ca_provider=?, headers=?, favicon_url=?, + lookup_error=?, last_checked=strftime('%Y-%m-%dT%H:%M:%fZ','now') + WHERE id=?`, + expiry, created, updated, r.RegistrarName, r.RegistrarID, r.RegistrarURL, + r.RegistryDomainID, r.DNSSEC, enc(r.Statuses), r.PrivacyEnabled, + r.TransferLock, r.RegistrantName, r.RegistrantOrg, r.RegistrantCountry, + r.AbuseEmail, enc(r.NameServers), enc(r.MXRecords), enc(r.TXTRecords), + r.CNAME, enc(r.IPv4), enc(r.IPv6), r.SSLIssuer, sslFrom, sslTo, + r.SSLSubject, r.SSLFingerprint, r.SSLKeySize, r.SSLSigAlgo, + enc(r.SSLAltNames), r.HostCountry, r.HostCountryCode, r.HostRegion, + r.HostCity, r.HostISP, r.HostOrg, r.HostAS, r.HostLat, r.HostLon, + r.DNSProvider, r.EmailProvider, r.HostingProvider, r.CAProvider, + string(hdr), r.FaviconURL, truncate(r.Error, 300), id) + if err != nil { + s.log.Error("domain apply", zap.Error(err)) + return + } + _, _ = s.db.Exec(`INSERT INTO domain_checks (domain_id, expiry_date, ssl_valid_to, ipv4, name_servers) + VALUES (?,?,?,?,?)`, id, expiry, sslTo, enc(r.IPv4), enc(r.NameServers)) +} + +// domainChecks returns the snapshot history — powers change timelines later. +func (s *Server) domainChecks(c *gin.Context) { + limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50")) + if limit <= 0 || limit > 500 { + limit = 50 + } + rows, err := s.db.Query(`SELECT id, expiry_date, ssl_valid_to, ipv4, name_servers, checked_at + FROM domain_checks WHERE domain_id = ? ORDER BY checked_at DESC LIMIT ?`, + c.Param("id"), limit) + if err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + defer rows.Close() + type chk struct { + ID int `json:"id"` + ExpiryDate *string `json:"expiryDate"` + SSLValidTo *string `json:"sslValidTo"` + IPv4 []string `json:"ipv4"` + NameServers []string `json:"nameServers"` + CheckedAt string `json:"checkedAt"` + } + out := []chk{} + for rows.Next() { + var k chk + var v4, ns string + if err := rows.Scan(&k.ID, &k.ExpiryDate, &k.SSLValidTo, &v4, &ns, &k.CheckedAt); err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + k.IPv4, k.NameServers = decodeList(v4), decodeList(ns) + out = append(out, k) + } + c.JSON(http.StatusOK, out) +} + +// ---------- scheduler ---------- + +// sweepDomains refreshes domains whose interval_h has elapsed. Runs inside +// the monitor tick — the SQL gate keeps it near-free when nothing is due. +func (s *Server) sweepDomains() { + rows, err := s.db.Query(`SELECT ` + domainCols + ` FROM domains + WHERE active = 1 + AND (last_checked IS NULL OR datetime(last_checked, '+' || interval_h || ' hours') <= datetime('now'))`) + if err != nil { + s.log.Error("domain sweep", zap.Error(err)) + return + } + var due []domainRow + for rows.Next() { + if d, err := scanDomain(rows); err == nil { + due = append(due, *d) + } + } + rows.Close() + for i := range due { + checkSem <- struct{}{} + go func(id string) { + defer func() { <-checkSem }() + s.refreshDomain(id) + }(due[i].ID) + } +} + +// ---------- widget fetcher ---------- + +// domainWidget serves board tiles bound to a domain via config.domainId. +type domainWidget struct{ db *sql.DB } + +func (w *domainWidget) Meta() widget.Type { + return widget.Type{ + Type: "domain", Name: "Domain", + Description: "Expiry countdown for a tracked domain", + Fields: []widget.Field{ + {Key: "domainId", Label: "Domain", Required: true}, + }, + } +} + +func (w *domainWidget) Fetch(_ context.Context, cfg json.RawMessage) (any, error) { + var c struct { + DomainID string `json:"domainId"` + } + if err := json.Unmarshal(cfg, &c); err != nil || c.DomainID == "" { + return nil, errors.New("config.domainId required") + } + d, err := scanDomain(w.db.QueryRow(`SELECT `+domainCols+` FROM domains WHERE id = ?`, c.DomainID)) + if err != nil { + return nil, errors.New("domain not found") + } + v := viewOf(d) + return map[string]any{ + "name": d.Name, + "faviconUrl": d.FaviconURL, + "daysUntilExpiry": v.DaysUntilExpiry, + "sslDaysUntilExpiry": v.SSLDaysUntilExpiry, + "expiring": v.Expiring, + "registrar": d.RegistrarName, + }, nil +} diff --git a/apps/backend/internal/api/importers.go b/apps/backend/internal/api/importers.go new file mode 100644 index 0000000..4fb3c3c --- /dev/null +++ b/apps/backend/internal/api/importers.go @@ -0,0 +1,322 @@ +package api + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + "github.com/gin-gonic/gin" + "github.com/goccy/go-yaml" +) + +// diBase mirrors the frontend's dashboard-icons CDN root (ServiceDialog). +const diBase = "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png" + +// importExternal ingests a foreign dashboard config — Homepage services.yml, +// Homarr JSON export, or Dashy conf.yml — and replaces the board with it. +// Format is sniffed from the body; settings are left untouched. +func (s *Server) importExternal(c *gin.Context) { + body, err := io.ReadAll(io.LimitReader(c.Request.Body, 8<<20)) + if err != nil || len(body) == 0 { + fail(c, http.StatusBadRequest, "empty or unreadable body") + return + } + sections, err := parseExternal(body) + if err != nil { + fail(c, http.StatusBadRequest, err.Error()) + return + } + if err := s.replaceBoard(sections, nil); err != nil { + failImport(c, err) + return + } + board, err := s.board() + if err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + c.JSON(http.StatusOK, board) +} + +// parseExternal sniffs the config format: JSON decodes as Homarr; a YAML +// sequence is Homepage's grouped services list; a YAML mapping carrying +// `sections` is a Dashy conf.yml. +func parseExternal(body []byte) ([]Section, error) { + var j any + if json.Unmarshal(body, &j) == nil { + return parseHomarr(j) + } + var y any + if err := yaml.Unmarshal(body, &y); err != nil { + return nil, errors.New("unreadable file — expected JSON or YAML") + } + switch root := y.(type) { + case []any: + return parseHomepage(root) + case map[string]any: + if _, ok := root["sections"]; ok { + return parseDashy(body) + } + } + return nil, errors.New("unrecognized config — expected a Homepage services.yml, Homarr JSON export, or Dashy conf.yml") +} + +// ---------- Homepage ---------- + +// parseHomepage walks a services.yml: a sequence of single-key maps +// (group name -> sequence of single-key service maps). Bare single-key +// entries at the top level land in an "Imported" section. +func parseHomepage(groups []any) ([]Section, error) { + var secs []Section + loose := Section{Name: "Imported"} + for _, g := range groups { + gm, ok := g.(map[string]any) + if !ok { + continue + } + for name, v := range gm { + switch val := v.(type) { + case []any: + sec := Section{Name: name, Items: homepageItems(val)} + if len(sec.Items) > 0 { + secs = append(secs, sec) + } + case map[string]any: + if it, ok := homepageService(name, val); ok { + loose.Items = append(loose.Items, it) + } + } + } + } + if len(loose.Items) > 0 { + secs = append([]Section{loose}, secs...) + } + if len(secs) == 0 { + return nil, errors.New("homepage: no importable services found") + } + return assignIDs(secs), nil +} + +func homepageItems(list []any) []Item { + var items []Item + for _, raw := range list { + m, ok := raw.(map[string]any) + if !ok { + continue + } + for name, fields := range m { + fm, _ := fields.(map[string]any) + if it, ok := homepageService(name, fm); ok { + items = append(items, it) + } + } + } + return items +} + +// homepageService maps one service entry. Entries without a reachable URL +// (pure widget config, bare ping targets) are skipped rather than failing +// the whole import. +func homepageService(name string, fields map[string]any) (Item, bool) { + u := strOf(fields, "href", "siteMonitor", "ping") + if strings.TrimSpace(name) == "" || !validURL(u) { + return Item{}, false + } + return importedItem(name, resolveIcon(strOf(fields, "icon"), u), u), true +} + +// ---------- Homarr ---------- + +// parseHomarr tolerates Homarr's shifting export shapes: `categories[].apps`, +// a flat `apps` array grouped by category id, or board `sections[].items`. +func parseHomarr(root any) ([]Section, error) { + m, ok := root.(map[string]any) + if !ok { + return nil, errors.New("homarr: expected a JSON object") + } + var secs []Section + seen := map[string]bool{} + catNames := map[string]string{} + + for _, c := range arrOf(m["categories"]) { + cm, _ := c.(map[string]any) + if name := strOf(cm, "name"); name != "" { + catNames[strOf(cm, "id")] = name + if s := sectionFromApps(name, arrOf(cm["apps"]), seen); len(s.Items) > 0 { + secs = append(secs, s) + } + } + } + + if apps := arrOf(m["apps"]); len(apps) > 0 { + var order []string + byCat := map[string][]any{} + for _, a := range apps { + am, _ := a.(map[string]any) + cat := strOf(am, "categoryId", "category") + if _, ok := byCat[cat]; !ok { + order = append(order, cat) + } + byCat[cat] = append(byCat[cat], a) + } + for _, cat := range order { + name := catNames[cat] + if name == "" { + name = "Imported" + } + if s := sectionFromApps(name, byCat[cat], seen); len(s.Items) > 0 { + secs = append(secs, s) + } + } + } + + for _, raw := range arrOf(m["sections"]) { + sm, _ := raw.(map[string]any) + if s := sectionFromApps(strOf(sm, "name"), arrOf(sm["items"]), seen); len(s.Items) > 0 { + secs = append(secs, s) + } + } + + if len(secs) == 0 { + return nil, errors.New("homarr: no apps found") + } + return assignIDs(secs), nil +} + +func sectionFromApps(name string, apps []any, seen map[string]bool) Section { + sec := Section{Name: orDefault(name, "Imported")} + for _, a := range apps { + am, _ := a.(map[string]any) + it, ok := homarrItem(am) + if !ok || seen[it.Name+"|"+it.URLs[0].URL] { + continue + } + seen[it.Name+"|"+it.URLs[0].URL] = true + sec.Items = append(sec.Items, it) + } + return sec +} + +func homarrItem(m map[string]any) (Item, bool) { + name := strOf(m, "name", "title", "label") + u := strOf(m, "url", "href", "appUrl", "externalUrl", "internalUrl") + if name == "" || !validURL(u) { + return Item{}, false + } + return importedItem(name, resolveIcon(strOf(m, "icon", "iconUrl"), u), u), true +} + +// ---------- Dashy ---------- + +type dashyConf struct { + Sections []struct { + Name string `yaml:"name"` + Items []struct { + Title string `yaml:"title"` + URL string `yaml:"url"` + Icon string `yaml:"icon"` + } `yaml:"items"` + } `yaml:"sections"` +} + +func parseDashy(body []byte) ([]Section, error) { + var conf dashyConf + if err := yaml.Unmarshal(body, &conf); err != nil { + return nil, fmt.Errorf("dashy: malformed conf.yml") + } + var secs []Section + for _, ds := range conf.Sections { + sec := Section{Name: orDefault(ds.Name, "Imported")} + for _, di := range ds.Items { + if di.Title == "" || !validURL(di.URL) { + continue + } + sec.Items = append(sec.Items, importedItem(di.Title, resolveIcon(di.Icon, di.URL), di.URL)) + } + secs = append(secs, sec) + } + if len(secs) == 0 { + return nil, errors.New("dashy: no sections found") + } + return assignIDs(secs), nil +} + +// ---------- shared ---------- + +// assignIDs stamps fresh ids and 1024-spaced positions on an imported tree. +func assignIDs(secs []Section) []Section { + for si := range secs { + secs[si].ID = newID("s") + secs[si].Position = float64(si+1) * 1024 + for ii := range secs[si].Items { + it := &secs[si].Items[ii] + it.ID = newID("i") + it.SectionID = secs[si].ID + it.Kind = orDefault(it.Kind, "service") + it.Position = float64(ii+1) * 1024 + for ui := range it.URLs { + it.URLs[ui].ID = newID("u") + it.URLs[ui].Position = float64(ui+1) * 1024 + } + } + } + return secs +} + +func importedItem(name, icon, u string) Item { + return Item{Kind: "service", Name: name, Icon: icon, URLs: []URL{{URL: u}}} +} + +// resolveIcon maps a foreign icon reference to a usable URL: passthrough for +// absolute URLs, favicon derivation for Dashy's `favicon`, the dashboard-icons +// CDN for prefixed or bare slugs, and "" (letter tile) for local file paths +// that only existed on the old dashboard. +func resolveIcon(icon, itemURL string) string { + icon = strings.TrimSpace(icon) + switch { + case icon == "": + return "" + case strings.HasPrefix(icon, "http://"), strings.HasPrefix(icon, "https://"): + return icon + case icon == "favicon", icon == "generalfavicon": + return faviconOf(itemURL) + case strings.ContainsAny(icon, "/.\\"): + return "" + } + lower := strings.ToLower(icon) + for _, p := range []string{"si-", "sh-", "hl-", "di-", "mdi-"} { + if rest, ok := strings.CutPrefix(lower, p); ok { + if rest == "" { + return "" + } + return diBase + "/" + rest + ".png" + } + } + return diBase + "/" + lower + ".png" +} + +func faviconOf(raw string) string { + u, err := url.Parse(raw) + if err != nil || u.Host == "" { + return "" + } + return u.Scheme + "://" + u.Host + "/favicon.ico" +} + +func arrOf(v any) []any { + a, _ := v.([]any) + return a +} + +func strOf(m map[string]any, keys ...string) string { + for _, k := range keys { + if s, ok := m[k].(string); ok && s != "" { + return s + } + } + return "" +} diff --git a/apps/backend/internal/api/metrics.go b/apps/backend/internal/api/metrics.go new file mode 100644 index 0000000..a228c29 --- /dev/null +++ b/apps/backend/internal/api/metrics.go @@ -0,0 +1,75 @@ +package api + +import ( + "fmt" + "net/http" + "strings" + + "github.com/gin-gonic/gin" +) + +// /api/metrics — Prometheus text exposition over the tables we already have. +// No registry, no deps: the counts are one-line SQL each. + +func promEscape(s string) string { + return strings.NewReplacer(`\`, `\\`, `"`, `\"`, "\n", `\n`).Replace(s) +} + +func (s *Server) metrics(c *gin.Context) { + var b strings.Builder + count := func(q string) int { + var n int + _ = s.db.QueryRow(q).Scan(&n) + return n + } + gauge := func(name, help string, v int) { + fmt.Fprintf(&b, "# HELP %s %s\n# TYPE %s gauge\n%s %d\n", name, help, name, name, v) + } + + gauge("dash_monitors_total", "Configured monitors", count(`SELECT COUNT(*) FROM monitors`)) + gauge("dash_monitors_up", "Monitors currently up", count(`SELECT COUNT(*) FROM monitors WHERE status='up'`)) + gauge("dash_monitors_down", "Monitors currently down", count(`SELECT COUNT(*) FROM monitors WHERE status='down'`)) + gauge("dash_domains_total", "Tracked domains", count(`SELECT COUNT(*) FROM domains`)) + gauge("dash_domains_expired", "Domains past expiry", count(`SELECT COUNT(*) FROM domains WHERE expiry_date IS NOT NULL AND datetime(expiry_date) < datetime('now')`)) + gauge("dash_domains_expiring_30d", "Domains expiring within 30 days", + count(`SELECT COUNT(*) FROM domains WHERE expiry_date IS NOT NULL + AND datetime(expiry_date) >= datetime('now') + AND datetime(expiry_date) <= datetime('now','+30 days')`)) + gauge("dash_systems_total", "Registered systems", count(`SELECT COUNT(*) FROM systems`)) + gauge("dash_systems_up", "Systems currently reporting", count(`SELECT COUNT(*) FROM systems WHERE status='up'`)) + gauge("dash_systems_down", "Systems gone silent", count(`SELECT COUNT(*) FROM systems WHERE status='down'`)) + gauge("dash_incidents_open", "Open or acknowledged incidents", count(`SELECT COUNT(*) FROM incidents WHERE status IN ('open','ack')`)) + gauge("dash_heartbeats_total", "Recorded monitor heartbeats", count(`SELECT COUNT(*) FROM heartbeats`)) + + // Per-monitor up/down gauge — label cardinality is user-controlled and small. + b.WriteString("# TYPE dash_monitor_up gauge\n") + if rows, err := s.db.Query(`SELECT name, status FROM monitors`); err == nil { + for rows.Next() { + var name, st string + if rows.Scan(&name, &st) == nil { + v := 0 + if st == "up" { + v = 1 + } + fmt.Fprintf(&b, "dash_monitor_up{name=%q} %d\n", promEscape(name), v) + } + } + rows.Close() + } + b.WriteString("# TYPE dash_system_up gauge\n") + if rows, err := s.db.Query(`SELECT name, status FROM systems`); err == nil { + for rows.Next() { + var name, st string + if rows.Scan(&name, &st) == nil { + v := 0 + if st == "up" { + v = 1 + } + fmt.Fprintf(&b, "dash_system_up{name=%q} %d\n", promEscape(name), v) + } + } + rows.Close() + } + + c.Data(http.StatusOK, "text/plain; version=0.0.4; charset=utf-8", []byte(b.String())) +} diff --git a/apps/backend/internal/api/monitors.go b/apps/backend/internal/api/monitors.go new file mode 100644 index 0000000..7e69426 --- /dev/null +++ b/apps/backend/internal/api/monitors.go @@ -0,0 +1,569 @@ +package api + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "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)) + } + // System samples are high-rate; a week of raw ticks is enough. + if _, err := s.db.Exec(`DELETE FROM system_stats WHERE ts < datetime('now','-7 days')`); err != nil { + s.log.Error("system stats 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)) + } + + s.sweepDomains() + s.sweepSystems() +} + +// 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 fires on real state flips (pending excluded upstream) +// — the notification + auto-incident seam for up/down events. An active +// maintenance window covering this monitor suppresses both. +func (s *Server) onMonitorTransition(m *monitor.Monitor, prev, next string) { + s.log.Info("monitor transition", + zap.String("monitor", m.Name), zap.String("from", prev), zap.String("to", next)) + if s.inMaintenance(m.ID) { + return + } + s.autoIncident(m.ID, m.Name, next) + target := m.URL + if target == "" { + target = m.Hostname + } + s.notify("monitor."+next, m.Name, + fmt.Sprintf("Monitor %q is %s (%s)", m.Name, next, target)) +} + +func truncate(s string, n int) string { + if len(s) > n { + return s[:n] + } + return s +} + +// ---------- widget fetcher ---------- + +// monitorWidget serves board tiles bound to a monitor via config.monitorId. +type monitorWidget struct{ db *sql.DB } + +func (w *monitorWidget) Meta() widget.Type { + return widget.Type{ + Type: "monitor", Name: "Monitor", + Description: "Live status of an uptime monitor", + Fields: []widget.Field{ + {Key: "monitorId", Label: "Monitor", Required: true}, + }, + } +} + +func (w *monitorWidget) Fetch(_ context.Context, cfg json.RawMessage) (any, error) { + var c struct { + MonitorID string `json:"monitorId"` + } + if err := json.Unmarshal(cfg, &c); err != nil || c.MonitorID == "" { + return nil, errors.New("config.monitorId required") + } + row := w.db.QueryRow(`SELECT `+monitorCols+` FROM monitors WHERE id = ?`, c.MonitorID) + m, err := scanMonitor(row) + if err != nil { + return nil, errors.New("monitor not found") + } + var up, down int + var avgPing float64 + _ = w.db.QueryRow(`SELECT COALESCE(SUM(status='up'),0), COALESCE(SUM(status='down'),0), + COALESCE(AVG(NULLIF(ping_ms,0)),0) + FROM heartbeats WHERE monitor_id = ? AND checked_at >= datetime('now','-24 hours')`, + m.ID).Scan(&up, &down, &avgPing) + uptime := 100.0 + if up+down > 0 { + uptime = float64(up) / float64(up+down) * 100 + } + return map[string]any{ + "name": m.Name, + "status": m.Status, + "uptime24": uptime, + "ping": int(avgPing), + "type": m.Type, + }, nil +} diff --git a/apps/backend/internal/api/notify.go b/apps/backend/internal/api/notify.go new file mode 100644 index 0000000..3e7d329 --- /dev/null +++ b/apps/backend/internal/api/notify.go @@ -0,0 +1,96 @@ +package api + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "time" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" +) + +// Notifications: one outbound webhook, configured via the notify_webhook +// settings key. Payload carries event/name/text/content/message so the same +// POST satisfies Slack ({text}), Discord ({content}), ntfy, and generic +// receivers. SMTP and per-service channels are deliberately out of scope — +// a webhook bridge (e.g. ntfy, Gotify, shoutrrr) covers them. + +type notifyPayload struct { + Event string `json:"event"` + Name string `json:"name"` + Text string `json:"text"` // Slack-style + Content string `json:"content"` // Discord-style + Message string `json:"message"` // ntfy/generic + At string `json:"at"` +} + +func (s *Server) webhookURL() string { + var raw string + if err := s.db.QueryRow(`SELECT value FROM settings WHERE key = 'notify_webhook'`).Scan(&raw); err != nil { + return "" + } + // Settings values are stored as JSON — unwrap the quoted string. + var u string + if err := json.Unmarshal([]byte(raw), &u); err != nil { + return "" + } + return u +} + +// notify fires the webhook asynchronously; delivery failures are logged, never fatal. +func (s *Server) notify(event, name, msg string) { + url := s.webhookURL() + if url == "" { + return + } + if u := parseHTTPURL(url); u == "" { + s.log.Warn("notify_webhook is not an http(s) URL", zap.String("event", event)) + return + } + p := notifyPayload{Event: event, Name: name, Text: msg, Content: msg, Message: msg, + At: time.Now().UTC().Format(time.RFC3339)} + go s.deliver(url, p) +} + +func (s *Server) deliver(url string, p notifyPayload) { + body, _ := json.Marshal(p) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Title", p.Text) // ntfy reads the title header + req.Header.Set("X-Dash-Event", p.Event) + resp, err := http.DefaultClient.Do(req) + if err != nil { + s.log.Warn("webhook delivery failed", zap.String("event", p.Event), zap.Error(err)) + return + } + resp.Body.Close() + if resp.StatusCode >= 400 { + s.log.Warn("webhook rejected", zap.String("event", p.Event), zap.Int("status", resp.StatusCode)) + } +} + +// parseHTTPURL enforces http/https on the configured webhook — same SSRF +// posture as monitor targets (user-configured, but no file:// or friends). +func parseHTTPURL(raw string) string { + if !validURL(raw) { + return "" + } + return raw +} + +// notifyTest lets the settings UI verify the configured webhook. +func (s *Server) notifyTest(c *gin.Context) { + if s.webhookURL() == "" { + fail(c, http.StatusBadRequest, "notify_webhook not configured") + return + } + s.notify("test", "dash", "Dash test notification") + c.JSON(http.StatusOK, gin.H{"status": "sent"}) +} diff --git a/apps/backend/internal/api/ops.go b/apps/backend/internal/api/ops.go new file mode 100644 index 0000000..73035ec --- /dev/null +++ b/apps/backend/internal/api/ops.go @@ -0,0 +1,717 @@ +package api + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + "time" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" +) + +// ---------- incidents ---------- + +type Incident struct { + ID string `json:"id"` + Title string `json:"title"` + Severity string `json:"severity"` + Status string `json:"status"` + MonitorID *string `json:"monitorId"` + Updates []IncidentUpdate `json:"updates,omitempty"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` +} + +type IncidentUpdate struct { + ID int64 `json:"id"` + Status string `json:"status"` + Message string `json:"message"` + At string `json:"at"` +} + +var incidentStatuses = map[string]bool{"open": true, "ack": true, "resolved": true, "closed": true} +var incidentSeverities = map[string]bool{"minor": true, "major": true, "critical": true} + +func scanIncident(row interface{ Scan(...any) error }) (*Incident, error) { + var in Incident + err := row.Scan(&in.ID, &in.Title, &in.Severity, &in.Status, &in.MonitorID, + &in.CreatedAt, &in.UpdatedAt) + return &in, err +} + +const incidentCols = `id, title, severity, status, monitor_id, created_at, updated_at` + +func (s *Server) loadIncident(id string) (*Incident, error) { + return scanIncident(s.db.QueryRow(`SELECT `+incidentCols+` FROM incidents WHERE id = ?`, id)) +} + +func (s *Server) incidentUpdates(id string) []IncidentUpdate { + rows, err := s.db.Query(`SELECT id, status, message, at FROM incident_updates + WHERE incident_id = ? ORDER BY at`, id) + if err != nil { + return nil + } + defer rows.Close() + out := []IncidentUpdate{} + for rows.Next() { + var u IncidentUpdate + if rows.Scan(&u.ID, &u.Status, &u.Message, &u.At) == nil { + out = append(out, u) + } + } + return out +} + +func (s *Server) listIncidents(c *gin.Context) { + openOnly := c.Query("status") == "open" + q := `SELECT ` + incidentCols + ` FROM incidents` + if openOnly { + q += ` WHERE status IN ('open','ack')` + } + q += ` ORDER BY created_at DESC LIMIT 200` + rows, err := s.db.Query(q) + if err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + defer rows.Close() + out := []Incident{} + for rows.Next() { + in, err := scanIncident(rows) + if err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + in.Updates = s.incidentUpdates(in.ID) + out = append(out, *in) + } + c.JSON(http.StatusOK, out) +} + +type incidentInput struct { + Title *string `json:"title"` + Severity *string `json:"severity"` + Status *string `json:"status"` + MonitorID *string `json:"monitorId"` + Message *string `json:"message"` // initial update body / transition note +} + +func (s *Server) createIncident(c *gin.Context) { + var in incidentInput + if err := c.ShouldBindJSON(&in); err != nil || in.Title == nil || *in.Title == "" { + fail(c, http.StatusBadRequest, "title required") + return + } + sev := strOr2(in.Severity, "major") + st := strOr2(in.Status, "open") + if !incidentSeverities[sev] || !incidentStatuses[st] { + fail(c, http.StatusBadRequest, "invalid severity or status") + return + } + id := newID("inc") + _, err := s.db.Exec(`INSERT INTO incidents (id, title, severity, status, monitor_id) + VALUES (?,?,?,?,?)`, id, *in.Title, sev, st, in.MonitorID) + if err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + if msg := strOr(in.Message); msg != "" { + _, _ = s.db.Exec(`INSERT INTO incident_updates (incident_id, status, message) VALUES (?,?,?)`, + id, st, msg) + } + inc, err := s.loadIncident(id) + if err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + inc.Updates = s.incidentUpdates(id) + c.JSON(http.StatusCreated, inc) +} + +// patchIncident moves status/severity/title; a status change also appends an +// incident_updates row so the timeline stays honest. +func (s *Server) patchIncident(c *gin.Context) { + id := c.Param("id") + cur, err := s.loadIncident(id) + if notFound(err) { + fail(c, http.StatusNotFound, "incident not found") + return + } + var in incidentInput + if err := c.ShouldBindJSON(&in); err != nil { + fail(c, http.StatusBadRequest, "invalid body") + return + } + if in.Severity != nil && !incidentSeverities[*in.Severity] { + fail(c, http.StatusBadRequest, "invalid severity") + return + } + if in.Status != nil && !incidentStatuses[*in.Status] { + fail(c, http.StatusBadRequest, "invalid status") + return + } + set := func(col string, p *string) { + if p != nil { + _, _ = s.db.Exec(`UPDATE incidents SET `+col+` = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`, *p, id) + } + } + set("title", in.Title) + set("severity", in.Severity) + set("status", in.Status) + if in.Status != nil && *in.Status != cur.Status { + _, _ = s.db.Exec(`INSERT INTO incident_updates (incident_id, status, message) VALUES (?,?,?)`, + id, *in.Status, strOr(in.Message)) + } + inc, err := s.loadIncident(id) + if err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + inc.Updates = s.incidentUpdates(id) + c.JSON(http.StatusOK, inc) +} + +func (s *Server) addIncidentUpdate(c *gin.Context) { + id := c.Param("id") + if _, err := s.loadIncident(id); notFound(err) { + fail(c, http.StatusNotFound, "incident not found") + return + } + var in incidentInput + if err := c.ShouldBindJSON(&in); err != nil { + fail(c, http.StatusBadRequest, "invalid body") + return + } + st := strOr2(in.Status, "") + if st != "" && !incidentStatuses[st] { + fail(c, http.StatusBadRequest, "invalid status") + return + } + if st == "" { + st = "note" + } + _, err := s.db.Exec(`INSERT INTO incident_updates (incident_id, status, message) VALUES (?,?,?)`, + id, st, strOr(in.Message)) + if err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + if in.Status != nil { + _, _ = s.db.Exec(`UPDATE incidents SET status = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`, *in.Status, id) + } + inc, _ := s.loadIncident(id) + inc.Updates = s.incidentUpdates(id) + c.JSON(http.StatusOK, inc) +} + +func (s *Server) deleteIncident(c *gin.Context) { + res, err := s.db.Exec(`DELETE FROM incidents WHERE id = ?`, c.Param("id")) + if err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + if n, _ := res.RowsAffected(); n == 0 { + fail(c, http.StatusNotFound, "incident not found") + return + } + c.Status(http.StatusNoContent) +} + +// ---------- maintenance windows ---------- + +type MaintWindow struct { + ID string `json:"id"` + Title string `json:"title"` + StartsAt string `json:"startsAt"` + EndsAt string `json:"endsAt"` + MonitorIDs []string `json:"monitorIds"` // empty = all monitors + Active bool `json:"active"` + CreatedAt string `json:"createdAt"` +} + +func scanWindow(row interface{ Scan(...any) error }) (*MaintWindow, error) { + var w MaintWindow + var ids string + err := row.Scan(&w.ID, &w.Title, &w.StartsAt, &w.EndsAt, &ids, &w.CreatedAt) + if err != nil { + return nil, err + } + _ = json.Unmarshal([]byte(ids), &w.MonitorIDs) + if w.MonitorIDs == nil { + w.MonitorIDs = []string{} + } + now := time.Now().UTC() + if st, err1 := time.Parse(time.RFC3339Nano, w.StartsAt); err1 == nil { + if en, err2 := time.Parse(time.RFC3339Nano, w.EndsAt); err2 == nil { + w.Active = !now.Before(st) && now.Before(en) + } + } + return &w, nil +} + +const windowCols = `id, title, starts_at, ends_at, monitor_ids, created_at` + +func (s *Server) listWindows(c *gin.Context) { + rows, err := s.db.Query(`SELECT ` + windowCols + ` FROM maintenance_windows ORDER BY starts_at DESC LIMIT 100`) + if err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + defer rows.Close() + out := []MaintWindow{} + for rows.Next() { + w, err := scanWindow(rows) + if err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + out = append(out, *w) + } + c.JSON(http.StatusOK, out) +} + +type windowInput struct { + Title *string `json:"title"` + StartsAt *string `json:"startsAt"` + EndsAt *string `json:"endsAt"` + MonitorIDs []string `json:"monitorIds"` +} + +func validWindow(in *windowInput) bool { + if in.Title == nil || in.StartsAt == nil || in.EndsAt == nil || *in.Title == "" { + return false + } + st, err1 := time.Parse(time.RFC3339Nano, *in.StartsAt) + en, err2 := time.Parse(time.RFC3339Nano, *in.EndsAt) + return err1 == nil && err2 == nil && en.After(st) +} + +func (s *Server) createWindow(c *gin.Context) { + var in windowInput + if err := c.ShouldBindJSON(&in); err != nil || !validWindow(&in) { + fail(c, http.StatusBadRequest, "title, startsAt, endsAt (RFC3339, end after start) required") + return + } + ids, _ := json.Marshal(in.MonitorIDs) + if in.MonitorIDs == nil { + ids = []byte("[]") + } + id := newID("mw") + if _, err := s.db.Exec(`INSERT INTO maintenance_windows (id, title, starts_at, ends_at, monitor_ids) + VALUES (?,?,?,?,?)`, id, *in.Title, *in.StartsAt, *in.EndsAt, string(ids)); err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + w, err := scanWindow(s.db.QueryRow(`SELECT `+windowCols+` FROM maintenance_windows WHERE id = ?`, id)) + if err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + c.JSON(http.StatusCreated, w) +} + +func (s *Server) deleteWindow(c *gin.Context) { + res, err := s.db.Exec(`DELETE FROM maintenance_windows WHERE id = ?`, c.Param("id")) + if err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + if n, _ := res.RowsAffected(); n == 0 { + fail(c, http.StatusNotFound, "window not found") + return + } + c.Status(http.StatusNoContent) +} + +// inMaintenance reports whether a monitor is covered by an active window — +// suppresses auto-incidents and transition alerts. +func (s *Server) inMaintenance(monitorID string) bool { + rows, err := s.db.Query(`SELECT monitor_ids FROM maintenance_windows + WHERE datetime(starts_at) <= datetime('now') AND datetime(ends_at) > datetime('now')`) + if err != nil { + return false + } + defer rows.Close() + for rows.Next() { + var raw string + if rows.Scan(&raw) != nil { + continue + } + var ids []string + _ = json.Unmarshal([]byte(raw), &ids) + if len(ids) == 0 { + return true + } + for _, id := range ids { + if id == monitorID { + return true + } + } + } + return false +} + +// ---------- status pages ---------- + +type StatusPage struct { + ID string `json:"id"` + Slug string `json:"slug"` + Title string `json:"title"` + Description string `json:"description"` + MonitorIDs []string `json:"monitorIds"` + SystemIDs []string `json:"systemIds"` + CreatedAt string `json:"createdAt"` +} + +func scanStatusPage(row interface{ Scan(...any) error }) (*StatusPage, error) { + var p StatusPage + var mids, sids string + err := row.Scan(&p.ID, &p.Slug, &p.Title, &p.Description, &mids, &sids, &p.CreatedAt) + if err != nil { + return nil, err + } + _ = json.Unmarshal([]byte(mids), &p.MonitorIDs) + _ = json.Unmarshal([]byte(sids), &p.SystemIDs) + if p.MonitorIDs == nil { + p.MonitorIDs = []string{} + } + if p.SystemIDs == nil { + p.SystemIDs = []string{} + } + return &p, nil +} + +const statusPageCols = `id, slug, title, description, monitor_ids, system_ids, created_at` + +func (s *Server) listStatusPages(c *gin.Context) { + rows, err := s.db.Query(`SELECT ` + statusPageCols + ` FROM status_pages ORDER BY title`) + if err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + defer rows.Close() + out := []StatusPage{} + for rows.Next() { + p, err := scanStatusPage(rows) + if err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + out = append(out, *p) + } + c.JSON(http.StatusOK, out) +} + +type statusPageInput struct { + Slug *string `json:"slug"` + Title *string `json:"title"` + Description *string `json:"description"` + MonitorIDs []string `json:"monitorIds"` + SystemIDs []string `json:"systemIds"` +} + +// validSlug keeps the public URL safe: lowercase letters, digits, dashes. +func validSlug(slug string) bool { + if slug == "" || len(slug) > 64 { + return false + } + for _, r := range slug { + if !(r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '-') { + return false + } + } + return true +} + +func (s *Server) createStatusPage(c *gin.Context) { + var in statusPageInput + if err := c.ShouldBindJSON(&in); err != nil || in.Title == nil || *in.Title == "" { + fail(c, http.StatusBadRequest, "title required") + return + } + slug := strOr(in.Slug) + if slug == "" { + slug = slugify(*in.Title) + } + if !validSlug(slug) { + fail(c, http.StatusBadRequest, "invalid slug") + return + } + mids, _ := json.Marshal(in.MonitorIDs) + sids, _ := json.Marshal(in.SystemIDs) + id := newID("sp") + _, err := s.db.Exec(`INSERT INTO status_pages (id, slug, title, description, monitor_ids, system_ids) + VALUES (?,?,?,?,?,?)`, id, slug, *in.Title, strOr(in.Description), string(mids), string(sids)) + if err != nil { + if strings.Contains(err.Error(), "UNIQUE") { + fail(c, http.StatusConflict, "slug already in use") + return + } + fail(c, http.StatusInternalServerError, err.Error()) + return + } + p, err := scanStatusPage(s.db.QueryRow(`SELECT `+statusPageCols+` FROM status_pages WHERE id = ?`, id)) + if err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + c.JSON(http.StatusCreated, p) +} + +func (s *Server) patchStatusPage(c *gin.Context) { + id := c.Param("id") + if _, err := scanStatusPage(s.db.QueryRow( + `SELECT `+statusPageCols+` FROM status_pages WHERE id = ?`, id)); notFound(err) { + fail(c, http.StatusNotFound, "status page not found") + return + } + var in statusPageInput + if err := c.ShouldBindJSON(&in); err != nil { + fail(c, http.StatusBadRequest, "invalid body") + return + } + if in.Slug != nil { + if !validSlug(*in.Slug) { + fail(c, http.StatusBadRequest, "invalid slug") + return + } + _, _ = s.db.Exec(`UPDATE status_pages SET slug = ? WHERE id = ?`, *in.Slug, id) + } + if in.Title != nil && *in.Title != "" { + _, _ = s.db.Exec(`UPDATE status_pages SET title = ? WHERE id = ?`, *in.Title, id) + } + if in.Description != nil { + _, _ = s.db.Exec(`UPDATE status_pages SET description = ? WHERE id = ?`, *in.Description, id) + } + if in.MonitorIDs != nil { + b, _ := json.Marshal(in.MonitorIDs) + _, _ = s.db.Exec(`UPDATE status_pages SET monitor_ids = ? WHERE id = ?`, string(b), id) + } + if in.SystemIDs != nil { + b, _ := json.Marshal(in.SystemIDs) + _, _ = s.db.Exec(`UPDATE status_pages SET system_ids = ? WHERE id = ?`, string(b), id) + } + p, err := scanStatusPage(s.db.QueryRow(`SELECT `+statusPageCols+` FROM status_pages WHERE id = ?`, id)) + if err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + c.JSON(http.StatusOK, p) +} + +func (s *Server) deleteStatusPage(c *gin.Context) { + res, err := s.db.Exec(`DELETE FROM status_pages WHERE id = ?`, c.Param("id")) + if err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + if n, _ := res.RowsAffected(); n == 0 { + fail(c, http.StatusNotFound, "status page not found") + return + } + c.Status(http.StatusNoContent) +} + +func slugify(s string) string { + var b strings.Builder + for _, r := range strings.ToLower(s) { + switch { + case r >= 'a' && r <= 'z' || r >= '0' && r <= '9': + b.WriteRune(r) + case r == ' ' || r == '-' || r == '_': + b.WriteRune('-') + } + } + return strings.Trim(b.String(), "-") +} + +// ---------- public status ---------- + +// publicStatus renders one status page's live view: monitors with uptime, +// systems with status, open incidents, active/upcoming maintenance. +func (s *Server) publicStatus(c *gin.Context) { + p, err := scanStatusPage(s.db.QueryRow( + `SELECT `+statusPageCols+` FROM status_pages WHERE slug = ?`, c.Param("slug"))) + if notFound(err) { + fail(c, http.StatusNotFound, "status page not found") + return + } + if err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + + pick := func(ids []string) (string, []any) { + if len(ids) == 0 { + return "", nil + } + marks := strings.TrimSuffix(strings.Repeat("?,", len(ids)), ",") + args := make([]any, len(ids)) + for i, id := range ids { + args[i] = id + } + return marks, args + } + + type monStatus struct { + ID string `json:"id"` + Name string `json:"name"` + Status string `json:"status"` // up|down|pending|maintenance + Uptime24h float64 `json:"uptime24h"` + } + mons := []monStatus{} + mq := `SELECT id, name, status FROM monitors WHERE active = 1` + marks, args := pick(p.MonitorIDs) + if marks != "" { + mq += ` AND id IN (` + marks + `)` + } + mq += ` ORDER BY name` + if rows, err := s.db.Query(mq, args...); err == nil { + for rows.Next() { + var m monStatus + if rows.Scan(&m.ID, &m.Name, &m.Status) == nil { + if s.inMaintenance(m.ID) { + m.Status = "maintenance" + } + m.Uptime24h = s.monitorStats(m.ID).Uptime24h + mons = append(mons, m) + } + } + rows.Close() + } + + type sysStatus struct { + ID string `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + } + systems := []sysStatus{} + sq := `SELECT id, name, status FROM systems` + marks, args = pick(p.SystemIDs) + if marks != "" { + sq += ` WHERE id IN (` + marks + `)` + } + sq += ` ORDER BY name` + if rows, err := s.db.Query(sq, args...); err == nil { + for rows.Next() { + var sm sysStatus + if rows.Scan(&sm.ID, &sm.Name, &sm.Status) == nil { + systems = append(systems, sm) + } + } + rows.Close() + } + + incidents := []Incident{} + if rows, err := s.db.Query(`SELECT ` + incidentCols + ` FROM incidents + WHERE status IN ('open','ack','resolved') + ORDER BY created_at DESC LIMIT 20`); err == nil { + for rows.Next() { + in, err := scanIncident(rows) + if err == nil { + in.Updates = s.incidentUpdates(in.ID) + incidents = append(incidents, *in) + } + } + rows.Close() + } + + maintenance := []MaintWindow{} + if rows, err := s.db.Query(`SELECT ` + windowCols + ` FROM maintenance_windows + WHERE datetime(ends_at) > datetime('now') ORDER BY starts_at LIMIT 10`); err == nil { + for rows.Next() { + if w, err := scanWindow(rows); err == nil { + maintenance = append(maintenance, *w) + } + } + rows.Close() + } + + // Overall state: down wins over maintenance wins over pending. + overall := "up" + for _, m := range mons { + switch m.Status { + case "down": + overall = "down" + case "maintenance": + if overall == "up" { + overall = "maintenance" + } + case "pending": + if overall == "up" { + overall = "pending" + } + } + } + for _, sm := range systems { + if sm.Status == "down" { + overall = "down" + } + } + + c.JSON(http.StatusOK, gin.H{ + "title": p.Title, + "slug": p.Slug, + "description": p.Description, + "overall": overall, + "monitors": mons, + "systems": systems, + "incidents": incidents, + "maintenance": maintenance, + }) +} + +// ---------- auto-incident hook ---------- + +// autoIncident opens an incident when a monitor goes down and resolves the +// matching open incident when it recovers. Suppressed during maintenance. +func (s *Server) autoIncident(monitorID, name, next string) { + if s.inMaintenance(monitorID) { + return + } + if next == "down" { + // Don't stack duplicates on a flap loop. + var open string + if err := s.db.QueryRow(`SELECT id FROM incidents + WHERE monitor_id = ? AND status IN ('open','ack')`, monitorID).Scan(&open); err == nil { + return + } + id := newID("inc") + title := fmt.Sprintf("Monitor %q is down", name) + if _, err := s.db.Exec(`INSERT INTO incidents (id, title, severity, status, monitor_id) + VALUES (?,?,'major','open',?)`, id, title, monitorID); err != nil { + s.log.Error("auto incident", zap.Error(err)) + return + } + _, _ = s.db.Exec(`INSERT INTO incident_updates (incident_id, status, message) + VALUES (?,'open','Opened automatically — monitor reported down.')`, id) + return + } + if next == "up" { + res, err := s.db.Exec(`UPDATE incidents SET status = 'resolved', + updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') + WHERE monitor_id = ? AND status IN ('open','ack')`, monitorID) + if err != nil { + return + } + if n, _ := res.RowsAffected(); n > 0 { + rows, _ := s.db.Query(`SELECT id FROM incidents WHERE monitor_id = ? AND status = 'resolved'`, monitorID) + var ids []string + if rows != nil { + for rows.Next() { + var id string + if rows.Scan(&id) == nil { + ids = append(ids, id) + } + } + rows.Close() + } + for _, id := range ids { + _, _ = s.db.Exec(`INSERT INTO incident_updates (incident_id, status, message) + VALUES (?,'resolved','Resolved automatically — monitor recovered.')`, id) + } + } + } +} diff --git a/apps/backend/internal/api/systems.go b/apps/backend/internal/api/systems.go new file mode 100644 index 0000000..a3d19a9 --- /dev/null +++ b/apps/backend/internal/api/systems.go @@ -0,0 +1,343 @@ +package api + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "net/http" + "strconv" + "strings" + + "github.com/gin-gonic/gin" + "github.com/tdvorak/dash/internal/widget" + "go.uber.org/zap" +) + +// ---------- model ---------- + +// StatSample is one agent payload. Numeric fields are optional-ish: the +// agent may omit what a platform cannot provide. +type StatSample struct { + Host string `json:"host"` + OS string `json:"os"` + Arch string `json:"arch"` + CPUModel string `json:"cpuModel"` + Cores int `json:"cores"` + IntervalS int `json:"intervalS"` + UptimeS float64 `json:"uptimeS"` + CPU float64 `json:"cpu"` // percent + MemTotal float64 `json:"memTotal"` // bytes + MemUsed float64 `json:"memUsed"` + SwapTotal float64 `json:"swapTotal"` + SwapUsed float64 `json:"swapUsed"` + DiskTotal float64 `json:"diskTotal"` // bytes, root fs + DiskUsed float64 `json:"diskUsed"` + NetRx float64 `json:"netRx"` // bytes/sec + NetTx float64 `json:"netTx"` + Load1 float64 `json:"load1"` + Load5 float64 `json:"load5"` + Load15 float64 `json:"load15"` + Temps map[string]float64 `json:"temps"` + Containers []ContainerStat `json:"containers"` +} + +// ContainerStat is one Docker container's liveness; per-container CPU/mem +// needs cgroup sampling — deferred (v1 ships name+state only). +type ContainerStat struct { + Name string `json:"name"` + State string `json:"state"` +} + +// System is one monitored host; `latest` is the newest StatSample. +type System struct { + ID string `json:"id"` + Name string `json:"name"` + Token string `json:"token"` + Host string `json:"host"` + OS string `json:"os"` + Arch string `json:"arch"` + CPUModel string `json:"cpuModel"` + Cores int `json:"cores"` + IntervalS int `json:"intervalS"` + Status string `json:"status"` // pending | up | down + LastSeenAt *string `json:"lastSeenAt"` + Latest *StatSample `json:"latest"` + CreatedAt string `json:"createdAt"` +} + +const systemCols = `id, name, token, host, os, arch, cpu_model, cores, + interval_s, status, last_seen_at, latest, created_at` + +func scanSystem(row interface{ Scan(...any) error }) (*System, error) { + var sys System + var latest string + err := row.Scan(&sys.ID, &sys.Name, &sys.Token, &sys.Host, &sys.OS, + &sys.Arch, &sys.CPUModel, &sys.Cores, &sys.IntervalS, &sys.Status, + &sys.LastSeenAt, &latest, &sys.CreatedAt) + if err != nil { + return nil, err + } + if latest != "" && latest != "{}" { + var s StatSample + if json.Unmarshal([]byte(latest), &s) == nil { + sys.Latest = &s + } + } + return &sys, nil +} + +func (s *Server) loadSystem(id string) (*System, error) { + return scanSystem(s.db.QueryRow(`SELECT `+systemCols+` FROM systems WHERE id = ?`, id)) +} + +func (s *Server) loadSystemByToken(token string) (*System, error) { + return scanSystem(s.db.QueryRow( + `SELECT `+systemCols+` FROM systems WHERE token = ?`, token)) +} + +// ---------- handlers ---------- + +func (s *Server) listSystemsH(c *gin.Context) { + rows, err := s.db.Query(`SELECT ` + systemCols + ` FROM systems ORDER BY name`) + if err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + defer rows.Close() + out := []System{} + for rows.Next() { + sys, err := scanSystem(rows) + if err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + out = append(out, *sys) + } + c.JSON(http.StatusOK, out) +} + +func (s *Server) createSystem(c *gin.Context) { + var in struct { + Name string `json:"name"` + } + if err := c.ShouldBindJSON(&in); err != nil || in.Name == "" { + fail(c, http.StatusBadRequest, "name required") + return + } + id := newID("sys") + if _, err := s.db.Exec(`INSERT INTO systems (id, name, token) VALUES (?,?,?)`, + id, in.Name, newID("agent")); err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + sys, err := s.loadSystem(id) + if err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + c.JSON(http.StatusCreated, sys) +} + +func (s *Server) getSystem(c *gin.Context) { + sys, err := s.loadSystem(c.Param("id")) + if notFound(err) { + fail(c, http.StatusNotFound, "system not found") + return + } + if err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + c.JSON(http.StatusOK, sys) +} + +func (s *Server) patchSystem(c *gin.Context) { + id := c.Param("id") + if _, err := s.loadSystem(id); notFound(err) { + fail(c, http.StatusNotFound, "system not found") + return + } + var in struct { + Name *string `json:"name"` + Rotate *bool `json:"rotateToken"` + } + if err := c.ShouldBindJSON(&in); err != nil { + fail(c, http.StatusBadRequest, "invalid body") + return + } + if in.Name != nil && *in.Name != "" { + _, _ = s.db.Exec(`UPDATE systems SET name = ? WHERE id = ?`, *in.Name, id) + } + if in.Rotate != nil && *in.Rotate { + _, _ = s.db.Exec(`UPDATE systems SET token = ? WHERE id = ?`, newID("agent"), id) + } + sys, err := s.loadSystem(id) + if err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + c.JSON(http.StatusOK, sys) +} + +func (s *Server) deleteSystem(c *gin.Context) { + res, err := s.db.Exec(`DELETE FROM systems 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, "system not found") + return + } + c.Status(http.StatusNoContent) +} + +// systemStats returns stored samples newest-last for charting. +func (s *Server) systemStats(c *gin.Context) { + if _, err := s.loadSystem(c.Param("id")); notFound(err) { + fail(c, http.StatusNotFound, "system not found") + return + } + hours, _ := strconv.Atoi(c.DefaultQuery("hours", "24")) + if hours <= 0 || hours > 24*7 { + hours = 24 + } + rows, err := s.db.Query(`SELECT ts, payload FROM system_stats + WHERE system_id = ? AND ts >= datetime('now', ?) ORDER BY ts`, + c.Param("id"), "-"+strconv.Itoa(hours)+" hours") + if err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + defer rows.Close() + type sample struct { + TS string `json:"ts"` + StatSample + } + out := []sample{} + for rows.Next() { + var ts, payload string + if err := rows.Scan(&ts, &payload); err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + var sm sample + sm.TS = ts + if json.Unmarshal([]byte(payload), &sm.StatSample) == nil { + out = append(out, sm) + } + } + c.JSON(http.StatusOK, out) +} + +// ingestSystem receives one agent push. Auth: Authorization: Bearer . +func (s *Server) ingestSystem(c *gin.Context) { + token := strings.TrimPrefix(c.GetHeader("Authorization"), "Bearer ") + if token == "" { + token = c.GetHeader("X-Dash-Token") + } + if token == "" { + fail(c, http.StatusUnauthorized, "token required") + return + } + sys, err := s.loadSystemByToken(token) + if err != nil { + fail(c, http.StatusUnauthorized, "unknown token") + return + } + var sample StatSample + if err := c.ShouldBindJSON(&sample); err != nil { + fail(c, http.StatusBadRequest, "invalid body") + return + } + if sample.IntervalS <= 0 { + sample.IntervalS = 10 + } + payload, _ := json.Marshal(&sample) + prev := sys.Status + _, err = s.db.Exec(`UPDATE systems SET + host = ?, os = ?, arch = ?, cpu_model = ?, cores = ?, interval_s = ?, + status = 'up', last_seen_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'), + latest = ? + WHERE id = ?`, + sample.Host, sample.OS, sample.Arch, sample.CPUModel, sample.Cores, + sample.IntervalS, string(payload), sys.ID) + if err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + if _, err := s.db.Exec(`INSERT INTO system_stats (system_id, payload) VALUES (?,?)`, + sys.ID, string(payload)); err != nil { + s.log.Error("system stats insert", zap.Error(err)) + } + if prev != "up" { + s.notify("system.up", sys.Name, + fmt.Sprintf("System %q is back online (%s)", sys.Name, sample.Host)) + } + c.JSON(http.StatusOK, gin.H{"status": "ok"}) +} + +// sweepSystems marks agents down when silent for > 3 intervals (min 30s). +func (s *Server) sweepSystems() { + rows, err := s.db.Query(`UPDATE systems SET status = 'down' + WHERE status = 'up' AND last_seen_at IS NOT NULL + AND datetime(last_seen_at, '+' || MAX(3 * interval_s, 30) || ' seconds') <= datetime('now') + RETURNING id, name, host`) + if err != nil { + s.log.Error("system sweep", zap.Error(err)) + return + } + defer rows.Close() + for rows.Next() { + var id, name, host string + if rows.Scan(&id, &name, &host) == nil { + s.notify("system.down", name, + fmt.Sprintf("System %q stopped reporting (%s)", name, host)) + } + } +} + +// ---------- widget fetcher ---------- + +// systemWidget serves board tiles bound to a system via config.systemId. +type systemWidget struct{ db *sql.DB } + +func (w *systemWidget) Meta() widget.Type { + return widget.Type{ + Type: "system", Name: "System", + Description: "Live CPU/memory/disk of a monitored host", + Fields: []widget.Field{ + {Key: "systemId", Label: "System", Required: true}, + }, + } +} + +func (w *systemWidget) Fetch(_ context.Context, cfg json.RawMessage) (any, error) { + var c struct { + SystemID string `json:"systemId"` + } + if err := json.Unmarshal(cfg, &c); err != nil || c.SystemID == "" { + return nil, errors.New("config.systemId required") + } + var name, status, latest string + var lastSeen *string + err := w.db.QueryRow(`SELECT name, status, last_seen_at, latest + FROM systems WHERE id = ?`, c.SystemID).Scan(&name, &status, &lastSeen, &latest) + if err != nil { + return nil, errors.New("system not found") + } + var sm StatSample + _ = json.Unmarshal([]byte(latest), &sm) + return map[string]any{ + "name": name, + "status": status, + "lastSeen": lastSeen, + "cpu": sm.CPU, + "memTotal": sm.MemTotal, + "memUsed": sm.MemUsed, + "diskTotal": sm.DiskTotal, + "diskUsed": sm.DiskUsed, + }, nil +} diff --git a/apps/backend/internal/api/transfer.go b/apps/backend/internal/api/transfer.go index 25da740..4002cfa 100644 --- a/apps/backend/internal/api/transfer.go +++ b/apps/backend/internal/api/transfer.go @@ -2,16 +2,70 @@ 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. +// v2 adds monitoring entities alongside — additive, so v1 files still import. type Export struct { Version int `json:"version"` Settings map[string]json.RawMessage `json:"settings"` Sections []Section `json:"sections"` + Monitors []json.RawMessage `json:"monitors,omitempty"` + Domains []json.RawMessage `json:"domains,omitempty"` + Systems []json.RawMessage `json:"systems,omitempty"` +} + +// dumpRows serializes each row of a table to JSON via the driver's column +// metadata — schema drift can't silently drop columns from the backup. +func (s *Server) dumpRows(table string) ([]json.RawMessage, error) { + rows, err := s.db.Query(`SELECT * FROM ` + table) // table is a fixed literal + if err != nil { + return nil, err + } + defer rows.Close() + cols, err := rows.Columns() + if err != nil { + return nil, err + } + out := []json.RawMessage{} + for rows.Next() { + vals := make([]any, len(cols)) + ptrs := make([]any, len(cols)) + for i := range vals { + ptrs[i] = &vals[i] + } + if err := rows.Scan(ptrs...); err != nil { + return nil, err + } + obj := map[string]any{} + for i, col := range cols { + obj[col] = vals[i] + } + b, err := json.Marshal(obj) + if err != nil { + return nil, err + } + out = append(out, b) + } + return out, rows.Err() } func (s *Server) exportBoard(c *gin.Context) { @@ -25,7 +79,20 @@ func (s *Server) exportBoard(c *gin.Context) { fail(c, http.StatusInternalServerError, err.Error()) return } - c.JSON(http.StatusOK, Export{Version: 1, Settings: settings, Sections: sections}) + ex := Export{Version: 2, Settings: settings, Sections: sections} + for _, t := range []struct { + name string + dst *[]json.RawMessage + }{ + {"monitors", &ex.Monitors}, + {"domains", &ex.Domains}, + {"systems", &ex.Systems}, + } { + if rows, err := s.dumpRows(t.name); err == nil { + *t.dst = rows + } + } + c.JSON(http.StatusOK, ex) } // importBoard replaces board state inside one transaction. Ids from the @@ -36,75 +103,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 +115,66 @@ func (s *Server) importBoard(c *gin.Context) { c.JSON(http.StatusOK, sections) } +// replaceBoard swaps all board rows inside one transaction — shared by the +// Dash export import and the external-config importers. +func (s *Server) replaceBoard(sections []Section, settings map[string]json.RawMessage) error { + tx, err := s.db.Begin() + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + + for _, q := range []string{`DELETE FROM urls`, `DELETE FROM items`, `DELETE FROM sections`} { + if _, err := tx.Exec(q); err != nil { + return err + } + } + for _, sec := range sections { + if sec.ID == "" || sec.Name == "" { + return fmt.Errorf("%w: section missing id or name", errBadInput) + } + if _, err := tx.Exec(`INSERT INTO sections (id, name, position, collapsed) VALUES (?, ?, ?, ?)`, + sec.ID, sec.Name, sec.Position, sec.Collapsed); err != nil { + return err + } + for _, it := range sec.Items { + if it.ID == "" || it.Name == "" { + return fmt.Errorf("%w: item missing id or name", errBadInput) + } + var cfg any + if it.Config != nil { + cfg = string(*it.Config) + } + if _, err := tx.Exec( + `INSERT INTO items (id, section_id, kind, name, icon, position, config) VALUES (?, ?, ?, ?, ?, ?, ?)`, + it.ID, sec.ID, orDefault(it.Kind, "service"), it.Name, it.Icon, it.Position, cfg); err != nil { + return err + } + for _, u := range it.URLs { + if !validURL(u.URL) { + return fmt.Errorf("%w: invalid url: %s", errBadInput, u.URL) + } + uid := u.ID + if uid == "" { + uid = newID("u") + } + if _, err := tx.Exec(`INSERT INTO urls (id, item_id, url, label, position) VALUES (?, ?, ?, ?, ?)`, + uid, it.ID, u.URL, u.Label, u.Position); err != nil { + return err + } + } + } + } + for k, v := range settings { + if _, err := tx.Exec( + `INSERT INTO settings (key, value) VALUES (?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value`, k, string(v)); err != nil { + return err + } + } + return tx.Commit() +} + func orDefault(v, def string) string { if v == "" { return def diff --git a/apps/backend/internal/db/migrations/0003_monitors.sql b/apps/backend/internal/db/migrations/0003_monitors.sql new file mode 100644 index 0000000..0b23e70 --- /dev/null +++ b/apps/backend/internal/db/migrations/0003_monitors.sql @@ -0,0 +1,45 @@ +-- +goose Up +-- Uptime monitors and their check history (merge phase M1). +CREATE TABLE monitors ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + type TEXT NOT NULL CHECK (type IN ('http','tcp','ping','dns','keyword','json','push')), + url TEXT NOT NULL DEFAULT '', + hostname TEXT NOT NULL DEFAULT '', + port INTEGER NOT NULL DEFAULT 0, + method TEXT NOT NULL DEFAULT 'GET', + headers JSON NOT NULL DEFAULT '{}', + body TEXT NOT NULL DEFAULT '', + keyword TEXT NOT NULL DEFAULT '', + keyword_invert INTEGER NOT NULL DEFAULT 0, + json_query TEXT NOT NULL DEFAULT '', + expected TEXT NOT NULL DEFAULT '', + dns_type TEXT NOT NULL DEFAULT 'A', + interval_s INTEGER NOT NULL DEFAULT 60, + timeout_s INTEGER NOT NULL DEFAULT 10, + retries INTEGER NOT NULL DEFAULT 0, + active INTEGER NOT NULL DEFAULT 1, + status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('up','down','pending','paused','maintenance')), + push_token TEXT NOT NULL DEFAULT '', + tags JSON NOT NULL DEFAULT '[]', + notes TEXT NOT NULL DEFAULT '', + position REAL NOT NULL DEFAULT 0, + last_check TEXT, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); +CREATE INDEX monitors_due ON monitors(active, last_check); + +CREATE TABLE heartbeats ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + monitor_id TEXT NOT NULL REFERENCES monitors(id) ON DELETE CASCADE, + status TEXT NOT NULL CHECK (status IN ('up','down')), + ping_ms INTEGER NOT NULL DEFAULT 0, + msg TEXT NOT NULL DEFAULT '', + cert_expiry INTEGER NOT NULL DEFAULT -1, + checked_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); +CREATE INDEX heartbeats_monitor_time ON heartbeats(monitor_id, checked_at); + +-- +goose Down +DROP TABLE heartbeats; +DROP TABLE monitors; diff --git a/apps/backend/internal/db/migrations/0004_domains.sql b/apps/backend/internal/db/migrations/0004_domains.sql new file mode 100644 index 0000000..6358540 --- /dev/null +++ b/apps/backend/internal/db/migrations/0004_domains.sql @@ -0,0 +1,84 @@ +-- +goose Up +-- Domain monitoring (merge phase M2). One wide table mirrors the flat +-- lookup Result; domain_checks snapshots each refresh for change history. +CREATE TABLE domains ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + tld TEXT NOT NULL DEFAULT '', + active INTEGER NOT NULL DEFAULT 1, + auto_renew INTEGER NOT NULL DEFAULT 0, + alert_days_before INTEGER NOT NULL DEFAULT 30, + tags JSON NOT NULL DEFAULT '[]', + notes TEXT NOT NULL DEFAULT '', + -- whois / rdap + expiry_date TEXT, + creation_date TEXT, + updated_date TEXT, + registrar_name TEXT NOT NULL DEFAULT '', + registrar_id TEXT NOT NULL DEFAULT '', + registrar_url TEXT NOT NULL DEFAULT '', + registry_domain_id TEXT NOT NULL DEFAULT '', + dnssec TEXT NOT NULL DEFAULT '', + statuses JSON NOT NULL DEFAULT '[]', + privacy_enabled INTEGER NOT NULL DEFAULT 0, + transfer_lock INTEGER NOT NULL DEFAULT 0, + registrant_name TEXT NOT NULL DEFAULT '', + registrant_org TEXT NOT NULL DEFAULT '', + registrant_country TEXT NOT NULL DEFAULT '', + abuse_email TEXT NOT NULL DEFAULT '', + -- dns + name_servers JSON NOT NULL DEFAULT '[]', + mx_records JSON NOT NULL DEFAULT '[]', + txt_records JSON NOT NULL DEFAULT '[]', + cname TEXT NOT NULL DEFAULT '', + ipv4 JSON NOT NULL DEFAULT '[]', + ipv6 JSON NOT NULL DEFAULT '[]', + -- tls leaf cert + ssl_issuer TEXT NOT NULL DEFAULT '', + ssl_valid_from TEXT, + ssl_valid_to TEXT, + ssl_subject TEXT NOT NULL DEFAULT '', + ssl_fingerprint TEXT NOT NULL DEFAULT '', + ssl_key_size INTEGER NOT NULL DEFAULT 0, + ssl_sig_algo TEXT NOT NULL DEFAULT '', + ssl_alt_names JSON NOT NULL DEFAULT '[]', + -- host geo + host_country TEXT NOT NULL DEFAULT '', + host_country_code TEXT NOT NULL DEFAULT '', + host_region TEXT NOT NULL DEFAULT '', + host_city TEXT NOT NULL DEFAULT '', + host_isp TEXT NOT NULL DEFAULT '', + host_org TEXT NOT NULL DEFAULT '', + host_as TEXT NOT NULL DEFAULT '', + host_lat REAL NOT NULL DEFAULT 0, + host_lon REAL NOT NULL DEFAULT 0, + -- provider detection + dns_provider TEXT NOT NULL DEFAULT '', + email_provider TEXT NOT NULL DEFAULT '', + hosting_provider TEXT NOT NULL DEFAULT '', + ca_provider TEXT NOT NULL DEFAULT '', + headers JSON NOT NULL DEFAULT '{}', + favicon_url TEXT NOT NULL DEFAULT '', + lookup_error TEXT NOT NULL DEFAULT '', + -- scheduling + interval_h INTEGER NOT NULL DEFAULT 24, + last_checked TEXT, + position REAL NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); +CREATE INDEX domains_due ON domains(active, last_checked); + +CREATE TABLE domain_checks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + domain_id TEXT NOT NULL REFERENCES domains(id) ON DELETE CASCADE, + expiry_date TEXT, + ssl_valid_to TEXT, + ipv4 JSON NOT NULL DEFAULT '[]', + name_servers JSON NOT NULL DEFAULT '[]', + checked_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); +CREATE INDEX domain_checks_domain_time ON domain_checks(domain_id, checked_at); + +-- +goose Down +DROP TABLE domain_checks; +DROP TABLE domains; diff --git a/apps/backend/internal/db/migrations/0005_systems.sql b/apps/backend/internal/db/migrations/0005_systems.sql new file mode 100644 index 0000000..3dff3f6 --- /dev/null +++ b/apps/backend/internal/db/migrations/0005_systems.sql @@ -0,0 +1,31 @@ +-- +goose Up +CREATE TABLE systems ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + token TEXT NOT NULL UNIQUE, + host TEXT NOT NULL DEFAULT '', + os TEXT NOT NULL DEFAULT '', + arch TEXT NOT NULL DEFAULT '', + cpu_model TEXT NOT NULL DEFAULT '', + cores INTEGER NOT NULL DEFAULT 0, + interval_s INTEGER NOT NULL DEFAULT 10, + status TEXT NOT NULL DEFAULT 'pending', + last_seen_at TEXT, + latest TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) +); + +-- Whole agent payload per tick; no sub-field queries, so one JSON column +-- instead of a wide row that drifts with every agent version. +CREATE TABLE system_stats ( + system_id TEXT NOT NULL REFERENCES systems(id) ON DELETE CASCADE, + ts TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + payload TEXT NOT NULL, + PRIMARY KEY (system_id, ts) +); + +CREATE INDEX idx_system_stats_ts ON system_stats(ts); + +-- +goose Down +DROP TABLE IF EXISTS system_stats; +DROP TABLE IF EXISTS systems; diff --git a/apps/backend/internal/db/migrations/0006_ops.sql b/apps/backend/internal/db/migrations/0006_ops.sql new file mode 100644 index 0000000..d1dd8d9 --- /dev/null +++ b/apps/backend/internal/db/migrations/0006_ops.sql @@ -0,0 +1,45 @@ +-- +goose Up +CREATE TABLE incidents ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + severity TEXT NOT NULL DEFAULT 'major', -- minor | major | critical + status TEXT NOT NULL DEFAULT 'open', -- open | ack | resolved | closed + monitor_id TEXT REFERENCES monitors(id) ON DELETE SET NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) +); +CREATE INDEX idx_incidents_status ON incidents(status); + +CREATE TABLE incident_updates ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + incident_id TEXT NOT NULL REFERENCES incidents(id) ON DELETE CASCADE, + status TEXT NOT NULL, + message TEXT NOT NULL DEFAULT '', + at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) +); +CREATE INDEX idx_incident_updates_inc ON incident_updates(incident_id); + +CREATE TABLE maintenance_windows ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + starts_at TEXT NOT NULL, + ends_at TEXT NOT NULL, + monitor_ids TEXT NOT NULL DEFAULT '[]', -- JSON array; empty covers all + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) +); + +CREATE TABLE status_pages ( + id TEXT PRIMARY KEY, + slug TEXT NOT NULL UNIQUE, + title TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + monitor_ids TEXT NOT NULL DEFAULT '[]', -- JSON array; empty = all monitors + system_ids TEXT NOT NULL DEFAULT '[]', + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) +); + +-- +goose Down +DROP TABLE IF EXISTS status_pages; +DROP TABLE IF EXISTS maintenance_windows; +DROP TABLE IF EXISTS incident_updates; +DROP TABLE IF EXISTS incidents; diff --git a/apps/backend/internal/domain/domain.go b/apps/backend/internal/domain/domain.go new file mode 100644 index 0000000..419f046 --- /dev/null +++ b/apps/backend/internal/domain/domain.go @@ -0,0 +1,404 @@ +// Package domain is the domain-intelligence half of the Beszel/Domain-Locker +// merge: given a domain name it gathers WHOIS/RDAP registration data, DNS +// records, TLS certificate details, host geolocation, and provider hints. +// Pure lookup logic — no DB, no HTTP handlers. +package domain + +import ( + "context" + "crypto/ecdsa" + "crypto/rsa" + "crypto/sha256" + "crypto/tls" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "regexp" + "strings" + "time" +) + +// Lookup bundles the shared HTTP client used by the network probes. +type Lookup struct { + http *http.Client +} + +func NewLookup() *Lookup { + return &Lookup{http: &http.Client{Timeout: 10 * time.Second}} +} + +// Result is everything one refresh learns about a domain. Fields stay flat — +// the API layer maps them 1:1 onto the domains table columns. +type Result struct { + Name string `json:"name"` + TLD string `json:"tld"` + + // WHOIS / RDAP + ExpiryDate *time.Time `json:"expiryDate"` + CreationDate *time.Time `json:"creationDate"` + UpdatedDate *time.Time `json:"updatedDate"` + RegistrarName string `json:"registrarName"` + RegistrarID string `json:"registrarId"` + RegistrarURL string `json:"registrarUrl"` + RegistryDomainID string `json:"registryDomainId"` + DNSSEC string `json:"dnssec"` + Statuses []string `json:"statuses"` + PrivacyEnabled bool `json:"privacyEnabled"` + TransferLock bool `json:"transferLock"` + RegistrantName string `json:"registrantName"` + RegistrantOrg string `json:"registrantOrg"` + RegistrantCountry string `json:"registrantCountry"` + AbuseEmail string `json:"abuseEmail"` + + // DNS + NameServers []string `json:"nameServers"` + MXRecords []string `json:"mxRecords"` + TXTRecords []string `json:"txtRecords"` + CNAME string `json:"cname"` + IPv4 []string `json:"ipv4"` + IPv6 []string `json:"ipv6"` + + // TLS leaf certificate + SSLIssuer string `json:"sslIssuer"` + SSLValidFrom *time.Time `json:"sslValidFrom"` + SSLValidTo *time.Time `json:"sslValidTo"` + SSLSubject string `json:"sslSubject"` + SSLFingerprint string `json:"sslFingerprint"` + SSLKeySize int `json:"sslKeySize"` + SSLSigAlgo string `json:"sslSigAlgo"` + SSLAltNames []string `json:"sslAltNames"` + + // Host geolocation (ip-api.com; empty when unreachable/private) + HostCountry string `json:"hostCountry"` + HostCountryCode string `json:"hostCountryCode"` + HostRegion string `json:"hostRegion"` + HostCity string `json:"hostCity"` + HostISP string `json:"hostIsp"` + HostOrg string `json:"hostOrg"` + HostAS string `json:"hostAs"` + HostLat float64 `json:"hostLat"` + HostLon float64 `json:"hostLon"` + + // Provider detection + DNSProvider string `json:"dnsProvider"` + EmailProvider string `json:"emailProvider"` + HostingProvider string `json:"hostingProvider"` + CAProvider string `json:"caProvider"` + + Headers map[string]string `json:"headers"` + FaviconURL string `json:"faviconUrl"` + Error string `json:"error"` // non-fatal partial failure note +} + +// Clean normalises user input to a bare registrable hostname. +func Clean(name string) string { + name = strings.TrimSpace(strings.ToLower(name)) + name = regexp.MustCompile(`^https?://`).ReplaceAllString(name, "") + name = regexp.MustCompile(`^www\.`).ReplaceAllString(name, "") + if i := strings.IndexAny(name, "/?#"); i >= 0 { + name = name[:i] + } + if i := strings.Index(name, ":"); i >= 0 { + name = name[:i] + } + return name +} + +// Valid reports whether s is a plausible DNS name we can query for. +func Valid(s string) bool { return validDomainName(s) } + +// Run gathers everything. Individual probe failures are non-fatal — a domain +// with no HTTPS still returns WHOIS+DNS. The returned Error field carries the +// first fatal WHOIS failure when even registration data is unavailable. +func (l *Lookup) Run(ctx context.Context, rawName string) *Result { + name := Clean(rawName) + r := &Result{ + Name: name, + TLD: tldOf(name), + Statuses: []string{}, + NameServers: []string{}, + MXRecords: []string{}, + TXTRecords: []string{}, + IPv4: []string{}, + IPv6: []string{}, + SSLAltNames: []string{}, + Headers: map[string]string{}, + FaviconURL: fmt.Sprintf("https://www.google.com/s2/favicons?domain=%s&sz=128", name), + } + + if w, err := l.Whois(ctx, name); err == nil { + r.ExpiryDate = w.ExpiryDate + r.CreationDate = w.CreationDate + r.UpdatedDate = w.UpdatedDate + r.RegistrarName = w.RegistrarName + r.RegistrarID = w.RegistrarID + r.RegistrarURL = w.RegistrarURL + r.RegistryDomainID = w.RegistryDomainID + r.DNSSEC = w.DNSSEC + r.Statuses = w.Statuses + r.PrivacyEnabled = w.PrivacyEnabled + r.TransferLock = w.TransferLock + r.RegistrantName = w.RegistrantName + r.RegistrantOrg = w.RegistrantOrg + r.RegistrantCountry = w.RegistrantCountry + r.AbuseEmail = w.AbuseEmail + } else { + r.Error = "whois: " + err.Error() + } + + l.dns(ctx, name, r) + l.cert(ctx, name, r) + if len(r.IPv4) > 0 { + l.host(r.IPv4[0], r) + } + l.headers(ctx, name, r) + + r.DNSProvider = detectDNSProvider(r.NameServers) + r.EmailProvider = detectEmailProvider(r.MXRecords) + r.HostingProvider = detectHostingProvider(r.Headers) + r.CAProvider = detectCA(r.SSLIssuer) + return r +} + +// --- DNS -------------------------------------------------------------------- + +func (l *Lookup) dns(ctx context.Context, name string, r *Result) { + res := net.DefaultResolver + if ns, err := res.LookupNS(ctx, name); err == nil { + for _, n := range ns { + r.NameServers = append(r.NameServers, strings.TrimSuffix(strings.ToLower(n.Host), ".")) + } + } + if mx, err := res.LookupMX(ctx, name); err == nil { + for _, m := range mx { + r.MXRecords = append(r.MXRecords, fmt.Sprintf("%s (pri %d)", strings.TrimSuffix(m.Host, "."), m.Pref)) + } + } + if txt, err := res.LookupTXT(ctx, name); err == nil { + r.TXTRecords = txt + } + if c, err := res.LookupCNAME(ctx, name); err == nil && c != "" && strings.TrimSuffix(c, ".") != name { + r.CNAME = strings.TrimSuffix(c, ".") + } + if ips, err := res.LookupIP(ctx, "ip4", name); err == nil { + for _, ip := range ips { + r.IPv4 = append(r.IPv4, ip.String()) + } + } + if ips, err := res.LookupIP(ctx, "ip6", name); err == nil { + for _, ip := range ips { + r.IPv6 = append(r.IPv6, ip.String()) + } + } +} + +// --- TLS leaf certificate ---------------------------------------------------- + +func (l *Lookup) cert(ctx context.Context, name string, r *Result) { + d := &net.Dialer{Timeout: 5 * time.Second} + conn, err := tls.DialWithDialer(d, "tcp", net.JoinHostPort(name, "443"), &tls.Config{ + ServerName: name, + InsecureSkipVerify: true, // inspecting, not authenticating — expiry is data + }) + if err != nil { + return + } + defer conn.Close() + certs := conn.ConnectionState().PeerCertificates + if len(certs) == 0 { + return + } + c := certs[0] + if len(c.Issuer.Organization) > 0 { + r.SSLIssuer = c.Issuer.Organization[0] + } else { + r.SSLIssuer = c.Issuer.CommonName + } + r.SSLValidFrom = &c.NotBefore + r.SSLValidTo = &c.NotAfter + r.SSLSubject = c.Subject.CommonName + fp := sha256.Sum256(c.Raw) + h := strings.ToUpper(hex.EncodeToString(fp[:])) + var parts []string + for i := 0; i+2 <= len(h); i += 2 { + parts = append(parts, h[i:i+2]) + } + r.SSLFingerprint = strings.Join(parts, ":") + r.SSLSigAlgo = c.SignatureAlgorithm.String() + switch k := c.PublicKey.(type) { + case *rsa.PublicKey: + r.SSLKeySize = k.N.BitLen() + case *ecdsa.PublicKey: + r.SSLKeySize = k.Curve.Params().BitSize + } + for _, n := range c.DNSNames { + r.SSLAltNames = append(r.SSLAltNames, n) + } +} + +// --- Host geolocation (ip-api.com, free non-commercial tier) ----------------- + +func (l *Lookup) host(ip string, r *Result) { + req, err := http.NewRequest(http.MethodGet, + fmt.Sprintf("http://ip-api.com/json/%s?fields=status,country,countryCode,regionName,city,lat,lon,isp,org,as", ip), nil) + if err != nil { + return + } + resp, err := l.http.Do(req) + if err != nil { + return + } + defer resp.Body.Close() + var g struct { + Status string `json:"status"` + Country string `json:"country"` + Code string `json:"countryCode"` + Region string `json:"regionName"` + City string `json:"city"` + Lat float64 `json:"lat"` + Lon float64 `json:"lon"` + ISP string `json:"isp"` + Org string `json:"org"` + AS string `json:"as"` + } + if err := json.NewDecoder(io.LimitReader(resp.Body, 64<<10)).Decode(&g); err != nil || g.Status != "success" { + return + } + r.HostCountry, r.HostCountryCode = g.Country, g.Code + r.HostRegion, r.HostCity = g.Region, g.City + r.HostISP, r.HostOrg, r.HostAS = g.ISP, g.Org, g.AS + r.HostLat, r.HostLon = g.Lat, g.Lon +} + +// --- HTTP headers (feed provider detection) ----------------------------------- + +func (l *Lookup) headers(ctx context.Context, name string, r *Result) { + for _, scheme := range []string{"https", "http"} { + req, err := http.NewRequestWithContext(ctx, http.MethodHead, scheme+"://"+name, nil) + if err != nil { + continue + } + req.Header.Set("User-Agent", "Dash domain lookup") + resp, err := l.http.Do(req) + if err != nil { + continue + } + for k, v := range resp.Header { + if len(v) > 0 { + r.Headers[strings.ToLower(k)] = v[0] + } + } + resp.Body.Close() + return + } +} + +// --- Provider detection (condensed port of beszel's detect package) ----------- + +func detectDNSProvider(ns []string) string { + table := []struct{ sub, name string }{ + {"cloudflare", "Cloudflare"}, {"awsdns", "Amazon Route 53"}, + {"googledomains", "Google Domains"}, {"google.com", "Google Domains"}, + {"namecheap", "Namecheap"}, {"godaddy", "GoDaddy"}, {"domaincontrol", "GoDaddy"}, + {"nsone.net", "NS1"}, {"digitalocean", "DigitalOcean"}, {"linode", "Linode"}, + {"vultr", "Vultr"}, {"he.net", "Hurricane Electric"}, {"dynect", "Dyn (Oracle)"}, + {"ultradns", "UltraDNS"}, {"dnsimple", "DNSimple"}, {"hover", "Hover"}, + {"enom", "eNom"}, {"worldnic", "Network Solutions"}, {"zoneedit", "ZoneEdit"}, + {"easydns", "EasyDNS"}, {"gandi", "Gandi"}, {"ovh", "OVH"}, + {"hetzner", "Hetzner"}, {"azure-dns", "Microsoft Azure"}, + {"dns.google", "Google Cloud DNS"}, {"porkbun", "Porkbun"}, + {"cloudns", "ClouDNS"}, {"desec", "deSEC"}, {"nic.cz", "CZ.NIC"}, + } + for _, n := range ns { + n = strings.ToLower(n) + for _, t := range table { + if strings.Contains(n, t.sub) { + return t.name + } + } + } + return "" +} + +func detectEmailProvider(mx []string) string { + table := []struct{ sub, name string }{ + {"google.com", "Google Workspace"}, {"aspmx", "Google Workspace"}, + {"outlook.com", "Microsoft 365"}, {"protection.outlook", "Microsoft 365"}, + {"mail.protection", "Microsoft 365"}, {"protonmail", "Proton Mail"}, + {"proton.me", "Proton Mail"}, {"zoho", "Zoho Mail"}, + {"fastmail", "Fastmail"}, {"mimecast", "Mimecast"}, + {"proofpoint", "Proofpoint"}, {"barracuda", "Barracuda"}, + {"mailgun", "Mailgun"}, {"sendgrid", "SendGrid"}, + {"amazonses", "Amazon SES"}, {"yandex", "Yandex"}, + {"tuta", "Tuta"}, {"mailbox.org", "mailbox.org"}, + } + for _, m := range mx { + m = strings.ToLower(m) + for _, t := range table { + if strings.Contains(m, t.sub) { + return t.name + } + } + } + return "" +} + +func detectHostingProvider(h map[string]string) string { + server := strings.ToLower(h["server"] + " " + h["x-powered-by"] + " " + h["via"]) + if _, ok := h["cf-ray"]; ok || strings.Contains(server, "cloudflare") { + return "Cloudflare" + } + if _, ok := h["x-vercel-id"]; ok || strings.Contains(server, "vercel") { + return "Vercel" + } + if _, ok := h["x-nf-request-id"]; ok || strings.Contains(server, "netlify") { + return "Netlify" + } + if _, ok := h["x-amz-cf-id"]; ok || strings.Contains(server, "cloudfront") { + return "AWS CloudFront" + } + if _, ok := h["x-github-request-id"]; ok || strings.Contains(server, "github") { + return "GitHub Pages" + } + if strings.Contains(server, "fly.io") || h["fly-request-id"] != "" { + return "Fly.io" + } + if strings.Contains(server, "render") { + return "Render" + } + table := []struct{ sub, name string }{ + {"nginx", "nginx"}, {"apache", "Apache"}, {"caddy", "Caddy"}, + {"litespeed", "LiteSpeed"}, {"openresty", "OpenResty"}, + {"envoy", "Envoy"}, {"awselb", "AWS ELB"}, {"gws", "Google"}, + {"kestrel", "Kestrel/.NET"}, {"cowboy", "Cowboy/Erlang"}, + } + for _, t := range table { + if strings.Contains(server, t.sub) { + return t.name + } + } + return "" +} + +func detectCA(issuer string) string { + i := strings.ToLower(issuer) + table := []struct{ sub, name string }{ + {"let's encrypt", "Let's Encrypt"}, {"letsencrypt", "Let's Encrypt"}, + {"digicert", "DigiCert"}, {"sectigo", "Sectigo"}, {"comodoca", "Sectigo"}, + {"globalsign", "GlobalSign"}, {"godaddy", "GoDaddy"}, {"starfield", "GoDaddy"}, + {"entrust", "Entrust"}, {"google trust", "Google Trust Services"}, + {"amazon", "Amazon"}, {"zerossl", "ZeroSSL"}, {"buypass", "Buypass"}, + {"ssl.com", "SSL.com"}, {"actalis", "Actalis"}, {"certum", "Certum"}, + {"harica", "HARICA"}, {"swisssign", "SwissSign"}, {"cloudflare", "Cloudflare"}, + {"pki.goog", "Google Trust Services"}, {"microsoft", "Microsoft"}, + } + for _, t := range table { + if strings.Contains(i, t.sub) { + return t.name + } + } + return "" +} diff --git a/apps/backend/internal/domain/whois.go b/apps/backend/internal/domain/whois.go new file mode 100644 index 0000000..f501423 --- /dev/null +++ b/apps/backend/internal/domain/whois.go @@ -0,0 +1,411 @@ +// WHOIS data acquisition. Ported from Beszel's hub/domains/whois — the +// portable parts only: RDAP via IANA bootstrap, TCP WHOIS on :43, and the +// registry-format parser. Dropped: native `whois` exec (breaks the +// single-binary model), EURid/whois.com HTML scraping (fragile), and the +// paid WhoisXML API. +package domain + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "regexp" + "strings" + "sync" + "time" +) + +// WhoisData is the registry half of a Lookup result. +type WhoisData struct { + DomainName string + Statuses []string + DNSSEC string // "signed" | "unsigned" | "" + ExpiryDate *time.Time + CreationDate *time.Time + UpdatedDate *time.Time + RegistrarName string + RegistrarID string + RegistrarURL string + RegistryDomainID string + RegistrantName string + RegistrantOrg string + RegistrantCountry string + AbuseEmail string + PrivacyEnabled bool + TransferLock bool +} + +// Whois looks up registration data: RDAP first (clean JSON), TCP WHOIS as +// fallback for TLDs without RDAP service. +func (l *Lookup) Whois(ctx context.Context, name string) (*WhoisData, error) { + if d, err := l.rdap(ctx, name); err == nil && d.valid() { + return d, nil + } + if d, err := tcpWhois(ctx, name); err == nil && d.valid() { + return d, nil + } + return nil, fmt.Errorf("no whois data for %s", name) +} + +func (d *WhoisData) valid() bool { + return d != nil && (d.ExpiryDate != nil || d.CreationDate != nil || + (d.RegistrarName != "" && d.RegistrarName != "Unknown") || len(d.Statuses) > 0) +} + +// --- RDAP ------------------------------------------------------------------ + +// rdapBases caches TLD -> RDAP base URL from the IANA bootstrap registry. +var rdapBases = struct { + sync.Mutex + m map[string]string +}{m: map[string]string{}} + +func (l *Lookup) rdap(ctx context.Context, name string) (*WhoisData, error) { + tld := tldOf(name) + if tld == "" { + return nil, fmt.Errorf("no tld") + } + base, err := rdapBase(ctx, tld) + if err != nil { + return nil, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, base+"/domain/"+name, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/rdap+json") + resp, err := l.http.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("rdap status %d", resp.StatusCode) + } + + var r struct { + LdhName string `json:"ldhName"` + Handle string `json:"handle"` + Status []string `json:"status"` + Events []struct { + Action string `json:"eventAction"` + Date string `json:"eventDate"` + } `json:"events"` + Entities []struct { + Roles []string `json:"roles"` + PublicIDs []struct { + Type string `json:"type"` + Identifier string `json:"identifier"` + } `json:"publicIds"` + VCard []any `json:"vcardArray"` + } `json:"entities"` + SecureDNS struct { + ZoneSigned bool `json:"zoneSigned"` + } `json:"secureDNS"` + } + if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&r); err != nil { + return nil, err + } + + d := &WhoisData{DomainName: r.LdhName, Statuses: r.Status, RegistryDomainID: r.Handle} + if r.SecureDNS.ZoneSigned { + d.DNSSEC = "signed" + } + for _, e := range r.Events { + t, err := time.Parse(time.RFC3339, e.Date) + if err != nil || t.IsZero() { + continue + } + switch e.Action { + case "registration": + d.CreationDate = &t + case "expiration": + d.ExpiryDate = &t + case "last changed": + d.UpdatedDate = &t + } + } + for _, ent := range r.Entities { + if !hasRole(ent.Roles, "registrar") { + continue + } + // vcardArray: ["vcard", [[name, params, type, value], ...]] + if len(ent.VCard) > 1 { + if props, ok := ent.VCard[1].([]any); ok { + for _, p := range props { + if arr, ok := p.([]any); ok && len(arr) >= 4 && arr[0] == "fn" { + if s, ok := arr[3].(string); ok { + d.RegistrarName = s + } + } + } + } + } + for _, pid := range ent.PublicIDs { + if pid.Type == "IANA Registrar ID" { + d.RegistrarID = pid.Identifier + } + } + } + d.derive() + return d, nil +} + +func hasRole(roles []string, want string) bool { + for _, r := range roles { + if r == want { + return true + } + } + return false +} + +func rdapBase(ctx context.Context, tld string) (string, error) { + rdapBases.Lock() + defer rdapBases.Unlock() + if u, ok := rdapBases.m[tld]; ok { + return u, nil + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://data.iana.org/rdap/dns.json", nil) + if err != nil { + return "", err + } + resp, err := (&http.Client{Timeout: 10 * time.Second}).Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + var boot struct { + Services [][]any `json:"services"` + } + if err := json.NewDecoder(io.LimitReader(resp.Body, 4<<20)).Decode(&boot); err != nil { + return "", err + } + for _, svc := range boot.Services { + if len(svc) < 2 { + continue + } + tlds, _ := svc[0].([]any) + urls, _ := svc[1].([]any) + if len(urls) == 0 { + continue + } + base, _ := urls[0].(string) + for _, t := range tlds { + if s, ok := t.(string); ok { + rdapBases.m[s] = strings.TrimSuffix(base, "/") + } + } + } + if u, ok := rdapBases.m[tld]; ok { + return u, nil + } + return "", fmt.Errorf("no rdap server for .%s", tld) +} + +// --- TCP WHOIS (:43) -------------------------------------------------------- + +var whoisServers = map[string]string{ + "com": "whois.verisign-grs.com", "net": "whois.verisign-grs.com", + "org": "whois.pir.org", "io": "whois.nic.io", "co": "whois.nic.co", + "dev": "whois.nic.google", "app": "whois.nic.google", "xyz": "whois.nic.xyz", + "info": "whois.afilias.net", "biz": "whois.biz", "us": "whois.nic.us", + "uk": "whois.nic.uk", "de": "whois.denic.de", "fr": "whois.nic.fr", + "eu": "whois.eu", "nl": "whois.domain-registry.nl", "ca": "whois.cira.ca", + "au": "whois.auda.org.au", "me": "whois.nic.me", "tv": "whois.nic.tv", + "cc": "whois.nic.cc", "name": "whois.nic.name", "pro": "whois.nic.pro", + "cz": "whois.nic.cz", "sk": "whois.sk-nic.sk", "pl": "whois.dns.pl", + "se": "whois.iis.se", "fi": "whois.fi", "no": "whois.norid.no", + "ch": "whois.nic.ch", "at": "whois.nic.at", "be": "whois.dns.be", + "it": "whois.nic.it", "es": "whois.nic.es", "pt": "whois.dns.pt", + "jp": "whois.jprs.jp", "kr": "whois.kr", "in": "whois.registry.in", + "br": "whois.registro.br", "za": "whois.registry.net.za", + "cloud": "whois.nic.cloud", "online": "whois.nic.online", + "site": "whois.nic.site", "store": "whois.nic.store", "tech": "whois.nic.tech", + "ai": "whois.nic.ai", "gg": "whois.gg", "sh": "whois.nic.sh", +} + +func tcpWhois(ctx context.Context, name string) (*WhoisData, error) { + tld := tldOf(name) + server, ok := whoisServers[tld] + if !ok { + server = "whois.iana.org" + } + d := &net.Dialer{Timeout: 10 * time.Second} + conn, err := d.DialContext(ctx, "tcp", net.JoinHostPort(server, "43")) + if err != nil { + return nil, err + } + defer conn.Close() + _ = conn.SetDeadline(time.Now().Add(15 * time.Second)) + if _, err := conn.Write([]byte(name + "\r\n")); err != nil { + return nil, err + } + raw, err := io.ReadAll(io.LimitReader(conn, 1<<20)) + if err != nil && len(raw) == 0 { + return nil, err + } + out := parseWhois(string(raw), name) + out.derive() + return out, nil +} + +// parseWhois flattens "Key: Value" lines and extracts the fields we store. +// Registry formats vary wildly; the field-name fallbacks cover the common set. +func parseWhois(raw, name string) *WhoisData { + d := &WhoisData{DomainName: name} + kv := map[string]string{} + for _, line := range strings.Split(raw, "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "%") || strings.HasPrefix(line, "#") { + continue + } + idx := strings.Index(line, ":") + if idx <= 0 { + continue + } + key := strings.ToLower(strings.TrimSpace(line[:idx])) + key = strings.NewReplacer(" ", "_", "/", "_", "-", "_").Replace(key) + val := strings.TrimSpace(line[idx+1:]) + if val == "" || strings.HasPrefix(strings.ToUpper(val), "REDACTED") { + continue + } + if _, dup := kv[key]; !dup { + kv[key] = val + } + switch key { + case "domain_status", "status": + if s := parseStatus(val); len(s) > 0 { + d.Statuses = append(d.Statuses, s...) + } + } + } + + d.ExpiryDate = parseDate(kv["registry_expiry_date"], kv["registrar_registration_expiration_date"], + kv["expiry_date"], kv["expiration_time"], kv["expire"], kv["paid_until"], + kv["expire_date"], kv["renewal_date"], kv["valid_until"], kv["expires"]) + d.CreationDate = parseDate(kv["creation_date"], kv["created_date"], kv["created"], + kv["registration_time"], kv["registered_on"], kv["domain_registered"], kv["registered"]) + d.UpdatedDate = parseDate(kv["updated_date"], kv["last_updated"], kv["last_modified"], + kv["modified_date"], kv["modified"], kv["changed"]) + + for _, k := range []string{"registrar", "registrar_name", "sponsoring_registrar", "registrar_organization"} { + if v := kv[k]; v != "" { + d.RegistrarName = v + break + } + } + if d.RegistrarName == "" { + d.RegistrarName = "Unknown" + } + d.RegistrarID = kv["registrar_iana_id"] + d.RegistrarURL = kv["registrar_url"] + d.RegistryDomainID = kv["registry_domain_id"] + + d.RegistrantName = firstOf(kv, "registrant_name", "registrant", "holder", "domain_holder") + d.RegistrantOrg = firstOf(kv, "registrant_organization", "org", "organization", "holder_org") + d.RegistrantCountry = firstOf(kv, "registrant_country", "country", "holder_country") + d.AbuseEmail = kv["registrar_abuse_contact_email"] + + if v := strings.ToLower(firstOf(kv, "dnssec", "dnssec_signed", "signed_dnssec")); v == "signed" || v == "yes" || v == "true" || v == "signeddelegation" { + d.DNSSEC = "signed" + } else if v != "" { + d.DNSSEC = "unsigned" + } + return d +} + +// derive fills computed flags after whichever lookup path populated d. +func (d *WhoisData) derive() { + low := strings.ToLower(d.RegistrantName + " " + d.RegistrantOrg) + for _, marker := range []string{"redacted", "privacy", "whoisguard", "not disclosed", "hidden", "data protected", "gdpr"} { + if strings.Contains(low, marker) { + d.PrivacyEnabled = true + break + } + } + for _, s := range d.Statuses { + if sl := strings.ToLower(s); strings.Contains(sl, "transferprohibited") { + d.TransferLock = true + break + } + } +} + +func firstOf(kv map[string]string, keys ...string) string { + for _, k := range keys { + if v := kv[k]; v != "" { + return v + } + } + return "" +} + +var knownStatuses = []string{ + "clientDeleteProhibited", "clientHold", "clientRenewProhibited", + "clientTransferProhibited", "clientUpdateProhibited", + "serverDeleteProhibited", "serverHold", "serverRenewProhibited", + "serverTransferProhibited", "serverUpdateProhibited", + "inactive", "ok", "pendingCreate", "pendingDelete", "pendingRenew", + "pendingRestore", "pendingTransfer", "pendingUpdate", + "addPeriod", "autoRenewPeriod", "renewPeriod", "transferPeriod", +} + +func parseStatus(s string) []string { + s = strings.ToLower(s) + var out []string + for _, k := range knownStatuses { + if strings.Contains(s, strings.ToLower(k)) { + out = append(out, k) + } + } + return out +} + +var dateFormats = []string{ + time.RFC3339, "2006-01-02T15:04:05Z0700", "2006-01-02T15:04:05", + "2006-01-02 15:04:05", "2006-01-02 15:04:05.0", "2006-01-02", + "01/02/2006", "02/01/2006", "02.01.2006", "02-Jan-2006", "2-Jan-2006", + "Jan 2 2006", "January 2 2006", "2 Jan 2006", "Jan 02 2006", + "2006-01-02 15:04:05 MST", "Mon, 02 Jan 2006 15:04:05 MST", + "20060102", "20060102150405", +} + +func parseDate(candidates ...string) *time.Time { + for _, s := range candidates { + s = strings.TrimSpace(strings.TrimPrefix(strings.TrimPrefix(s, "before "), "after ")) + if s == "" || strings.Contains(strings.ToUpper(s), "REDACTED") { + continue + } + // Trim trailing "(YYYY-MM-DD)" style annotations and zone offsets in parens. + if i := strings.Index(s, "("); i > 0 { + s = strings.TrimSpace(s[:i]) + } + for _, f := range dateFormats { + if t, err := time.Parse(f, s); err == nil && !t.IsZero() { + return &t + } + } + } + return nil +} + +// tldOf returns the last label; registrable-domain granularity is +// unnecessary here since we only feed it to server maps. +func tldOf(name string) string { + parts := strings.Split(name, ".") + if len(parts) < 2 { + return "" + } + return strings.ToLower(parts[len(parts)-1]) +} + +// whoisPattern guards against sending garbage to whois servers. +var whoisPattern = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$`) + +func validDomainName(s string) bool { + return len(s) <= 253 && whoisPattern.MatchString(s) +} diff --git a/apps/backend/internal/monitor/monitor.go b/apps/backend/internal/monitor/monitor.go new file mode 100644 index 0000000..54165da --- /dev/null +++ b/apps/backend/internal/monitor/monitor.go @@ -0,0 +1,315 @@ +// Package monitor holds the uptime-check domain: the Monitor model mirroring +// the monitors table, plus the check implementations the scheduler invokes. +// No DB access, no HTTP handlers — pure check logic. +package monitor + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "strconv" + "strings" + "time" + + "golang.org/x/net/icmp" + "golang.org/x/net/ipv4" +) + +// Monitor mirrors one row of the monitors table. +type Monitor struct { + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + URL string `json:"url"` + Hostname string `json:"hostname"` + Port int `json:"port"` + Method string `json:"method"` + Headers string `json:"headers"` // JSON object + Body string `json:"body"` + Keyword string `json:"keyword"` + KeywordInvert bool `json:"keywordInvert"` + JSONQuery string `json:"jsonQuery"` + Expected string `json:"expected"` + DNSType string `json:"dnsType"` + IntervalS int `json:"intervalS"` + TimeoutS int `json:"timeoutS"` + Retries int `json:"retries"` + Active bool `json:"active"` + Status string `json:"status"` + PushToken string `json:"pushToken,omitempty"` + Tags []string `json:"tags"` + Notes string `json:"notes"` + Position float64 `json:"position"` + LastCheck *string `json:"lastCheck"` + CreatedAt string `json:"createdAt"` +} + +// Result is the outcome of one check. +type Result struct { + Up bool + PingMs int + Msg string + CertExpiry int // days until TLS cert expiry; -1 when not applicable +} + +// Types lists the check kinds the add-monitor dialog offers. +func Types() []string { + return []string{"http", "tcp", "ping", "dns", "keyword", "json", "push"} +} + +// Check dispatches to the checker for m.Type. Retries happen in the caller. +func Check(ctx context.Context, m *Monitor) *Result { + timeout := time.Duration(m.TimeoutS) * time.Second + if timeout <= 0 { + timeout = 10 * time.Second + } + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + switch m.Type { + case "http", "keyword", "json": + return checkHTTP(ctx, m) + case "tcp": + return checkTCP(ctx, m) + case "ping": + return checkPing(ctx, m) + case "dns": + return checkDNS(ctx, m) + default: + return &Result{Msg: "unknown monitor type: " + m.Type} + } +} + +func hostOf(m *Monitor) string { + if m.Hostname != "" { + return m.Hostname + } + return m.URL +} + +func checkHTTP(ctx context.Context, m *Monitor) *Result { + method := m.Method + if method == "" { + method = http.MethodGet + } + var body io.Reader + if m.Body != "" { + body = strings.NewReader(m.Body) + } + req, err := http.NewRequestWithContext(ctx, method, m.URL, body) + if err != nil { + return &Result{Msg: "bad url: " + m.URL} + } + var hdrs map[string]string + if json.Unmarshal([]byte(m.Headers), &hdrs) == nil { + for k, v := range hdrs { + req.Header.Set(k, v) + } + } + if m.Body != "" && req.Header.Get("Content-Type") == "" { + req.Header.Set("Content-Type", "application/json") + } + + start := time.Now() + resp, err := http.DefaultClient.Do(req) + ping := int(time.Since(start).Milliseconds()) + if err != nil { + return &Result{Msg: err.Error()} + } + defer resp.Body.Close() + + res := &Result{PingMs: ping} + if resp.TLS != nil && len(resp.TLS.PeerCertificates) > 0 { + res.CertExpiry = int(time.Until(resp.TLS.PeerCertificates[0].NotAfter).Hours() / 24) + } else { + res.CertExpiry = -1 + } + + switch m.Type { + case "keyword", "json": + raw, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20)) + if err != nil { + res.Msg = "read body: " + err.Error() + return res + } + if m.Type == "keyword" { + found := strings.Contains(string(raw), m.Keyword) + if m.KeywordInvert { + found = !found + } + res.Up = found + res.Msg = fmt.Sprintf("keyword %q %s", m.Keyword, map[bool]string{true: "found", false: "absent"}[found]) + return res + } + return checkJSONBody(res, raw, m) + default: + res.Up = resp.StatusCode < 400 + res.Msg = fmt.Sprintf("HTTP %d", resp.StatusCode) + return res + } +} + +// checkJSONBody walks a dot path ("a.b.0.c") into decoded JSON and compares +// the leaf to m.Expected. +func checkJSONBody(res *Result, raw []byte, m *Monitor) *Result { + var v any + if err := json.Unmarshal(raw, &v); err != nil { + res.Msg = "invalid JSON: " + err.Error() + return res + } + for _, key := range strings.Split(m.JSONQuery, ".") { + switch n := v.(type) { + case map[string]any: + v = n[key] + case []any: + i, err := strconv.Atoi(key) + if err != nil || i < 0 || i >= len(n) { + res.Msg = "json path missing: " + m.JSONQuery + return res + } + v = n[i] + default: + res.Msg = "json path missing: " + m.JSONQuery + return res + } + } + got := fmt.Sprint(v) + res.Up = got == m.Expected + res.Msg = fmt.Sprintf("%s = %q (want %q)", m.JSONQuery, got, m.Expected) + return res +} + +func checkTCP(ctx context.Context, m *Monitor) *Result { + host := hostOf(m) + if host == "" { + return &Result{Msg: "hostname or url required"} + } + addr := net.JoinHostPort(host, strconv.Itoa(m.Port)) + start := time.Now() + conn, err := (&net.Dialer{}).DialContext(ctx, "tcp", addr) + if err != nil { + return &Result{Msg: err.Error()} + } + _ = conn.Close() + return &Result{Up: true, PingMs: int(time.Since(start).Milliseconds()), Msg: "port open"} +} + +// checkPing sends one ICMP echo. Raw sockets need CAP_NET_RAW; the udp4 +// datagram path works unprivileged on Linux via ping_group_range. +func checkPing(ctx context.Context, m *Monitor) *Result { + host := hostOf(m) + if host == "" { + return &Result{Msg: "hostname required"} + } + dst, err := net.ResolveIPAddr("ip4", host) + if err != nil { + if dst, err = net.ResolveIPAddr("ip6", host); err != nil { + return &Result{Msg: "resolve: " + err.Error()} + } + } + + conn, err := icmp.ListenPacket("ip4:icmp", "0.0.0.0") + if err != nil { + conn, err = icmp.ListenPacket("udp4", "0.0.0.0") + } + if err != nil { + return &Result{Msg: "icmp unavailable (needs cap_net_raw or ping_group_range): " + err.Error()} + } + defer conn.Close() + _ = conn.SetDeadline(time.Now().Add(4 * time.Second)) + + wm := icmp.Message{Type: ipv4.ICMPTypeEcho, Code: 0, Body: &icmp.Echo{ + ID: 1, + Seq: 1, + Data: []byte("dash"), + }} + wb, err := wm.Marshal(nil) + if err != nil { + return &Result{Msg: err.Error()} + } + start := time.Now() + var dstAddr net.Addr = dst + if _, err := conn.WriteTo(wb, dstAddr); err != nil { + return &Result{Msg: err.Error()} + } + rb := make([]byte, 1500) + for { + n, _, err := conn.ReadFrom(rb) + if err != nil { + return &Result{Msg: "no reply: " + err.Error()} + } + rm, err := icmp.ParseMessage(ipv4.ICMPTypeEchoReply.Protocol(), rb[:n]) + if err != nil { + continue + } + if rm.Type == ipv4.ICMPTypeEchoReply { + return &Result{Up: true, PingMs: int(time.Since(start).Milliseconds()), Msg: "echo reply"} + } + } +} + +func checkDNS(ctx context.Context, m *Monitor) *Result { + host := hostOf(m) + if host == "" { + return &Result{Msg: "hostname required"} + } + r := net.DefaultResolver + start := time.Now() + var answers []string + var err error + switch strings.ToUpper(m.DNSType) { + case "AAAA": + var ips []net.IP + ips, err = r.LookupIP(ctx, "ip6", host) + for _, ip := range ips { + answers = append(answers, ip.String()) + } + case "MX": + var mxs []*net.MX + mxs, err = r.LookupMX(ctx, host) + for _, mx := range mxs { + answers = append(answers, mx.Host) + } + case "NS": + var nss []*net.NS + nss, err = r.LookupNS(ctx, host) + for _, ns := range nss { + answers = append(answers, ns.Host) + } + case "TXT": + answers, err = r.LookupTXT(ctx, host) + case "CNAME": + var c string + c, err = r.LookupCNAME(ctx, host) + if c != "" { + answers = []string{c} + } + default: // A + var ips []net.IP + ips, err = r.LookupIP(ctx, "ip4", host) + for _, ip := range ips { + answers = append(answers, ip.String()) + } + } + ping := int(time.Since(start).Milliseconds()) + if err != nil { + return &Result{Msg: err.Error()} + } + if len(answers) == 0 { + return &Result{Msg: "no records"} + } + res := &Result{Up: true, PingMs: ping, Msg: strings.Join(answers, ", ")} + if m.Expected != "" { + for _, a := range answers { + if strings.Contains(a, m.Expected) { + return res + } + } + res.Up = false + res.Msg = fmt.Sprintf("no record contains %q (got %s)", m.Expected, res.Msg) + } + return res +} diff --git a/apps/backend/internal/web/dist/index.html b/apps/backend/internal/web/dist/index.html index 8e21f5d..21b8ed0 100644 --- a/apps/backend/internal/web/dist/index.html +++ b/apps/backend/internal/web/dist/index.html @@ -12,8 +12,8 @@ document.documentElement.dataset.theme = t === "light" ? "light" : "dark"; } catch (e) {} - - + +
diff --git a/apps/backend/internal/widget/adguard.go b/apps/backend/internal/widget/adguard.go index 1d0a795..6c319c8 100644 --- a/apps/backend/internal/widget/adguard.go +++ b/apps/backend/internal/widget/adguard.go @@ -11,7 +11,7 @@ import ( // AdGuard Home REST API: GET /control/stats with HTTP basic auth. type adguard struct{} -func init() { register(adguard{}) } +func init() { Register(adguard{}) } func (adguard) Meta() Type { return Type{ diff --git a/apps/backend/internal/widget/clock.go b/apps/backend/internal/widget/clock.go index ebc2646..ff69acb 100644 --- a/apps/backend/internal/widget/clock.go +++ b/apps/backend/internal/widget/clock.go @@ -10,7 +10,7 @@ import ( // it and the add-widget dialog can offer it like any other type. type clock struct{} -func init() { register(clock{}) } +func init() { Register(clock{}) } func (clock) Meta() Type { return Type{ diff --git a/apps/backend/internal/widget/immich.go b/apps/backend/internal/widget/immich.go index 5477b5a..1d3cca2 100644 --- a/apps/backend/internal/widget/immich.go +++ b/apps/backend/internal/widget/immich.go @@ -10,7 +10,7 @@ import ( // Immich REST API: GET /api/statistics with an x-api-key header. type immich struct{} -func init() { register(immich{}) } +func init() { Register(immich{}) } func (immich) Meta() Type { return Type{ diff --git a/apps/backend/internal/widget/pihole.go b/apps/backend/internal/widget/pihole.go index 42f880e..44c50cd 100644 --- a/apps/backend/internal/widget/pihole.go +++ b/apps/backend/internal/widget/pihole.go @@ -11,7 +11,7 @@ import ( // jarvis: v5 only for now — v6 (/api/...) lands when someone runs it. type pihole struct{} -func init() { register(pihole{}) } +func init() { Register(pihole{}) } func (pihole) Meta() Type { return Type{ diff --git a/apps/backend/internal/widget/widget.go b/apps/backend/internal/widget/widget.go index 58518ad..09224ea 100644 --- a/apps/backend/internal/widget/widget.go +++ b/apps/backend/internal/widget/widget.go @@ -40,7 +40,9 @@ type Fetcher interface { var registry = map[string]Fetcher{} -func register(f Fetcher) { registry[f.Meta().Type] = f } +// Register adds a fetcher — used by the api package for DB-backed widgets +// (monitor/domain/system) that can't live in this leaf package. +func Register(f Fetcher) { registry[f.Meta().Type] = f } // Get resolves a config.type to its fetcher. func Get(t string) (Fetcher, bool) { diff --git a/apps/frontend/package.json b/apps/frontend/package.json index b6b6a52..813e551 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -28,8 +28,10 @@ "lucide-react": "^0.545.0", "react": "^18.3.1", "react-dom": "^18.3.1", + "recharts": "^3.10.1", "tailwind-merge": "^3.3.1", - "tw-animate-css": "^1.4.0" + "tw-animate-css": "^1.4.0", + "wouter": "^3.11.0" }, "devDependencies": { "@tailwindcss/vite": "^4.1.13", diff --git a/apps/frontend/src/App.tsx b/apps/frontend/src/App.tsx index 3da0f7b..77005f8 100644 --- a/apps/frontend/src/App.tsx +++ b/apps/frontend/src/App.tsx @@ -1,12 +1,24 @@ import { useEffect, useState } from "react"; -import { LayoutGrid, Moon, Plus, Search, Settings, Sun } from "lucide-react"; +import { Link, Route, useLocation } from "wouter"; +import { Activity, AlertTriangle, Globe, LayoutGrid, Megaphone, Moon, Plus, Search, Server, Settings, Sun } from "lucide-react"; import { BoardProvider, useBoard } from "@/board/store"; import { Board } from "@/board/Board"; import { ServiceDialog } from "@/components/ServiceDialog"; 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 { DomainsPage } from "@/pages/DomainsPage"; +import { DomainDetailPage } from "@/pages/DomainDetailPage"; +import { SystemsPage } from "@/pages/SystemsPage"; +import { SystemDetailPage } from "@/pages/SystemDetailPage"; +import { IncidentsPage } from "@/pages/IncidentsPage"; +import { StatusPagesPage } from "@/pages/StatusPagesPage"; +import { StatusPublicPage } from "@/pages/StatusPublicPage"; import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import { t, type Key } from "@/i18n"; import type { Item } from "@/types"; export default function App() { @@ -17,8 +29,18 @@ export default function App() { ); } +const NAV: { href: string; labelKey: Key; icon: typeof LayoutGrid }[] = [ + { href: "/", labelKey: "nav.board", icon: LayoutGrid }, + { href: "/monitors", labelKey: "nav.monitors", icon: Activity }, + { href: "/domains", labelKey: "nav.domains", icon: Globe }, + { href: "/systems", labelKey: "nav.systems", icon: Server }, + { href: "/incidents", labelKey: "nav.incidents", icon: AlertTriangle }, + { href: "/status", labelKey: "nav.status", icon: Megaphone }, +]; + function Shell() { const board = useBoard(); + const [loc] = useLocation(); const [settingsOpen, setSettingsOpen] = useState(false); const [paletteOpen, setPaletteOpen] = useState(false); const [svcOpen, setSvcOpen] = useState(false); @@ -60,9 +82,27 @@ function Shell() { Dash +
- + + + + + + + + {(p) => } + + + + + + {(p) => } + + + + + + {(p) => } + + + + + + + + + {(p) => } + diff --git a/apps/frontend/src/components/CommandPalette.tsx b/apps/frontend/src/components/CommandPalette.tsx index 3418699..1ddec58 100644 --- a/apps/frontend/src/components/CommandPalette.tsx +++ b/apps/frontend/src/components/CommandPalette.tsx @@ -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, AlertTriangle, ArrowRight, Download, Globe, LayoutGrid, Megaphone, Moon, Plus, Server, Settings, Sun } from "lucide-react"; import { useBoard } from "@/board/store"; import { hostOf, IconImg } from "@/board/primitives"; import { rendererLabels, rendererList } from "@/renderers"; @@ -34,12 +35,19 @@ export function CommandPalette({ onSettings(): void; }) { const board = useBoard(); + const [, navigate] = useLocation(); const [query, setQuery] = useState(""); const [active, setActive] = useState(0); const listRef = useRef(null); const commands = useMemo(() => { 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: "nav-domains", group: "Actions", glyph: Globe, label: "Go to Domains", run: () => navigate("/domains") }, + { id: "nav-systems", group: "Actions", glyph: Server, label: "Go to Systems", run: () => navigate("/systems") }, + { id: "nav-incidents", group: "Actions", glyph: AlertTriangle, label: "Go to Incidents", run: () => navigate("/incidents") }, + { id: "nav-status", group: "Actions", glyph: Megaphone, label: "Go to Status pages", run: () => navigate("/status") }, { id: "add-service", group: "Actions", glyph: Plus, label: "Add service", hint: "new", run: onAddService }, { id: "add-widget", group: "Actions", glyph: Plus, label: "Add widget", hint: "new", run: onAddWidget }, { id: "settings", group: "Actions", glyph: Settings, label: "Open settings", run: onSettings }, @@ -73,7 +81,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 diff --git a/apps/frontend/src/components/DomainDialog.tsx b/apps/frontend/src/components/DomainDialog.tsx new file mode 100644 index 0000000..a6632e3 --- /dev/null +++ b/apps/frontend/src/components/DomainDialog.tsx @@ -0,0 +1,121 @@ +import { useEffect, useState } from "react"; +import { api } from "@/api"; +import type { Domain, DomainInput } 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"; + +// Add/edit a tracked domain. The first WHOIS/DNS/TLS lookup runs on create — +// expect a few seconds of spinner. +export function DomainDialog({ + open, + onOpenChange, + domain, + onSaved, +}: { + open: boolean; + onOpenChange: (o: boolean) => void; + domain?: Domain | undefined; + onSaved: () => void; +}) { + const editing = Boolean(domain); + const [name, setName] = useState(""); + const [intervalH, setIntervalH] = useState("24"); + const [alertDays, setAlertDays] = useState("30"); + const [autoRenew, setAutoRenew] = useState(false); + const [notes, setNotes] = useState(""); + const [busy, setBusy] = useState(false); + const [err, setErr] = useState(""); + + useEffect(() => { + if (!open) return; + setErr(""); + setName(domain?.name ?? ""); + setIntervalH(String(domain?.intervalH ?? 24)); + setAlertDays(String(domain?.alertDaysBefore ?? 30)); + setAutoRenew(domain?.autoRenew ?? false); + setNotes(domain?.notes ?? ""); + }, [open, domain]); + + async function submit() { + if (!name.trim()) return setErr("Domain is required"), undefined; + setBusy(true); + const body: DomainInput = { + name: name.trim(), + intervalH: Math.max(1, parseInt(intervalH) || 24), + alertDaysBefore: Math.max(0, parseInt(alertDays) || 30), + autoRenew, + notes, + }; + try { + if (editing && domain) { + await api.PATCH("/api/domains/{id}", { params: { path: { id: domain.id! } }, body }); + } else { + const { error } = await api.POST("/api/domains", { body }); + if (error) throw new Error("create failed"); + } + onSaved(); + onOpenChange(false); + } catch (e) { + setErr(e instanceof Error ? e.message : "save failed"); + } finally { + setBusy(false); + } + } + + return ( + + + + {editing ? "Edit domain" : "Track a domain"} + + WHOIS, DNS, TLS certificate, and host info — refreshed on your interval. + + + +
+
+ + setName(e.target.value)} + placeholder="example.com" className="font-mono text-[12px]" autoFocus /> +
+ +
+
+ + setIntervalH(e.target.value)} className="font-mono text-[12px]" /> +
+
+ + setAlertDays(e.target.value)} className="font-mono text-[12px]" /> +
+
+ + + +
+ + setNotes(e.target.value)} /> +
+ + {err ?

{err}

: null} +
+ + + + + +
+
+ ); +} diff --git a/apps/frontend/src/components/MonitorDialog.tsx b/apps/frontend/src/components/MonitorDialog.tsx new file mode 100644 index 0000000..47e9393 --- /dev/null +++ b/apps/frontend/src/components/MonitorDialog.tsx @@ -0,0 +1,244 @@ +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>({}); + 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) => + setF((s) => ({ ...s, [k]: e.target.value })); + // SAFETY: f.type only ever receives values from the TYPES select or a + // persisted monitor row, both constrained to MonType. + 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 ( + + + + {editing ? "Edit monitor" : "New monitor"} + Uptime check on a schedule. + + +
+
+
+ + +
+
+ + +
+
+ + {(type === "http" || type === "keyword" || type === "json") && ( + <> +
+
+ + +
+
+ + +
+
+ {type === "keyword" && ( +
+ + + +
+ )} + {type === "json" && ( +
+
+ + +
+
+ + +
+
+ )} + + )} + + {(type === "tcp" || type === "ping" || type === "dns") && ( +
+
+ + +
+ {type === "tcp" ? ( +
+ + +
+ ) : type === "dns" ? ( +
+ + +
+ ) :
} +
+ )} + {type === "dns" && ( +
+ + +
+ )} + + {type === "push" && ( +

+ After saving, your jobs call the shown push URL on a schedule. + If it goes quiet for 2× the interval, the monitor flips down. +

+ )} + +
+
+ + +
+
+ + +
+
+ + +
+
+ + {monitor?.pushToken ? ( +
+ + + {location.origin}/api/push/{monitor.pushToken} + +
+ ) : null} + + {err ?

{err}

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

URL or uploaded file; blank shows a letter tile.

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

{err}

: null} diff --git a/apps/frontend/src/components/SettingsDialog.tsx b/apps/frontend/src/components/SettingsDialog.tsx index c81b73c..489fe61 100644 --- a/apps/frontend/src/components/SettingsDialog.tsx +++ b/apps/frontend/src/components/SettingsDialog.tsx @@ -1,12 +1,14 @@ -import { useRef, useState } from "react"; -import { Download, Upload } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { Bell, Download, Upload } from "lucide-react"; import { useBoard } from "@/board/store"; +import { api } from "@/api"; import { rendererDescriptions, rendererLabels, rendererList } from "@/renderers"; import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogDescription, 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"; @@ -14,6 +16,23 @@ export function SettingsDialog({ open, onOpenChange }: { open: boolean; onOpenCh const board = useBoard(); const fileRef = useRef(null); const [msg, setMsg] = useState(""); + const [webhook, setWebhook] = useState(""); + const [webhookMsg, setWebhookMsg] = useState(""); + + useEffect(() => { + if (!open) return; + void api.GET("/api/settings").then(({ data }) => { + const v = data?.notify_webhook; + setWebhook(typeof v === "string" ? v : ""); + setWebhookMsg(""); + }); + }, [open]); + + async function saveWebhook() { + setWebhookMsg(""); + await api.PUT("/api/settings", { body: { notify_webhook: webhook.trim() } }); + setWebhookMsg("saved"); + } async function exportJson() { const res = await fetch("/api/export"); @@ -28,11 +47,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"); } } @@ -82,6 +112,38 @@ export function SettingsDialog({ open, onOpenChange }: { open: boolean; onOpenCh +
+ +
+ { setWebhook(e.target.value); setWebhookMsg(""); }} + placeholder="https://ntfy.sh/dash or Slack/Discord hook" + className="font-mono text-[12px]" + /> + + +
+

+ Monitor up/down flips and domain/certificate expiry post JSON here. + Works with Slack, Discord, ntfy, or any JSON receiver. +

+ {webhookMsg ?

{webhookMsg}

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

Import accepts Dash exports plus Homepage, Homarr, and Dashy configs.

{msg ?

{msg}

: null} diff --git a/apps/frontend/src/components/WidgetDialog.tsx b/apps/frontend/src/components/WidgetDialog.tsx index 99d62e5..df4ef73 100644 --- a/apps/frontend/src/components/WidgetDialog.tsx +++ b/apps/frontend/src/components/WidgetDialog.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from "react"; import { useBoard } from "@/board/store"; +import { api } from "@/api"; import type { Item } from "@/types"; import { Button } from "@/components/ui/button"; import { @@ -31,6 +32,9 @@ export function WidgetDialog({ const [name, setName] = useState(""); const [sectionId, setSectionId] = useState(""); const [values, setValues] = useState>({}); + const [monitors, setMonitors] = useState<{ id?: string; name?: string }[]>([]); + const [domains, setDomains] = useState<{ id?: string; name?: string }[]>([]); + const [systems, setSystems] = useState<{ id?: string; name?: string }[]>([]); const [busy, setBusy] = useState(false); const [err, setErr] = useState(""); @@ -42,6 +46,9 @@ export function WidgetDialog({ .then((r) => r.json() as Promise) .then(setTypes) .catch(() => setTypes([])); + void api.GET("/api/monitors").then(({ data }) => setMonitors(data ?? [])); + void api.GET("/api/domains").then(({ data }) => setDomains(data ?? [])); + void api.GET("/api/systems").then(({ data }) => setSystems(data ?? [])); const cfg = item?.config ?? null; setType(cfg && typeof cfg.type === "string" ? cfg.type : ""); @@ -141,14 +148,28 @@ export function WidgetDialog({ - setValues((v) => ({ ...v, [f.key]: e.target.value }))} - className="font-mono text-[12px]" - /> + {f.key === "monitorId" || f.key === "domainId" || f.key === "systemId" ? ( + + ) : ( + setValues((v) => ({ ...v, [f.key]: e.target.value }))} + className="font-mono text-[12px]" + /> + )} ))} diff --git a/apps/frontend/src/i18n/en.ts b/apps/frontend/src/i18n/en.ts new file mode 100644 index 0000000..49029e1 --- /dev/null +++ b/apps/frontend/src/i18n/en.ts @@ -0,0 +1,27 @@ +// English source strings. Flat keys, dot-separated namespaces. +// Add a locale by copying this file — keys must match exactly. +export const en = { + "nav.board": "Board", + "nav.monitors": "Monitors", + "nav.domains": "Domains", + "nav.systems": "Systems", + "nav.incidents": "Incidents", + "nav.status": "Status", + + "header.search": "Search (⌘K)", + "header.settings": "Settings", + "header.theme": "Toggle theme", + "header.addWidget": "Add widget", + "header.addService": "Add service", + "header.widget": "Widget", + + "common.add": "Add", + "common.edit": "Edit", + "common.delete": "Delete", + "common.cancel": "Cancel", + "common.save": "Save", + "common.test": "Test", + "common.loading": "Loading…", +} as const; + +export type Key = keyof typeof en; diff --git a/apps/frontend/src/i18n/index.ts b/apps/frontend/src/i18n/index.ts new file mode 100644 index 0000000..d50a624 --- /dev/null +++ b/apps/frontend/src/i18n/index.ts @@ -0,0 +1,13 @@ +import { en, type Key } from "@/i18n/en"; + +export type { Key } from "@/i18n/en"; + +// i18n seam: dictionaries are plain objects keyed by dotted names. `t` is a +// typed lookup that falls back to the key itself, so a missing translation +// renders the key — loud in dev, never a crash. Locales beyond `en` plug in +// here later (settings key + lazy import); nothing else changes. +const dict: Record = en; + +export function t(key: Key): string { + return dict[key] ?? key; +} diff --git a/apps/frontend/src/pages/DomainDetailPage.tsx b/apps/frontend/src/pages/DomainDetailPage.tsx new file mode 100644 index 0000000..154c186 --- /dev/null +++ b/apps/frontend/src/pages/DomainDetailPage.tsx @@ -0,0 +1,222 @@ +import { useCallback, useEffect, useState } from "react"; +import { Link } from "wouter"; +import { ArrowLeft, Pause, Play, RefreshCw, Shield, Trash2 } from "lucide-react"; +import { api } from "@/api"; +import type { DomainCheck, DomainView } from "@/types"; +import { DomainDialog } from "@/components/DomainDialog"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +function Row({ k, v, mono }: { k: string; v: React.ReactNode; mono?: boolean }) { + if (v === undefined || v === null || v === "") return null; + return ( +
+ {k} + {v} +
+ ); +} + +function Section({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+

{title}

+
{children}
+
+ ); +} + +function fmtDate(iso: string | null | undefined) { + if (!iso) return "—"; + return new Date(iso).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" }); +} + +export function DomainDetailPage({ id }: { id: string }) { + const [d, setD] = useState(null); + const [checks, setChecks] = useState([]); + const [dlgOpen, setDlgOpen] = useState(false); + const [refreshing, setRefreshing] = useState(false); + + const load = useCallback(async () => { + const [{ data: dv }, { data: ck }] = await Promise.all([ + api.GET("/api/domains/{id}", { params: { path: { id } } }), + api.GET("/api/domains/{id}/checks", { params: { path: { id } } }), + ]); + if (dv) setD(dv); + if (ck) setChecks(ck); + }, [id]); + + useEffect(() => { void load(); }, [load]); + + if (!d) return
Loading…
; + + const expiring = d.expiring || (d.daysUntilExpiry ?? Infinity) < 0; + + return ( +
+ + Domains + + +
+
+ {d.faviconUrl ? : null} +
+

{d.name}

+

+ {d.tld ? `.${d.tld}` : ""} + {d.dnssec === "signed" && dnssec} + {d.transferLock ? transfer-locked : null} + {d.privacyEnabled ? whois privacy : null} + {d.autoRenew ? auto-renew : null} +

+
+
+
+ + + + +
+
+ + {d.lookupError ? ( +

+ lookup: {d.lookupError} +

+ ) : null} + +
+
+
Domain expires
+
+ {d.daysUntilExpiry !== null && d.daysUntilExpiry !== undefined + ? (d.daysUntilExpiry < 0 ? "expired" : `${d.daysUntilExpiry}d`) + : "—"} +
+
{fmtDate(d.expiryDate)}
+
+
+
SSL expires
+
+ {d.sslDaysUntilExpiry !== null && d.sslDaysUntilExpiry !== undefined + ? (d.sslDaysUntilExpiry < 0 ? "expired" : `${d.sslDaysUntilExpiry}d`) + : "—"} +
+
{fmtDate(d.sslValidTo)}
+
+
+
Registrar
+
{d.registrarName || "—"}
+
{d.caProvider || "—"}
+
+
+
Providers
+
{d.dnsProvider || "—"}
+
{d.hostingProvider || d.emailProvider || "—"}
+
+
+ +
+
+ + + + + + + + + {d.statuses && d.statuses.length > 0 ? : null} +
+ +
+ {d.nameServers?.map((n) => )} + {d.mxRecords?.map((m) => )} + {d.ipv4?.map((a) => )} + {d.ipv6?.map((a) => )} + {d.cname ? : null} + {d.txtRecords?.slice(0, 4).map((t, i) => 60 ? t.slice(0, 60) + "…" : t} mono />)} + {d.dnsProvider ? : null} + {d.emailProvider ? : null} +
+ +
+ + + + + + + {d.sslAltNames && d.sslAltNames.length > 0 ? ( + 5 ? ` +${d.sslAltNames.length - 5}` : "")} mono /> + ) : null} + +
+ +
+ + + + + + +
+
+ + {Object.keys(d.headers ?? {}).length > 0 ? ( +
+ + Response headers ({Object.keys(d.headers ?? {}).length}) + +
+ {Object.entries(d.headers ?? {}).map(([k, v]) => ( +
+ {k} + {v} +
+ ))} +
+
+ ) : null} + +

Check history

+
+ + + {checks.map((c) => ( + + + + + + + ))} + {checks.length === 0 && ( + + )} + +
{new Date(c.checkedAt ?? "").toLocaleString()}exp {fmtDate(c.expiryDate)}ssl {fmtDate(c.sslValidTo)}{(c.ipv4 ?? []).join(", ")}
No snapshots yet.
+
+ + +
+ ); +} diff --git a/apps/frontend/src/pages/DomainsPage.tsx b/apps/frontend/src/pages/DomainsPage.tsx new file mode 100644 index 0000000..54a6dd5 --- /dev/null +++ b/apps/frontend/src/pages/DomainsPage.tsx @@ -0,0 +1,127 @@ +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 { DomainView } from "@/types"; +import { DomainDialog } from "@/components/DomainDialog"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +function daysLabel(days: number | null | undefined, warn?: boolean) { + if (days === null || days === undefined) return "—"; + const cls = warn || days < 0 ? "text-down" : days <= 30 ? "text-amber-500" : "text-text-dim"; + return {days < 0 ? "expired" : `${days}d`}; +} + +export function DomainsPage() { + const [domains, setDomains] = useState([]); + const [loaded, setLoaded] = useState(false); + const [dlgOpen, setDlgOpen] = useState(false); + const [editing, setEditing] = useState(); + const [refreshing, setRefreshing] = useState(null); + + const load = useCallback(async () => { + const { data } = await api.GET("/api/domains"); + if (data) setDomains(data); + setLoaded(true); + }, []); + + useEffect(() => { void load(); }, [load]); + + async function act(id: string | undefined, fn: () => Promise) { + if (!id) return; + await fn(); + await load(); + } + + return ( +
+
+
+

Domains

+

WHOIS, DNS, and certificate watch.

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

No domains tracked yet.

+

+ Expiry, registrar, DNS records, SSL certificates, and provider detection. +

+
+ ) : ( +
+ + + + + + + + + + + + {domains.map((d) => ( + + + + + + + + + ))} + +
DomainRegistrarExpiresSSLProviders +
+ + {d.faviconUrl ? ( + + ) : null} + {d.name} + {d.lookupError ? ( + lookup error + ) : null} + + {d.registrarName || "—"}{daysLabel(d.daysUntilExpiry, d.expiring)}{daysLabel(d.sslDaysUntilExpiry, d.sslExpiring)} + {[d.dnsProvider, d.hostingProvider].filter(Boolean).join(" · ") || "—"} + +
+ + + + +
+
+
+ )} + + +
+ ); +} diff --git a/apps/frontend/src/pages/IncidentsPage.tsx b/apps/frontend/src/pages/IncidentsPage.tsx new file mode 100644 index 0000000..7633214 --- /dev/null +++ b/apps/frontend/src/pages/IncidentsPage.tsx @@ -0,0 +1,203 @@ +import { useCallback, useEffect, useState } from "react"; +import { Plus, Trash2 } from "lucide-react"; +import { api } from "@/api"; +import type { Incident } from "@/types"; +import { Button } from "@/components/ui/button"; +import { + Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { cn } from "@/lib/utils"; + +const SEV_COLOR = { + critical: "text-down", + major: "text-down", + minor: "text-text-faint", +} satisfies Record, string>; + +type Status = NonNullable; +const NEXT: Partial> = { + open: [{ label: "Acknowledge", to: "ack" }, { label: "Resolve", to: "resolved" }], + ack: [{ label: "Resolve", to: "resolved" }], + resolved: [{ label: "Close", to: "closed" }], +}; + +export function IncidentsPage() { + const [incidents, setIncidents] = useState([]); + const [loaded, setLoaded] = useState(false); + const [showAll, setShowAll] = useState(false); + const [dlgOpen, setDlgOpen] = useState(false); + const [title, setTitle] = useState(""); + const [sev, setSev] = useState("major"); + const [msg, setMsg] = useState(""); + const [busy, setBusy] = useState(false); + const [expanded, setExpanded] = useState(null); + const [note, setNote] = useState(""); + + const load = useCallback(async () => { + const { data } = await api.GET("/api/incidents", showAll ? {} : { params: { query: { status: "open" } } }); + if (data) setIncidents(data); + setLoaded(true); + }, [showAll]); + + useEffect(() => { + void load(); + const t = setInterval(() => void load(), 30_000); + return () => clearInterval(t); + }, [load]); + + async function create() { + if (!title.trim()) return; + setBusy(true); + // The select only offers the three severities, but its value is a + // plain string — narrow at the boundary instead of asserting. + const severity = sev === "critical" || sev === "minor" ? sev : "major"; + await api.POST("/api/incidents", { + body: { title: title.trim(), severity, ...(msg.trim() ? { message: msg.trim() } : {}) }, + }); + setBusy(false); + setDlgOpen(false); + setTitle(""); setMsg(""); + void load(); + } + + async function transition(id: string | undefined, to: Status, message?: string) { + if (!id) return; + await api.PATCH("/api/incidents/{id}", { + params: { path: { id } }, + body: { status: to, ...(message?.trim() ? { message } : {}) }, + }); + await load(); + } + + async function addNote(id: string | undefined) { + if (!id || !note.trim()) return; + await api.POST("/api/incidents/{id}/updates", { params: { path: { id } }, body: { message: note.trim() } }); + setNote(""); + await load(); + } + + return ( +
+
+
+

Incidents

+

Auto-opened on monitor outages, or tracked manually.

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

No open incidents.

+
+ ) : ( +
+ {incidents.map((inc) => ( +
+
+ + {inc.severity} + + + {inc.status} + + {inc.createdAt ? new Date(inc.createdAt).toLocaleString() : ""} + + {(inc.status ? (NEXT[inc.status] ?? []) : []).map((n) => ( + + ))} + +
+ {expanded === inc.id && ( +
+
+ {(inc.updates ?? []).map((u) => ( +
+ {u.status} + {u.message} + + {u.at ? new Date(u.at).toLocaleString() : ""} + +
+ ))} + {(inc.updates ?? []).length === 0 && ( +

No updates yet.

+ )} +
+
+ setNote(e.target.value)} + placeholder="Add a note…" className="h-8 text-[12px]" + onKeyDown={(e) => { if (e.key === "Enter") void addNote(inc.id); }} /> + +
+
+ )} +
+ ))} +
+ )} + + + + + New incident + Track an outage or degradation manually. + +
+
+ + setTitle(e.target.value)} + placeholder="NAS offline" autoFocus /> +
+
+
+ + +
+
+ + setMsg(e.target.value)} placeholder="First observed…" /> +
+
+
+ + + + +
+
+
+ ); +} diff --git a/apps/frontend/src/pages/MonitorDetailPage.tsx b/apps/frontend/src/pages/MonitorDetailPage.tsx new file mode 100644 index 0000000..8053597 --- /dev/null +++ b/apps/frontend/src/pages/MonitorDetailPage.tsx @@ -0,0 +1,146 @@ +import { useCallback, useEffect, useState } from "react"; +import { Link } from "wouter"; +import { ArrowLeft, Pause, Play, RefreshCw, Trash2 } from "lucide-react"; +import { Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"; +import { api } from "@/api"; +import type { Heartbeat, MonitorView } from "@/types"; +import { MonitorDialog } from "@/components/MonitorDialog"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +export function MonitorDetailPage({ id }: { id: string }) { + const [m, setM] = useState(null); + const [hbs, setHbs] = useState([]); + const [hours, setHours] = useState(24); + const [dlgOpen, setDlgOpen] = useState(false); + + const load = useCallback(async () => { + const [{ data: mv }, { data: hb }] = await Promise.all([ + api.GET("/api/monitors/{id}", { params: { path: { id } } }), + api.GET("/api/monitors/{id}/heartbeats", { params: { path: { id }, query: { hours } } }), + ]); + if (mv) setM(mv); + if (hb) setHbs(hb); + }, [id, hours]); + + useEffect(() => { + void load(); + const t = setInterval(() => void load(), 30_000); + return () => clearInterval(t); + }, [load]); + + if (!m) return
Loading…
; + + const chart = hbs.map((h) => ({ + t: new Date(h.checkedAt ?? "").toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }), + ping: h.pingMs, + up: h.status === "up", + })); + + return ( +
+ + Monitors + + +
+
+
+ +

{m.name}

+ {m.status} +
+

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

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

Response time

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

Recent checks

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

Monitors

+

Uptime checks on a schedule.

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

No monitors yet.

+

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

+
+ ) : ( +
+ + + + + + + + + + + + + + {monitors.map((m) => ( + + + + + + + + + + + ))} + +
StatusNameTypeUptime 24hUptime 30dPingEvery +
+ + + {m.status} + + + {m.name} + {m.type} + {m.stats?.uptime24h?.toFixed(1)}% + + {m.stats?.uptime30d?.toFixed(1)}% + + {m.stats?.avgPing24h ? `${m.stats.avgPing24h}ms` : "—"} + {m.intervalS}s +
+ + + + +
+
+
+ )} + + +
+ ); +} diff --git a/apps/frontend/src/pages/StatusPagesPage.tsx b/apps/frontend/src/pages/StatusPagesPage.tsx new file mode 100644 index 0000000..db09178 --- /dev/null +++ b/apps/frontend/src/pages/StatusPagesPage.tsx @@ -0,0 +1,245 @@ +import { useCallback, useEffect, useState } from "react"; +import { Link } from "wouter"; +import { ExternalLink, Plus, Trash2 } from "lucide-react"; +import { api } from "@/api"; +import type { MaintenanceWindow, MonitorView, StatusPage } from "@/types"; +import { Button } from "@/components/ui/button"; +import { + Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { cn } from "@/lib/utils"; + +// Admin for public status pages + maintenance windows. The public view +// itself lives at /status/:slug. + +export function StatusPagesPage() { + const [pages, setPages] = useState([]); + const [windows, setWindows] = useState([]); + const [monitors, setMonitors] = useState([]); + const [loaded, setLoaded] = useState(false); + + const [pageOpen, setPageOpen] = useState(false); + const [pTitle, setPTitle] = useState(""); + const [pSlug, setPSlug] = useState(""); + const [pDesc, setPDesc] = useState(""); + const [pMonitors, setPMonitors] = useState([]); + + const [winOpen, setWinOpen] = useState(false); + const [wTitle, setWTitle] = useState(""); + const [wStart, setWStart] = useState(""); + const [wEnd, setWEnd] = useState(""); + const [busy, setBusy] = useState(false); + const [err, setErr] = useState(""); + + const load = useCallback(async () => { + const [{ data: p }, { data: w }, { data: m }] = await Promise.all([ + api.GET("/api/status-pages"), + api.GET("/api/maintenance"), + api.GET("/api/monitors"), + ]); + if (p) setPages(p); + if (w) setWindows(w); + if (m) setMonitors(m); + setLoaded(true); + }, []); + + useEffect(() => { + void load(); + const t = setInterval(() => void load(), 30_000); + return () => clearInterval(t); + }, [load]); + + async function createPage() { + if (!pTitle.trim()) return; + setBusy(true); setErr(""); + const { error } = await api.POST("/api/status-pages", { + body: { + title: pTitle.trim(), + ...(pSlug.trim() ? { slug: pSlug.trim() } : {}), + description: pDesc.trim(), + monitorIds: pMonitors, + }, + }); + setBusy(false); + if (error) { setErr("Could not create — slug may be taken."); return; } + setPageOpen(false); setPTitle(""); setPSlug(""); setPDesc(""); setPMonitors([]); + void load(); + } + + async function createWindow() { + if (!wTitle.trim() || !wStart || !wEnd) return; + setBusy(true); setErr(""); + const { error } = await api.POST("/api/maintenance", { + body: { + title: wTitle.trim(), + startsAt: new Date(wStart).toISOString(), + endsAt: new Date(wEnd).toISOString(), + }, + }); + setBusy(false); + if (error) { setErr("Invalid window — check times."); return; } + setWinOpen(false); setWTitle(""); setWStart(""); setWEnd(""); + void load(); + } + + return ( +
+
+
+

Status pages

+

Public live views at /status/<slug>.

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

No status pages yet.

+
+ ) : ( +
+ {pages.map((p) => ( +
+ {p.title} + /status/{p.slug} + + + + +
+ ))} +
+ )} + +
+
+

Maintenance windows

+

Active windows mute alerts + auto-incidents and mark covered monitors.

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

No maintenance scheduled.

+
+ ) : ( +
+ {windows.map((w) => ( +
+ + {w.title} + + {new Date(w.startsAt ?? "").toLocaleString()} → {new Date(w.endsAt ?? "").toLocaleString()} + + + {w.active ? "active" : "scheduled"} + + +
+ ))} +
+ )} + + + + + New status page + Public live view over selected monitors. + +
+
+
+ + setPTitle(e.target.value)} placeholder="Homelab status" autoFocus /> +
+
+ + setPSlug(e.target.value)} placeholder="auto" className="font-mono text-[12px]" /> +
+
+
+ + setPDesc(e.target.value)} placeholder="optional" /> +
+
+ +
+ {monitors.map((m) => ( + + ))} + {monitors.length === 0 &&

No monitors yet — empty selection shows all.

} +
+

Leave all unchecked to include every active monitor.

+
+ {err ?

{err}

: null} +
+ + + + +
+
+ + + + + Schedule maintenance + Suppresses alerts and auto-incidents for the window. + +
+
+ + setWTitle(e.target.value)} placeholder="Network upgrade" autoFocus /> +
+
+
+ + setWStart(e.target.value)} /> +
+
+ + setWEnd(e.target.value)} /> +
+
+ {err ?

{err}

: null} +
+ + + + +
+
+
+ ); +} diff --git a/apps/frontend/src/pages/StatusPublicPage.tsx b/apps/frontend/src/pages/StatusPublicPage.tsx new file mode 100644 index 0000000..6cfd762 --- /dev/null +++ b/apps/frontend/src/pages/StatusPublicPage.tsx @@ -0,0 +1,128 @@ +import { useCallback, useEffect, useState } from "react"; +import { api } from "@/api"; +import type { PublicStatus } from "@/types"; +import { cn } from "@/lib/utils"; + +// Public status page — clean read-only view; refreshes every 30s. +// Route: /status/:slug -> GET /api/status-pages/:slug/public. + +// status strings arrive as plain `string` — map with functions, not +// open dictionaries, so the lookup type stays honest. +function overallBanner(overall?: string) { + if (overall === "down") return { label: "Major outage", cls: "bg-down" }; + if (overall === "maintenance") return { label: "Under maintenance", cls: "bg-text-faint/60" }; + if (overall === "up") return { label: "All systems operational", cls: "bg-up" }; + return { label: "Checking…", cls: "bg-text-faint/40" }; +} + +function dotCls(status?: string) { + if (status === "up") return "bg-up"; + if (status === "down") return "bg-down"; + if (status === "maintenance") return "bg-text-faint/60"; + return "bg-text-faint/40"; +} + +export function StatusPublicPage({ slug }: { slug: string }) { + const [st, setSt] = useState(null); + const [missing, setMissing] = useState(false); + + const load = useCallback(async () => { + const { data, response } = await api.GET("/api/status-pages/{slug}/public", { params: { path: { slug } } }); + if (response.status === 404) setMissing(true); + if (data) setSt(data); + }, [slug]); + + useEffect(() => { + void load(); + const t = setInterval(() => void load(), 30_000); + return () => clearInterval(t); + }, [load]); + + if (missing) return
Status page not found.
; + if (!st) return
Loading…
; + + const banner = overallBanner(st.overall); + + return ( +
+
+

{st.title}

+ {st.description ?

{st.description}

: null} +
+ +
+ {banner.label} +
+ + {(st.monitors ?? []).length > 0 && ( +
+

Services

+
+ {(st.monitors ?? []).map((m) => ( +
+ + {m.name} + {m.uptime24h?.toFixed(1)}% + {m.status} +
+ ))} +
+
+ )} + + {(st.systems ?? []).length > 0 && ( +
+

Systems

+
+ {(st.systems ?? []).map((s) => ( +
+ + {s.name} + {s.status} +
+ ))} +
+
+ )} + + {(st.maintenance ?? []).length > 0 && ( +
+

Maintenance

+
+ {(st.maintenance ?? []).map((w) => ( +
+ {w.title} + + {new Date(w.startsAt ?? "").toLocaleString()} → {new Date(w.endsAt ?? "").toLocaleString()} + +
+ ))} +
+
+ )} + + {(st.incidents ?? []).length > 0 && ( +
+

Incidents

+
+ {(st.incidents ?? []).map((inc) => ( +
+
+ {inc.title} + {inc.status} +
+ {(inc.updates ?? []).slice(-3).map((u) => ( +

+ {u.status} — {u.message} +

+ ))} +
+ ))} +
+
+ )} + +

powered by dash

+
+ ); +} diff --git a/apps/frontend/src/pages/SystemDetailPage.tsx b/apps/frontend/src/pages/SystemDetailPage.tsx new file mode 100644 index 0000000..77c669a --- /dev/null +++ b/apps/frontend/src/pages/SystemDetailPage.tsx @@ -0,0 +1,220 @@ +import { useCallback, useEffect, useState } from "react"; +import { Link } from "wouter"; +import { ArrowLeft, Copy, Trash2 } from "lucide-react"; +import { Area, AreaChart, CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"; +import { api } from "@/api"; +import type { System, SystemStat } from "@/types"; +import { agentCmd, fmtBytes, fmtUptime } from "@/pages/SystemsPage"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +export function SystemDetailPage({ id }: { id: string }) { + const [sys, setSys] = useState(null); + const [stats, setStats] = useState([]); + const [hours, setHours] = useState(24); + const [notFound, setNotFound] = useState(false); + + const load = useCallback(async () => { + const [{ data: s, response }, { data: st }] = await Promise.all([ + api.GET("/api/systems/{id}", { params: { path: { id } } }), + api.GET("/api/systems/{id}/stats", { params: { path: { id }, query: { hours } } }), + ]); + if (response.status === 404) setNotFound(true); + if (s) setSys(s); + if (st) setStats(st); + }, [id, hours]); + + useEffect(() => { + void load(); + const t = setInterval(() => void load(), 15_000); + return () => clearInterval(t); + }, [load]); + + if (notFound) return ( +
+ Systems +

System not found.

+
+ ); + if (!sys) return
Loading…
; + + const l = sys.latest; + const chart = stats.map((s) => ({ + t: new Date(s.ts ?? "").toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }), + cpu: s.cpu, + memPct: s.memTotal ? ((s.memUsed ?? 0) / s.memTotal) * 100 : undefined, + rx: (s.netRx ?? 0) / 1024, + tx: (s.netTx ?? 0) / 1024, + load: s.load1, + })); + const axisTick = { fontSize: 10, fill: "var(--text-faint)" }; + const tipStyle = { background: "var(--surface)", border: "1px solid var(--border)", borderRadius: 8, fontSize: 11 }; + + return ( +
+ + Systems + + +
+
+
+ +

{sys.name}

+ {sys.status} +
+

+ {sys.host ? `${sys.host} · ` : ""}{sys.os}/{sys.arch} + {sys.cpuModel ? ` · ${sys.cpuModel}` : ""}{sys.cores ? ` · ${sys.cores} cores` : ""} + {sys.lastSeenAt ? ` · seen ${new Date(sys.lastSeenAt).toLocaleTimeString()}` : " · never seen"} +

+
+
+ +
+
+ +
+ {[ + ["CPU", l?.cpu !== undefined ? `${l.cpu.toFixed(1)}%` : "—"], + ["Memory", l?.memTotal ? `${fmtBytes(l.memUsed ?? 0)} / ${fmtBytes(l.memTotal)}` : "—"], + ["Disk /", l?.diskTotal ? `${fmtBytes(l.diskUsed ?? 0)} / ${fmtBytes(l.diskTotal)}` : "—"], + ["Uptime", l?.uptimeS ? fmtUptime(l.uptimeS) : "—"], + ].map(([k, v]) => ( +
+
{k}
+
{v}
+
+ ))} +
+ +
+

Usage

+
+ {[1, 6, 24, 168].map((h) => ( + + ))} +
+
+ +
+
+
CPU %
+ + + + + + + + + +
+
+
Memory %
+ + + + + + + + + +
+
+
Network KB/s
+ + + + + + + + + + +
+
+
Load 1m
+ + + + + + + + + +
+
+ +
+ {l?.temps && Object.keys(l.temps).length > 0 && ( +
+

+ Temperatures +

+ + + {Object.entries(l.temps).map(([k, v]) => ( + + + + + ))} + +
{k}{v.toFixed(1)}°C
+
+ )} + {l?.containers && l.containers.length > 0 && ( +
+

+ Containers +

+ + + {l.containers.map((ctr) => ( + + + + + ))} + +
{ctr.name} + + {ctr.state} + +
+
+ )} +
+ +
+
Agent command
+
+ + {agentCmd(sys)} + + +
+
+
+ ); +} diff --git a/apps/frontend/src/pages/SystemsPage.tsx b/apps/frontend/src/pages/SystemsPage.tsx new file mode 100644 index 0000000..4f1af6e --- /dev/null +++ b/apps/frontend/src/pages/SystemsPage.tsx @@ -0,0 +1,201 @@ +import { useCallback, useEffect, useState } from "react"; +import { Link } from "wouter"; +import { Copy, Plus, Trash2 } from "lucide-react"; +import { api } from "@/api"; +import type { System } from "@/types"; +import { Button } from "@/components/ui/button"; +import { + Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { cn } from "@/lib/utils"; + +const TB = 2 ** 40; +const GB = 2 ** 30; +const MB = 2 ** 20; + +export function fmtBytes(b: number) { + if (b >= TB) return `${(b / TB).toFixed(1)} TB`; + if (b >= GB) return `${(b / GB).toFixed(1)} GB`; + return `${(b / MB).toFixed(0)} MB`; +} + +export function fmtUptime(s: number) { + const d = Math.floor(s / 86400); + if (d > 0) return `${d}d ${Math.floor((s % 86400) / 3600)}h`; + const h = Math.floor(s / 3600); + if (h > 0) return `${h}h ${Math.floor((s % 3600) / 60)}m`; + return `${Math.floor(s / 60)}m`; +} + +export function Bar({ pct, warnAt = 90 }: { pct: number | undefined; warnAt?: number }) { + return ( + + warnAt ? "bg-down" : "bg-up")} + style={{ width: `${Math.min(pct ?? 0, 100)}%` }} + /> + + ); +} + +/** The one-liner agents run; shown on create and on the detail page. */ +export function agentCmd(sys: System) { + const base = location.origin; + return `DASH_URL=${base} DASH_TOKEN=${sys.token} dash-agent`; +} + +export function SystemsPage() { + const [systems, setSystems] = useState([]); + const [loaded, setLoaded] = useState(false); + const [dlgOpen, setDlgOpen] = useState(false); + const [name, setName] = useState(""); + const [busy, setBusy] = useState(false); + const [created, setCreated] = useState(null); + + const load = useCallback(async () => { + const { data } = await api.GET("/api/systems"); + if (data) setSystems(data); + setLoaded(true); + }, []); + + useEffect(() => { + void load(); + const t = setInterval(() => void load(), 15_000); + return () => clearInterval(t); + }, [load]); + + async function create() { + if (!name.trim()) return; + setBusy(true); + const { data } = await api.POST("/api/systems", { body: { name: name.trim() } }); + setBusy(false); + if (data) { + setCreated(data); + setName(""); + void load(); + } + } + + async function remove(id: string | undefined, n: string | undefined) { + if (!id || !confirm(`Delete system "${n}"?`)) return; + await api.DELETE("/api/systems/{id}", { params: { path: { id } } }); + await load(); + } + + return ( +
+
+
+

Systems

+

Servers and PCs reporting via dash-agent.

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

No systems yet.

+

+ Add one, then run dash-agent on the host with the issued token. +

+
+ ) : ( +
+ {systems.map((sys) => { + const l = sys.latest; + const memPct = l?.memTotal ? ((l.memUsed ?? 0) / l.memTotal) * 100 : undefined; + const diskPct = l?.diskTotal ? ((l.diskUsed ?? 0) / l.diskTotal) * 100 : undefined; + const rows: [string, number | undefined, string][] = [ + ["cpu", l?.cpu, l?.cpu !== undefined ? `${l.cpu.toFixed(0)}%` : "—"], + ["mem", memPct, l?.memTotal ? `${fmtBytes(l.memUsed ?? 0)} / ${fmtBytes(l.memTotal)}` : "—"], + ["disk", diskPct, l?.diskTotal ? `${fmtBytes(l.diskUsed ?? 0)} / ${fmtBytes(l.diskTotal)}` : "—"], + ]; + return ( +
+
+ + + {sys.name} + + +
+ {sys.host ? ( +

+ {sys.host} · {sys.cpuModel || `${sys.cores} cores`} + {l?.uptimeS ? ` · up ${fmtUptime(l.uptimeS)}` : ""} +

+ ) : ( +

waiting for agent…

+ )} +
+ {rows.map(([label, pct, txt]) => ( +
+ {label} + + {txt} +
+ ))} +
+
+ ); + })} +
+ )} + + + + + New system + Name the host, then run dash-agent on it with the issued token. + + {created ? ( +
+

+ Agent command for {created.name}: +

+
+ + {agentCmd(created)} + + +
+

+ The token identifies this system — keep it out of public repos. +

+
+ ) : ( +
+ + setName(e.target.value)} + placeholder="nas" autoFocus + onKeyDown={(e) => { if (e.key === "Enter") void create(); }} /> +
+ )} + + {created ? ( + + ) : ( + <> + + + + )} + +
+
+
+ ); +} diff --git a/apps/frontend/src/types.ts b/apps/frontend/src/types.ts index b07cc50..5cd968f 100644 --- a/apps/frontend/src/types.ts +++ b/apps/frontend/src/types.ts @@ -11,6 +11,25 @@ export type Status = components["schemas"]["Status"]; export type StatusMap = Record; export type ExportPayload = components["schemas"]["Export"]; +// Monitoring (merge program) — same generated-schema rule applies. +export type Monitor = components["schemas"]["Monitor"]; +export type MonitorInput = components["schemas"]["MonitorInput"]; +export type MonitorView = components["schemas"]["MonitorView"]; +export type Heartbeat = components["schemas"]["Heartbeat"]; +export type Domain = components["schemas"]["Domain"]; +export type DomainInput = components["schemas"]["DomainInput"]; +export type DomainView = components["schemas"]["DomainView"]; +export type DomainCheck = components["schemas"]["DomainCheck"]; +export type System = components["schemas"]["System"]; +export type StatSample = components["schemas"]["StatSample"]; +/** Stats rows are StatSample plus the server-side timestamp. */ +export type SystemStat = StatSample & { ts?: string }; +export type Incident = components["schemas"]["Incident"]; +export type IncidentInput = components["schemas"]["IncidentInput"]; +export type MaintenanceWindow = components["schemas"]["MaintenanceWindow"]; +export type StatusPage = components["schemas"]["StatusPage"]; +export type PublicStatus = components["schemas"]["PublicStatus"]; + export type Theme = "dark" | "light"; export type RendererName = "bento" | "cards" | "index" | "console"; diff --git a/apps/frontend/src/widgets/domain.tsx b/apps/frontend/src/widgets/domain.tsx new file mode 100644 index 0000000..23f7238 --- /dev/null +++ b/apps/frontend/src/widgets/domain.tsx @@ -0,0 +1,37 @@ +import type { WidgetData } from "@/widgets"; +import type { Item } from "@/types"; +import { cn } from "@/lib/utils"; + +// Board tile for a tracked domain: favicon, name, expiry countdown. +export function DomainWidget({ item, data, error }: { + item: Item; + data: WidgetData | undefined; + error: string | undefined; +}) { + const days = typeof data?.daysUntilExpiry === "number" ? data.daysUntilExpiry : undefined; + const sslDays = typeof data?.sslDaysUntilExpiry === "number" ? data.sslDaysUntilExpiry : undefined; + const expiring = data?.expiring === true; + const domainId = typeof item.config?.domainId === "string" ? item.config.domainId : ""; + + return ( + e.stopPropagation()} + > + {typeof data?.faviconUrl === "string" && data.faviconUrl ? ( + + ) : null} + + {typeof data?.name === "string" ? data.name : item.name} + + + {error ? "err" : days === undefined ? "…" : days < 0 ? "expired" : `${days}d`} + {sslDays !== undefined && sslDays <= 30 ? ` · ssl ${sslDays}d` : ""} + + + ); +} diff --git a/apps/frontend/src/widgets/index.tsx b/apps/frontend/src/widgets/index.tsx index 848bfe1..9d634f5 100644 --- a/apps/frontend/src/widgets/index.tsx +++ b/apps/frontend/src/widgets/index.tsx @@ -3,6 +3,9 @@ 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"; +import { DomainWidget } from "@/widgets/domain"; +import { SystemWidget } from "@/widgets/system"; // 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 +31,9 @@ const registry: WidgetRegistry = { pihole: { view: PiholeWidget }, adguard: { view: AdguardWidget }, immich: { view: ImmichWidget }, + monitor: { view: MonitorWidget }, + domain: { view: DomainWidget }, + system: { view: SystemWidget }, }; export function cfgStr(item: Item, key: string): string { diff --git a/apps/frontend/src/widgets/monitor.tsx b/apps/frontend/src/widgets/monitor.tsx new file mode 100644 index 0000000..674c8a3 --- /dev/null +++ b/apps/frontend/src/widgets/monitor.tsx @@ -0,0 +1,38 @@ +import type { WidgetData } from "@/widgets"; +import type { Item } from "@/types"; +import { cn } from "@/lib/utils"; + +// Board tile for an uptime monitor: status dot, uptime %, avg ping. +// config.monitorId binds it; data comes from the monitor fetcher. +export function MonitorWidget({ item, data, error }: { + item: Item; + data: WidgetData | undefined; + error: string | undefined; +}) { + const status = typeof data?.status === "string" ? data.status : undefined; + const uptime = typeof data?.uptime24 === "number" ? data.uptime24 : undefined; + const ping = typeof data?.ping === "number" ? data.ping : undefined; + const monId = typeof item.config?.monitorId === "string" ? item.config.monitorId : ""; + + return ( + e.stopPropagation()} + > + + + {typeof data?.name === "string" ? data.name : item.name} + + + {error ? "err" : uptime !== undefined ? `${uptime.toFixed(1)}%` : "…"} + {ping ? ` · ${Math.round(ping)}ms` : ""} + + + ); +} diff --git a/apps/frontend/src/widgets/system.tsx b/apps/frontend/src/widgets/system.tsx new file mode 100644 index 0000000..d852b20 --- /dev/null +++ b/apps/frontend/src/widgets/system.tsx @@ -0,0 +1,60 @@ +import type { WidgetData } from "@/widgets"; +import type { Item } from "@/types"; +import { cn } from "@/lib/utils"; + +// Board tile for a monitored system: status dot + cpu/mem/disk mini bars. +// config.systemId binds it; data comes from the system fetcher. +export function SystemWidget({ item, data, error }: { + item: Item; + data: WidgetData | undefined; + error: string | undefined; +}) { + const status = typeof data?.status === "string" ? data.status : undefined; + const sysId = typeof item.config?.systemId === "string" ? item.config.systemId : ""; + const cpu = typeof data?.cpu === "number" ? data.cpu : undefined; + const memTotal = typeof data?.memTotal === "number" ? data.memTotal : 0; + const memUsed = typeof data?.memUsed === "number" ? data.memUsed : 0; + const diskTotal = typeof data?.diskTotal === "number" ? data.diskTotal : 0; + const diskUsed = typeof data?.diskUsed === "number" ? data.diskUsed : 0; + const memPct = memTotal > 0 ? (memUsed / memTotal) * 100 : undefined; + const diskPct = diskTotal > 0 ? (diskUsed / diskTotal) * 100 : undefined; + + const bar = (pct: number | undefined, label: string) => ( + + {label} + + 90 ? "bg-down" : "bg-up")} + style={{ width: `${Math.min(pct ?? 0, 100)}%` }} + /> + + + ); + + return ( + e.stopPropagation()} + > + + + {typeof data?.name === "string" ? data.name : item.name} + + {error ? ( + err + ) : ( + + {bar(cpu, "cpu")} + {bar(memPct, "mem")} + {bar(diskPct, "dsk")} + + )} + + ); +} diff --git a/docs/announcement.md b/docs/announcement.md new file mode 100644 index 0000000..00289d8 --- /dev/null +++ b/docs/announcement.md @@ -0,0 +1,57 @@ +# v1.0.0 launch post — draft + +For r/selfhosted, HN Show, and similar. Edit freely before posting. + +--- + +**Title:** Dash — a self-hosted homelab dashboard with monitoring built in. +Single Go binary, no YAML, no auth. + +**Body:** + +I built Dash because my homelab had outgrown a bookmark page: I wanted service +links, uptime checks, domain expiry, server stats, and a status page — without +running five separate tools or maintaining a YAML file. + +What it does: + +- **Board** — drag-and-drop service grid, multiple layouts, dark/light, icon + upload. Imports your existing Homepage (`services.yml`), Homarr, or Dashy + config in one click. +- **Monitors** — HTTP/TCP/ping/DNS/keyword/JSON-path checks plus push monitors + for cron jobs. Webhook alerts (Slack/Discord/ntfy). Uptime history. +- **Domains** — WHOIS/RDAP, DNS, TLS certificate expiry, provider detection. + Inspired by Domain Locker, minus the paid APIs. +- **Systems** — a ~4 MB static agent pushes CPU/mem/disk/net/temps/Docker + container states. Inspired by Beszel, push-JSON instead of SSH. +- **Ops** — incidents, maintenance windows, public status pages, SVG badges, + Prometheus `/api/metrics`. + +Deployment is one container: + +```sh +docker run -d -v ./data:/data -p 3000:3000 ghcr.io/dvorinka/dash:latest +``` + +SQLite inside, no database to babysit, no accounts. Deliberately no auth — +it expects a trusted LAN or your own proxy in front. + +Stack: Go + Gin + SQLite (single static binary serving an embedded React UI). +MIT licensed. + +Repo: https://github.com/Dvorinka/Dash +Screenshots are in the README. + +Happy to answer questions — feedback welcome, especially on which monitor +types or integrations to add next. + +--- + +**HN variant (Show HN guidelines — terse):** + +Show HN: Dash – self-hosted homelab dashboard with monitoring, one Go binary + +Dash combines a service board, uptime monitors, domain/SSL watching, system +metrics via a tiny push agent, and public status pages. Single container or +single binary, SQLite storage, no YAML, no auth (LAN-trust model). MIT. +https://github.com/Dvorinka/Dash diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..9031516 --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,71 @@ +# Configuration + +Dash is configured from the UI. The only environment the server understands: + +| Variable | Default | Purpose | +|---|---|---| +| `DASH_PORT` | `8080` (`3000` in Docker) | listen port | +| `DASH_DATA_DIR` | `./data` (`/data` in Docker) | SQLite + uploaded icons | +| `DASH_DEV` | unset | `1` = dev mode: API only, no embedded UI | + +## Data directory + +``` +data/ + dash.db # SQLite — boards, monitors, domains, systems, ops + icons/ # uploaded service icons +``` + +Back up the directory to back up everything. `/api/export` produces a JSON +snapshot; `/api/import/csv` bulk-loads monitors and domains from CSV. + +## Notifications + +**Settings → Notifications** holds one generic webhook URL +(`notify_webhook`). On monitor up/down transitions and domain events, Dash +POSTs a JSON payload with `event`, `name`, `text`, `content`, `message`, and +`at` fields — shaped so Slack, Discord, and ntfy all render it sensibly. +**Test** sends a `test` event. Delivery is async and non-fatal; failures are +logged. + +## Monitors + +HTTP(s), TCP, ping (ICMP), DNS, keyword, JSON-path, and push types. Per-monitor +interval, timeout, retries, headers/method/body, expected values, tags, and +notes. Push monitors expose `POST /api/push/:token` for cron-style jobs — the +monitor goes down if no push arrives within the expected window. + +## Domains + +WHOIS (RDAP first, TCP WHOIS fallback), DNS records, TLS certificate chain, +and provider detection run daily and on demand. Expiry and certificate +deadlines surface in the UI and through notifications. + +## Systems + +Bearer-token push ingest at `POST /api/systems/ingest`. A system is marked +offline when no sample arrives within a few intervals. Stats are pruned during +the hourly sweep. + +## Operations + +- **Incidents** — open/ack/resolve/close, with per-incident update timeline. + A monitor going down opens an incident automatically; recovery resolves it. +- **Maintenance windows** — suppress monitor alerts for the window's scope. +- **Status pages** — public, unauthenticated views at `/status/:slug` listing + selected monitors and systems with live state. +- **Badges** — `GET /api/badge/monitor/:id.svg` and + `GET /api/badge/domain/:id.svg` for embedding status shields. +- **Metrics** — `GET /api/metrics` in Prometheus exposition format. + +## Importers + +Settings → Import accepts: + +- Dash JSON export (full board restore) +- Homepage `services.yml` +- Homarr board JSON +- Dashy `conf.yml` +- CSV of monitors/domains (`/api/import/csv`) + +Foreign formats map onto sections, items, and URLs; unknown fields are dropped. diff --git a/docs/installation.md b/docs/installation.md new file mode 100644 index 0000000..c4ee3f9 --- /dev/null +++ b/docs/installation.md @@ -0,0 +1,124 @@ +# Installation + +Dash ships as a single container image or a single static binary. All state +lives in one directory: a SQLite database and uploaded icons. + +## Docker (recommended) + +```sh +docker run -d --name dash \ + -v ./data:/data \ + -p 3000:3000 \ + --restart unless-stopped \ + ghcr.io/dvorinka/dash:latest +``` + +Or compose: + +```yaml +services: + dash: + image: ghcr.io/dvorinka/dash:latest + ports: + - "3000:3000" + volumes: + - ./data:/data + restart: unless-stopped +``` + +Open http://localhost:3000. + +To build the image locally instead of pulling: + +```sh +docker compose up --build +``` + +## Binary + +Release archives contain a static `dash` binary (no CGO, no dependencies). + +```sh +tar xzf dash_linux_amd64.tar.gz +./dash # listens on :8080, stores data in ./data +``` + +Set `DASH_PORT` and `DASH_DATA_DIR` to change the defaults. + +## dash-agent (system monitoring) + +The agent collects CPU, memory, disk, network, load, temperatures, and Docker +container state, then pushes a JSON sample to Dash every few seconds. + +1. In the UI, go to **Systems → New system**, copy the generated token. +2. Download the `dash-agent` binary for the host's architecture from the + release page, or build it: + + ```sh + cd apps/backend && go build -o dash-agent ./cmd/dash-agent + ``` + +3. Run it: + + ```sh + dash-agent -url http://dash:3000 -token + ``` + +### Flags / environment + +| Flag | Env | Default | Purpose | +|---|---|---|---| +| `-url` | `DASH_URL` | — | Dash base URL | +| `-token` | `DASH_TOKEN` | — | system token from the UI | +| `-interval` | `DASH_INTERVAL` | `10` | push interval, seconds | +| `-once` | — | `false` | print one sample and exit (no push) | + +If `/var/run/docker.sock` exists, container states are included automatically. + +### systemd + +`packaging/dash-agent.service` is a ready-made unit: + +```sh +sudo install -m755 dash-agent /usr/local/bin/dash-agent +sudo install -m644 packaging/dash-agent.service /etc/systemd/system/ +sudo mkdir -p /etc/dash +printf 'DASH_URL=http://dash:3000\nDASH_TOKEN=\n' | sudo tee /etc/dash/agent.env +sudo systemctl enable --now dash-agent +``` + +### Docker (limited) + +`infra/Dockerfile.agent` builds a minimal agent image. A containerized agent +only sees its own `/proc` — mount the host's procfs and use host PID mode for +meaningful numbers: + +```sh +docker run -d --name dash-agent --pid=host \ + -v /proc:/host/proc:ro \ + -v /var/run/docker.sock:/var/run/docker.sock:ro \ + -e DASH_URL=http://dash:3000 -e DASH_TOKEN= \ + ghcr.io/dvorinka/dash-agent:latest +``` + +The systemd install above is the recommended path. + +## Reverse proxy + +Dash serves the UI and API on one port — a plain reverse proxy works: + +```nginx +location / { + proxy_pass http://127.0.0.1:3000; + proxy_set_header Host $host; +} +``` + +## Upgrading + +Pull the new image and restart, or replace the binary. Goose migrations run +automatically on boot; `data/` carries forward. + +```sh +docker pull ghcr.io/dvorinka/dash:latest && docker compose up -d +``` diff --git a/docs/screenshots/board.png b/docs/screenshots/board.png new file mode 100644 index 0000000..1fe6e03 Binary files /dev/null and b/docs/screenshots/board.png differ diff --git a/docs/screenshots/domains.png b/docs/screenshots/domains.png new file mode 100644 index 0000000..3078a83 Binary files /dev/null and b/docs/screenshots/domains.png differ diff --git a/docs/screenshots/monitors.png b/docs/screenshots/monitors.png new file mode 100644 index 0000000..9a5c5f2 Binary files /dev/null and b/docs/screenshots/monitors.png differ diff --git a/docs/screenshots/status-page.png b/docs/screenshots/status-page.png new file mode 100644 index 0000000..ef9ccad Binary files /dev/null and b/docs/screenshots/status-page.png differ diff --git a/docs/screenshots/systems.png b/docs/screenshots/systems.png new file mode 100644 index 0000000..66bb31b Binary files /dev/null and b/docs/screenshots/systems.png differ diff --git a/docs/widgets.md b/docs/widgets.md new file mode 100644 index 0000000..63787f2 --- /dev/null +++ b/docs/widgets.md @@ -0,0 +1,89 @@ +# Widget development + +A widget is two pieces: + +1. a Go **fetcher** in `apps/backend/internal/widget/` that declares config + fields and pulls live data, and +2. a React **view** in `apps/frontend/src/widgets/` that renders the payload + inside a tile. + +Renderers own the tile chrome — widgets never touch layout, drag-drop, or +popovers. + +## Backend fetcher + +One file, registered in `init()`: + +```go +package widget + +type pihole struct{} + +func init() { Register(pihole{}) } + +func (pihole) Meta() Type { + return Type{ + Type: "pihole", + Name: "Pi-hole", + Description: "Queries and block rate", + Fields: []Field{ + {Key: "endpoint", Label: "Endpoint", Placeholder: "http://pi.hole", Required: true}, + {Key: "token", Label: "API token", Secret: true}, + }, + } +} + +func (pihole) Fetch(ctx context.Context, cfg json.RawMessage) (any, error) { + // cfg holds the item's config JSON. Never log it — it may hold keys. +} +``` + +- `Meta()` drives the add-widget dialog: `GET /api/widgets/types` returns + every registered type with its fields. +- `Fetch` is called by `GET /api/widgets/:id/data`; responses are cached ~30s + in-process. Return any JSON-marshalable value — it reaches the frontend as + `data`. +- Read config with the `cfgStr`/`cfgNum` helpers in the package. +- Fetchers needing the database (monitor, domain, system) live in + `internal/api` and register via `widget.Register` at router setup — the + `widget` package stays a leaf with no DB dependency. + +## Frontend view + +One component in `apps/frontend/src/widgets/`, one line in the registry at +`src/widgets/index.tsx`: + +```tsx +export function PiholeWidget({ data, error }: { item: Item; data?: WidgetData; error?: string }) { + if (error) return ; + if (!data) return ; + return
{data.blockedPercent}%
; +} +``` + +```ts +const registry: WidgetRegistry = { + pihole: { view: PiholeWidget }, +}; +``` + +- `useWidgetData` polls `/api/widgets/:id/data` every 30s. `data` is the + fetcher's payload, `error` is a human-readable upstream failure. +- Payloads are free-form; read fields defensively (values may be absent on + first paint). +- `local: true` marks a widget as frontend-only (e.g. clock) — no fetcher, no + polling. + +## Config fields + +`Fields` in `Meta()` render inputs in the add-widget dialog. `Secret: true` +masks the input. Config is stored in `items.config` JSON, sent back to the +fetcher on every poll, and never logged. + +## Checklist + +- [ ] `apps/backend/internal/widget/.go` — fetcher + `init()` + `Meta()` +- [ ] `apps/frontend/src/widgets/.tsx` — view component +- [ ] registry entry in `src/widgets/index.tsx` +- [ ] error and empty-data states render cleanly in the tile +- [ ] upstream timeouts bounded (respect `ctx`, keep HTTP client timeouts) diff --git a/infra/Dockerfile.agent b/infra/Dockerfile.agent new file mode 100644 index 0000000..77a7e96 --- /dev/null +++ b/infra/Dockerfile.agent @@ -0,0 +1,15 @@ +# syntax=docker/dockerfile:1 + +# Minimal dash-agent image. A containerized agent only sees its own /proc — +# run with --pid=host and -v /proc:/host/proc for host metrics, or prefer the +# systemd unit in packaging/. +FROM golang:1.27-alpine AS build +WORKDIR /src +COPY apps/backend/go.mod apps/backend/go.sum ./ +RUN go mod download +COPY apps/backend ./ +RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/dash-agent ./cmd/dash-agent + +FROM gcr.io/distroless/static-debian12 +COPY --from=build /out/dash-agent /dash-agent +ENTRYPOINT ["/dash-agent"] diff --git a/openapi.yaml b/openapi.yaml index 5f4ba89..ef1fa5d 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -214,6 +214,280 @@ 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/domains: + get: + operationId: listDomains + summary: All tracked domains with expiry countdowns + responses: + "200": + description: Domains ordered by position + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/DomainView" + post: + operationId: createDomain + summary: Track a domain; runs the first WHOIS/DNS/SSL lookup inline + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/DomainInput" + responses: + "201": + description: Domain after the initial lookup + content: + application/json: + schema: + $ref: "#/components/schemas/DomainView" + "400": + $ref: "#/components/responses/BadRequest" + "409": + description: Domain already tracked + + /api/domains/{id}: + get: + operationId: getDomain + parameters: + - $ref: "#/components/parameters/id" + responses: + "200": + description: Domain detail + content: + application/json: + schema: + $ref: "#/components/schemas/DomainView" + "404": + $ref: "#/components/responses/NotFound" + patch: + operationId: updateDomain + summary: Sparse update — settings fields only; lookup data is refreshed separately + parameters: + - $ref: "#/components/parameters/id" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/DomainInput" + responses: + "200": + description: Updated domain + content: + application/json: + schema: + $ref: "#/components/schemas/DomainView" + "404": + $ref: "#/components/responses/NotFound" + delete: + operationId: deleteDomain + parameters: + - $ref: "#/components/parameters/id" + responses: + "204": + description: Domain and its check history deleted + "404": + $ref: "#/components/responses/NotFound" + + /api/domains/{id}/refresh: + post: + operationId: refreshDomain + summary: Re-run WHOIS/RDAP + DNS + TLS + geo lookup now + parameters: + - $ref: "#/components/parameters/id" + responses: + "200": + description: Domain after the refresh + content: + application/json: + schema: + $ref: "#/components/schemas/DomainView" + "404": + $ref: "#/components/responses/NotFound" + + /api/domains/{id}/checks: + get: + operationId: domainChecks + summary: Snapshot history for change detection + parameters: + - $ref: "#/components/parameters/id" + - name: limit + in: query + schema: + type: integer + default: 50 + maximum: 500 + responses: + "200": + description: Snapshots, newest first + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/DomainCheck" + /api/widgets/types: get: operationId: widgetTypes @@ -266,6 +540,407 @@ paths: additionalProperties: $ref: "#/components/schemas/Status" + /api/notify/test: + post: + operationId: notifyTest + summary: Send a test notification to the configured webhook + description: Requires the `notify_webhook` settings key to hold an http(s) URL. + responses: + "200": + description: Test notification dispatched + "400": + $ref: "#/components/responses/BadRequest" + + /api/systems: + get: + operationId: listSystems + responses: + "200": + description: All monitored systems with their latest sample + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/System" + post: + operationId: createSystem + summary: Register a system; the returned token authenticates agent pushes + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [name] + properties: + name: { type: string } + responses: + "201": + description: Created system + content: + application/json: + schema: + $ref: "#/components/schemas/System" + "400": + $ref: "#/components/responses/BadRequest" + + /api/systems/{id}: + parameters: + - $ref: "#/components/parameters/id" + get: + operationId: getSystem + responses: + "200": + description: System detail + content: + application/json: + schema: + $ref: "#/components/schemas/System" + "404": + $ref: "#/components/responses/NotFound" + patch: + operationId: patchSystem + summary: Rename or rotate the agent token + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + name: { type: string } + rotateToken: { type: boolean } + responses: + "200": + description: Updated system + content: + application/json: + schema: + $ref: "#/components/schemas/System" + "404": + $ref: "#/components/responses/NotFound" + delete: + operationId: deleteSystem + responses: + "204": + description: Deleted + "404": + $ref: "#/components/responses/NotFound" + + /api/systems/{id}/stats: + parameters: + - $ref: "#/components/parameters/id" + get: + operationId: systemStats + summary: Stored agent samples for charting + parameters: + - name: hours + in: query + schema: { type: integer, default: 24, maximum: 168 } + responses: + "200": + description: Samples oldest-to-newest + content: + application/json: + schema: + type: array + items: + allOf: + - $ref: "#/components/schemas/StatSample" + - type: object + properties: + ts: { type: string, format: date-time } + "404": + $ref: "#/components/responses/NotFound" + + /api/systems/ingest: + post: + operationId: ingestSystem + summary: "Agent push endpoint; Authorization: Bearer " + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/StatSample" + responses: + "200": + description: Sample stored + "401": + description: Missing or unknown token + "400": + $ref: "#/components/responses/BadRequest" + + /api/incidents: + get: + operationId: listIncidents + parameters: + - name: status + in: query + description: "`open` filters to open+ack" + schema: { type: string } + responses: + "200": + description: Incidents newest-first, updates embedded + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Incident" + post: + operationId: createIncident + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/IncidentInput" + responses: + "201": + description: Created incident + content: + application/json: + schema: + $ref: "#/components/schemas/Incident" + "400": + $ref: "#/components/responses/BadRequest" + + /api/incidents/{id}: + parameters: + - $ref: "#/components/parameters/id" + patch: + operationId: patchIncident + summary: Update fields; a status change appends a timeline entry + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/IncidentInput" + responses: + "200": + description: Updated incident + content: + application/json: + schema: + $ref: "#/components/schemas/Incident" + "404": + $ref: "#/components/responses/NotFound" + delete: + operationId: deleteIncident + responses: + "204": + description: Deleted + "404": + $ref: "#/components/responses/NotFound" + + /api/incidents/{id}/updates: + parameters: + - $ref: "#/components/parameters/id" + post: + operationId: addIncidentUpdate + summary: Append a timeline note; `status` also moves the incident + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/IncidentInput" + responses: + "200": + description: Incident with updated timeline + content: + application/json: + schema: + $ref: "#/components/schemas/Incident" + "404": + $ref: "#/components/responses/NotFound" + + /api/maintenance: + get: + operationId: listWindows + responses: + "200": + description: Maintenance windows newest-first + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/MaintenanceWindow" + post: + operationId: createWindow + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/MaintenanceWindowInput" + responses: + "201": + description: Created window + content: + application/json: + schema: + $ref: "#/components/schemas/MaintenanceWindow" + "400": + $ref: "#/components/responses/BadRequest" + + /api/maintenance/{id}: + parameters: + - $ref: "#/components/parameters/id" + delete: + operationId: deleteWindow + responses: + "204": + description: Deleted + "404": + $ref: "#/components/responses/NotFound" + + /api/status-pages: + get: + operationId: listStatusPages + responses: + "200": + description: All status pages + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/StatusPage" + post: + operationId: createStatusPage + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/StatusPageInput" + responses: + "201": + description: Created page + content: + application/json: + schema: + $ref: "#/components/schemas/StatusPage" + "409": + description: Slug already in use + "400": + $ref: "#/components/responses/BadRequest" + + /api/status-pages/{id}: + parameters: + - $ref: "#/components/parameters/id" + patch: + operationId: patchStatusPage + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/StatusPageInput" + responses: + "200": + description: Updated page + content: + application/json: + schema: + $ref: "#/components/schemas/StatusPage" + "404": + $ref: "#/components/responses/NotFound" + delete: + operationId: deleteStatusPage + responses: + "204": + description: Deleted + "404": + $ref: "#/components/responses/NotFound" + + /api/status-pages/{slug}/public: + parameters: + - name: slug + in: path + required: true + schema: { type: string } + get: + operationId: publicStatus + summary: Live public view for a status page + responses: + "200": + description: Title, overall state, monitors, systems, incidents, maintenance + content: + application/json: + schema: + $ref: "#/components/schemas/PublicStatus" + "404": + $ref: "#/components/responses/NotFound" + + /api/badge/{kind}/{file}: + parameters: + - name: kind + in: path + required: true + schema: { type: string, enum: [monitor, domain, system] } + - name: file + in: path + required: true + description: "Entity id, conventionally suffixed .svg" + schema: { type: string } + get: + operationId: badge + summary: Stateless SVG shield from live state + responses: + "200": + description: SVG badge + content: + image/svg+xml: {} + "404": + $ref: "#/components/responses/NotFound" + + /api/metrics: + get: + operationId: metrics + summary: Prometheus text exposition + responses: + "200": + description: text/plain metrics + content: + text/plain: {} + + /api/import/csv: + post: + operationId: importCSV + summary: "Bulk import from CSV; ?kind=monitors|domains" + parameters: + - name: kind + in: query + schema: { type: string, enum: [monitors, domains], default: monitors } + requestBody: + required: true + content: + text/csv: + schema: { type: string } + responses: + "200": + description: Import summary + content: + application/json: + schema: + type: object + properties: + created: { type: integer } + skipped: { type: integer } + errors: + type: array + items: { type: string } + "400": + $ref: "#/components/responses/BadRequest" + /api/settings: get: operationId: getSettings @@ -330,7 +1005,45 @@ 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: + securitySchemes: + bearerAuth: + type: http + scheme: bearer parameters: id: name: id @@ -518,6 +1231,539 @@ 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 + Domain: + type: object + required: [id, name, active] + properties: + id: + type: string + name: + type: string + tld: + type: string + active: + type: boolean + autoRenew: + type: boolean + alertDaysBefore: + type: integer + tags: + type: array + items: + type: string + notes: + type: string + expiryDate: + type: string + format: date-time + nullable: true + creationDate: + type: string + format: date-time + nullable: true + updatedDate: + type: string + format: date-time + nullable: true + registrarName: + type: string + registrarId: + type: string + registrarUrl: + type: string + registryDomainId: + type: string + dnssec: + type: string + statuses: + type: array + items: + type: string + privacyEnabled: + type: boolean + transferLock: + type: boolean + registrantName: + type: string + registrantOrg: + type: string + registrantCountry: + type: string + abuseEmail: + type: string + nameServers: + type: array + items: + type: string + mxRecords: + type: array + items: + type: string + txtRecords: + type: array + items: + type: string + cname: + type: string + ipv4: + type: array + items: + type: string + ipv6: + type: array + items: + type: string + sslIssuer: + type: string + sslValidFrom: + type: string + format: date-time + nullable: true + sslValidTo: + type: string + format: date-time + nullable: true + sslSubject: + type: string + sslFingerprint: + type: string + sslKeySize: + type: integer + sslSigAlgo: + type: string + sslAltNames: + type: array + items: + type: string + hostCountry: + type: string + hostCountryCode: + type: string + hostRegion: + type: string + hostCity: + type: string + hostIsp: + type: string + hostOrg: + type: string + hostAs: + type: string + hostLat: + type: number + hostLon: + type: number + dnsProvider: + type: string + emailProvider: + type: string + hostingProvider: + type: string + caProvider: + type: string + headers: + type: object + additionalProperties: + type: string + faviconUrl: + type: string + lookupError: + type: string + intervalH: + type: integer + lastChecked: + type: string + format: date-time + nullable: true + position: + type: number + createdAt: + type: string + format: date-time + DomainInput: + type: object + properties: + name: + type: string + active: + type: boolean + autoRenew: + type: boolean + alertDaysBefore: + type: integer + intervalH: + type: integer + tags: + type: array + items: + type: string + notes: + type: string + DomainView: + allOf: + - $ref: "#/components/schemas/Domain" + - type: object + properties: + daysUntilExpiry: + type: integer + nullable: true + sslDaysUntilExpiry: + type: integer + nullable: true + expiring: + type: boolean + sslExpiring: + type: boolean + DomainCheck: + type: object + properties: + id: + type: integer + expiryDate: + type: string + format: date-time + nullable: true + sslValidTo: + type: string + format: date-time + nullable: true + ipv4: + type: array + items: + type: string + nameServers: + type: array + items: + type: string + checkedAt: + type: string + format: date-time + StatSample: + type: object + description: One agent push; missing fields mean the platform can't provide them + properties: + host: { type: string } + os: { type: string } + arch: { type: string } + cpuModel: { type: string } + cores: { type: integer } + intervalS: { type: integer } + uptimeS: { type: number } + cpu: + type: number + description: CPU usage percent 0-100 + memTotal: { type: number, description: bytes } + memUsed: { type: number, description: bytes } + swapTotal: { type: number, description: bytes } + swapUsed: { type: number, description: bytes } + diskTotal: { type: number, description: root filesystem bytes } + diskUsed: { type: number, description: bytes } + netRx: { type: number, description: bytes per second } + netTx: { type: number, description: bytes per second } + load1: { type: number } + load5: { type: number } + load15: { type: number } + temps: + type: object + additionalProperties: { type: number } + description: Sensor label -> celsius + containers: + type: array + items: + $ref: "#/components/schemas/ContainerStat" + ContainerStat: + type: object + properties: + name: { type: string } + state: { type: string } + System: + type: object + required: [id, name, token, status] + properties: + id: { type: string } + name: { type: string } + token: + type: string + description: Agent bearer token + host: { type: string } + os: { type: string } + arch: { type: string } + cpuModel: { type: string } + cores: { type: integer } + intervalS: { type: integer } + status: + type: string + enum: [pending, up, down] + lastSeenAt: + type: string + format: date-time + nullable: true + latest: + $ref: "#/components/schemas/StatSample" + createdAt: + type: string + format: date-time + IncidentUpdate: + type: object + properties: + id: { type: integer } + status: { type: string } + message: { type: string } + at: { type: string, format: date-time } + Incident: + type: object + required: [id, title, severity, status] + properties: + id: { type: string } + title: { type: string } + severity: { type: string, enum: [minor, major, critical] } + status: { type: string, enum: [open, ack, resolved, closed] } + monitorId: { type: string, nullable: true } + updates: + type: array + items: + $ref: "#/components/schemas/IncidentUpdate" + createdAt: { type: string, format: date-time } + updatedAt: { type: string, format: date-time } + IncidentInput: + type: object + properties: + title: { type: string } + severity: { type: string, enum: [minor, major, critical] } + status: { type: string, enum: [open, ack, resolved, closed] } + monitorId: { type: string } + message: + type: string + description: Timeline note attached to the create/transition + MaintenanceWindow: + type: object + required: [id, title, startsAt, endsAt] + properties: + id: { type: string } + title: { type: string } + startsAt: { type: string, format: date-time } + endsAt: { type: string, format: date-time } + monitorIds: + type: array + items: { type: string } + description: Empty array covers all monitors + active: { type: boolean } + createdAt: { type: string, format: date-time } + MaintenanceWindowInput: + type: object + required: [title, startsAt, endsAt] + properties: + title: { type: string } + startsAt: { type: string, format: date-time } + endsAt: { type: string, format: date-time } + monitorIds: + type: array + items: { type: string } + StatusPage: + type: object + required: [id, slug, title] + properties: + id: { type: string } + slug: { type: string } + title: { type: string } + description: { type: string } + monitorIds: + type: array + items: { type: string } + description: Empty array means all active monitors + systemIds: + type: array + items: { type: string } + createdAt: { type: string, format: date-time } + StatusPageInput: + type: object + required: [title] + properties: + slug: { type: string } + title: { type: string } + description: { type: string } + monitorIds: + type: array + items: { type: string } + systemIds: + type: array + items: { type: string } + PublicStatus: + type: object + properties: + title: { type: string } + slug: { type: string } + description: { type: string } + overall: { type: string, enum: [up, down, maintenance, pending] } + monitors: + type: array + items: + type: object + properties: + id: { type: string } + name: { type: string } + status: { type: string } + uptime24h: { type: number } + systems: + type: array + items: + type: object + properties: + id: { type: string } + name: { type: string } + status: { type: string } + incidents: + type: array + items: + $ref: "#/components/schemas/Incident" + maintenance: + type: array + items: + $ref: "#/components/schemas/MaintenanceWindow" WidgetField: type: object required: [key, label] diff --git a/package-lock.json b/package-lock.json index a3e80b2..02c2125 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,8 +37,10 @@ "lucide-react": "^0.545.0", "react": "^18.3.1", "react-dom": "^18.3.1", + "recharts": "^3.10.1", "tailwind-merge": "^3.3.1", - "tw-animate-css": "^1.4.0" + "tw-animate-css": "^1.4.0", + "wouter": "^3.11.0" }, "devDependencies": { "@tailwindcss/vite": "^4.1.13", @@ -1986,6 +1988,32 @@ "npm": ">=9.5.0" } }, + "node_modules/@reduxjs/toolkit": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz", + "integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-rc.3", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", @@ -2382,6 +2410,18 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@tailwindcss/node": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", @@ -2711,6 +2751,69 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -2756,6 +2859,12 @@ "@types/react": "^18.0.0" } }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@vitejs/plugin-react": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", @@ -2950,6 +3059,127 @@ "devOptional": true, "license": "MIT" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -2968,6 +3198,12 @@ } } }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -3005,6 +3241,18 @@ "node": ">=10.13.0" } }, + "node_modules/es-toolkit": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.52.0.tgz", + "integrity": "sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks", + "tests/types", + "tests/browser-compat" + ] + }, "node_modules/esbuild": { "version": "0.28.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", @@ -3057,6 +3305,12 @@ "node": ">=6" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3137,6 +3391,16 @@ "node": ">= 14" } }, + "node_modules/immer": { + "version": "11.1.18", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.18.tgz", + "integrity": "sha512-EQyQtLiYW029lyoczMl/Hh4Xu7cDecSc58JRYpHyL4tIAu3eqd1yJzQX04d2BZHDkzFFvm6qJEJWOtfDSWAXbQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/index-to-position": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz", @@ -3150,6 +3414,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/jiti": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", @@ -3782,6 +4055,36 @@ "react": "^18.3.1" } }, + "node_modules/react-is": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.3.0.tgz", + "integrity": "sha512-UpMYezM4v5/18F28aC66AEsjXIgE02kyEMH6yLdgLXu/UTfa1Ntwck/nNLrbqJsEXW7gPb0coNO9FQse9WTovA==", + "license": "MIT", + "peer": true + }, + "node_modules/react-redux": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz", + "integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, "node_modules/react-refresh": { "version": "0.18.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", @@ -3861,6 +4164,60 @@ } } }, + "node_modules/recharts": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.10.1.tgz", + "integrity": "sha512-QXFrvt6IVcw7eeZCoyXTwkIJAX3Dv1nyVhMicXJ47GsGDDpcN8z6o644DibE9XjpBTThtsomLKnTV6lc+cVFUA==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "^1.9.0 || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^11.1.8", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.2.0", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, + "node_modules/regexparam": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/regexparam/-/regexparam-3.0.0.tgz", + "integrity": "sha512-RSYAtP31mvYLkAHrOlh25pCNQ5hWnT106VukGaaFfuJrZFkGRX5GhUAdPqpSDXxOhA2c4akmRuplv1mRqnBn6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -3871,6 +4228,12 @@ "node": ">=0.10.0" } }, + "node_modules/reselect": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz", + "integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==", + "license": "MIT" + }, "node_modules/rollup": { "version": "4.63.3", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.3.tgz", @@ -3990,6 +4353,12 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -4137,6 +4506,37 @@ } } }, + "node_modules/use-sync-external-store": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.7.0.tgz", + "integrity": "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/vite": { "version": "7.3.6", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", @@ -4212,6 +4612,19 @@ } } }, + "node_modules/wouter": { + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/wouter/-/wouter-3.11.0.tgz", + "integrity": "sha512-xyMLHhytdGhIjXVRcSjeYbXd7TR1lrxVTPLGBWmmyW52ZuWVQ1jXmsgoJ4G5s4tjloJCq7h4i2eREljWUQ4+/A==", + "license": "Unlicense", + "dependencies": { + "regexparam": "^3.0.0", + "use-sync-external-store": "^1.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/packaging/dash-agent.service b/packaging/dash-agent.service new file mode 100644 index 0000000..30756df --- /dev/null +++ b/packaging/dash-agent.service @@ -0,0 +1,18 @@ +[Unit] +Description=Dash system monitoring agent +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +EnvironmentFile=/etc/dash/agent.env +ExecStart=/usr/local/bin/dash-agent +Restart=always +RestartSec=5 + +# Reads /proc, /sys, and (if present) the Docker socket. +ProtectHome=true +NoNewPrivileges=true + +[Install] +WantedBy=multi-user.target