Merge pull request #2 from Dvorinka/feat/monitoring-merge

feat: monitoring merge — importers, monitors, domains, systems, ops, launch prep
This commit is contained in:
Tomáš Dvořák authored and GitHub committed 2026-09-21 19:53:41 +02:00
commit 2c07d04972
77 files changed
+10300 -114

No files matched your search

+31
View File
@@ -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
@@ -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.
+11
View File
@@ -0,0 +1,11 @@
## Summary
<!-- What does this change and why? -->
## 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
<!-- Screenshots for UI changes. -->
+71
View File
@@ -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
+2
View File
@@ -20,6 +20,8 @@ data/*
# misc
.DS_Store
coverage/
dist/
# local tooling artifacts
.playwright-mcp/
.playwright-cli/
+61
View File
@@ -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 }}`
+87
View File
@@ -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
+38
View File
@@ -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
+60
View File
@@ -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.
+53 -5
View File
@@ -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 <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
+83 -6
View File
@@ -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.
+34
View File
@@ -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.
+362
View File
@@ -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
}
+8 -5
View File
@@ -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))
}
+2 -2
View File
@@ -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
+75 -4
View File
@@ -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
}
+581 -1
View File
@@ -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 {
+92
View File
@@ -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/<kind>/<id>.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(`<svg xmlns="http://www.w3.org/2000/svg" width="%d" height="20" role="img" aria-label="%s: %s">
<linearGradient id="s" x2="0" y2="100%%"><stop offset="0" stop-color="#fff" stop-opacity=".7"/><stop offset="1" stop-opacity=".7"/></linearGradient>
<clipPath id="r"><rect width="%d" height="20" rx="3"/></clipPath>
<g clip-path="url(#r)">
<rect width="%d" height="20" fill="#3a3f46"/>
<rect x="%d" width="%d" height="20" fill="%s"/>
<rect width="%d" height="20" fill="url(#s)" fill-opacity=".1"/>
</g>
<g fill="#fff" font-family="DejaVu Sans,Verdana,sans-serif" font-size="11">
<text x="%d" y="14">%s</text>
<text x="%d" y="14">%s</text>
</g></svg>`,
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/<kind>/<file>; 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")
}
}
+96
View File
@@ -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})
}
+517
View File
@@ -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
}
+322
View File
@@ -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 ""
}
+75
View File
@@ -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()))
}
+569
View File
@@ -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
}
+96
View File
@@ -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"})
}
+717
View File
@@ -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)
}
}
}
}
+343
View File
@@ -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 <token>.
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
}
+130 -68
View File
@@ -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
@@ -0,0 +1,45 @@
-- +goose Up
-- Uptime monitors and their check history (merge phase M1).
CREATE TABLE monitors (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
type TEXT NOT NULL CHECK (type IN ('http','tcp','ping','dns','keyword','json','push')),
url TEXT NOT NULL DEFAULT '',
hostname TEXT NOT NULL DEFAULT '',
port INTEGER NOT NULL DEFAULT 0,
method TEXT NOT NULL DEFAULT 'GET',
headers JSON NOT NULL DEFAULT '{}',
body TEXT NOT NULL DEFAULT '',
keyword TEXT NOT NULL DEFAULT '',
keyword_invert INTEGER NOT NULL DEFAULT 0,
json_query TEXT NOT NULL DEFAULT '',
expected TEXT NOT NULL DEFAULT '',
dns_type TEXT NOT NULL DEFAULT 'A',
interval_s INTEGER NOT NULL DEFAULT 60,
timeout_s INTEGER NOT NULL DEFAULT 10,
retries INTEGER NOT NULL DEFAULT 0,
active INTEGER NOT NULL DEFAULT 1,
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('up','down','pending','paused','maintenance')),
push_token TEXT NOT NULL DEFAULT '',
tags JSON NOT NULL DEFAULT '[]',
notes TEXT NOT NULL DEFAULT '',
position REAL NOT NULL DEFAULT 0,
last_check TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
CREATE INDEX monitors_due ON monitors(active, last_check);
CREATE TABLE heartbeats (
id INTEGER PRIMARY KEY AUTOINCREMENT,
monitor_id TEXT NOT NULL REFERENCES monitors(id) ON DELETE CASCADE,
status TEXT NOT NULL CHECK (status IN ('up','down')),
ping_ms INTEGER NOT NULL DEFAULT 0,
msg TEXT NOT NULL DEFAULT '',
cert_expiry INTEGER NOT NULL DEFAULT -1,
checked_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
CREATE INDEX heartbeats_monitor_time ON heartbeats(monitor_id, checked_at);
-- +goose Down
DROP TABLE heartbeats;
DROP TABLE monitors;
@@ -0,0 +1,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;
@@ -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;
@@ -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;
+404
View File
@@ -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 ""
}
+411
View File
@@ -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)
}
+315
View File
@@ -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
}
+2 -2
View File
@@ -12,8 +12,8 @@
document.documentElement.dataset.theme = t === "light" ? "light" : "dark";
} catch (e) {}
</script>
<script type="module" crossorigin src="/assets/index-LtBmkZex.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CoYED22n.css">
<script type="module" crossorigin src="/assets/index-WK6y87Wv.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CHTjdWqd.css">
</head>
<body>
<div id="root"></div>
+1 -1
View File
@@ -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{
+1 -1
View File
@@ -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{
+1 -1
View File
@@ -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{
+1 -1
View File
@@ -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{
+3 -1
View File
@@ -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) {
+3 -1
View File
@@ -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",
+76 -7
View File
@@ -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() {
</svg>
Dash
</div>
<nav className="ml-6 flex items-center gap-1" aria-label="Primary">
{NAV.map((n) => {
const active = n.href === "/" ? loc === "/" : loc.startsWith(n.href);
return (
<Link
key={n.href}
href={n.href}
className={cn(
"flex items-center gap-1.5 rounded-[7px] px-2.5 py-1.5 text-[12.5px] transition-colors",
active ? "bg-surface-hover text-text" : "text-text-faint hover:text-text",
)}
>
<n.icon size={13} strokeWidth={1.8} />
{t(n.labelKey)}
</Link>
);
})}
</nav>
<div className="ml-auto flex items-center gap-2">
<Button
variant="outline" aria-label="Search (⌘K)"
variant="outline" aria-label={t("header.search")}
onClick={() => setPaletteOpen(true)}
className="gap-1.5 text-text-faint"
>
@@ -70,32 +110,61 @@ function Shell() {
<kbd className="hidden font-mono text-[10px] min-[620px]:inline">⌘K</kbd>
</Button>
<Button
variant="outline" size="icon" aria-label="Settings"
variant="outline" size="icon" aria-label={t("header.settings")}
onClick={() => setSettingsOpen(true)}
>
<Settings size={15} strokeWidth={1.8} />
</Button>
<Button
variant="outline" size="icon" aria-label="Toggle theme"
variant="outline" size="icon" aria-label={t("header.theme")}
onClick={() => void board.setSetting("theme", board.theme === "dark" ? "light" : "dark")}
>
{board.theme === "dark" ? <Moon size={15} strokeWidth={1.8} /> : <Sun size={15} strokeWidth={1.8} />}
</Button>
<Button
variant="outline" aria-label="Add widget"
variant="outline" aria-label={t("header.addWidget")}
onClick={() => { setEditing(undefined); setWdgOpen(true); }}
>
<LayoutGrid size={13} strokeWidth={2} />
Widget
{t("header.widget")}
</Button>
<Button onClick={() => { setEditing(undefined); setSvcOpen(true); }}>
<Plus size={13} strokeWidth={2.2} />
Add service
{t("header.addService")}
</Button>
</div>
</header>
<Route path="/">
<Board onEditItem={openEditor} />
</Route>
<Route path="/monitors">
<MonitorsPage />
</Route>
<Route path="/monitors/:id">
{(p) => <MonitorDetailPage id={p.id} />}
</Route>
<Route path="/domains">
<DomainsPage />
</Route>
<Route path="/domains/:id">
{(p) => <DomainDetailPage id={p.id} />}
</Route>
<Route path="/systems">
<SystemsPage />
</Route>
<Route path="/systems/:id">
{(p) => <SystemDetailPage id={p.id} />}
</Route>
<Route path="/incidents">
<IncidentsPage />
</Route>
<Route path="/status">
<StatusPagesPage />
</Route>
<Route path="/status/:slug">
{(p) => <StatusPublicPage slug={p.slug} />}
</Route>
<ServiceDialog open={svcOpen} onOpenChange={setSvcOpen} item={editing?.kind === "service" ? editing : undefined} />
<WidgetDialog open={wdgOpen} onOpenChange={setWdgOpen} item={editing?.kind === "widget" ? editing : undefined} />
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { ArrowRight, Download, LayoutGrid, Moon, Plus, Settings, Sun } from "lucide-react";
import { useLocation } from "wouter";
import { Activity, 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<HTMLDivElement>(null);
const commands = useMemo<Command[]>(() => {
const cmds: Command[] = [
{ id: "nav-board", group: "Actions", glyph: LayoutGrid, label: "Go to Board", run: () => navigate("/") },
{ id: "nav-monitors", group: "Actions", glyph: Activity, label: "Go to Monitors", run: () => navigate("/monitors") },
{ id: "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
@@ -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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{editing ? "Edit domain" : "Track a domain"}</DialogTitle>
<DialogDescription>
WHOIS, DNS, TLS certificate, and host info — refreshed on your interval.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<Label htmlFor="dom-name">Domain</Label>
<Input id="dom-name" value={name} onChange={(e) => setName(e.target.value)}
placeholder="example.com" className="font-mono text-[12px]" autoFocus />
</div>
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor="dom-int">Refresh every (hours)</Label>
<Input id="dom-int" value={intervalH} onChange={(e) => setIntervalH(e.target.value)} className="font-mono text-[12px]" />
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="dom-alert">Alert days before expiry</Label>
<Input id="dom-alert" value={alertDays} onChange={(e) => setAlertDays(e.target.value)} className="font-mono text-[12px]" />
</div>
</div>
<label className="flex items-center gap-2 text-[12.5px] text-text-dim">
<input type="checkbox" checked={autoRenew} onChange={(e) => setAutoRenew(e.target.checked)} />
Auto-renew enabled at the registrar
</label>
<div className="flex flex-col gap-1.5">
<Label htmlFor="dom-notes">Notes (optional)</Label>
<Input id="dom-notes" value={notes} onChange={(e) => setNotes(e.target.value)} />
</div>
{err ? <p className="text-[12px] text-destructive">{err}</p> : null}
</div>
<DialogFooter className="items-center">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={busy}>
Cancel
</Button>
<Button type="button" onClick={() => void submit()} disabled={busy}>
{busy ? "Looking up…" : editing ? "Save" : "Track domain"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -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<Record<string, string>>({});
const [busy, setBusy] = useState(false);
const [err, setErr] = useState("");
useEffect(() => {
if (!open) return;
setErr("");
setF({
name: monitor?.name ?? "",
type: monitor?.type ?? "http",
url: monitor?.url ?? "",
hostname: monitor?.hostname ?? "",
port: monitor?.port ? String(monitor.port) : "",
method: monitor?.method ?? "GET",
keyword: monitor?.keyword ?? "",
keywordInvert: monitor?.keywordInvert ? "1" : "",
jsonQuery: monitor?.jsonQuery ?? "",
expected: monitor?.expected ?? "",
dnsType: monitor?.dnsType ?? "A",
intervalS: String(monitor?.intervalS ?? 60),
timeoutS: String(monitor?.timeoutS ?? 10),
retries: String(monitor?.retries ?? 0),
notes: monitor?.notes ?? "",
});
}, [open, monitor]);
const set = (k: string) => (e: React.ChangeEvent<HTMLInputElement>) =>
setF((s) => ({ ...s, [k]: e.target.value }));
// 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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{editing ? "Edit monitor" : "New monitor"}</DialogTitle>
<DialogDescription>Uptime check on a schedule.</DialogDescription>
</DialogHeader>
<div className="flex max-h-[60vh] flex-col gap-4 overflow-y-auto pr-1">
<div className="grid grid-cols-[1fr_130px] gap-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor="mon-name">Name</Label>
<Input id="mon-name" value={f.name ?? ""} onChange={set("name")} placeholder="Plex" autoFocus />
</div>
<div className="flex flex-col gap-1.5">
<Label>Type</Label>
<Select value={type} onValueChange={(v) => setF((s) => ({ ...s, type: v }))}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{TYPES.map((t) => <SelectItem key={t} value={t}>{t}</SelectItem>)}
</SelectContent>
</Select>
</div>
</div>
{(type === "http" || type === "keyword" || type === "json") && (
<>
<div className="grid grid-cols-[1fr_110px] gap-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor="mon-url">URL</Label>
<Input id="mon-url" value={f.url ?? ""} onChange={set("url")} placeholder="https://plex.local" className="font-mono text-[12px]" />
</div>
<div className="flex flex-col gap-1.5">
<Label>Method</Label>
<Select value={f.method || "GET"} onValueChange={(v) => setF((s) => ({ ...s, method: v }))}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{["GET", "HEAD", "POST"].map((m) => <SelectItem key={m} value={m}>{m}</SelectItem>)}
</SelectContent>
</Select>
</div>
</div>
{type === "keyword" && (
<div className="flex flex-col gap-1.5">
<Label htmlFor="mon-kw">Keyword</Label>
<Input id="mon-kw" value={f.keyword ?? ""} onChange={set("keyword")} placeholder="text that must appear" className="font-mono text-[12px]" />
<label className="flex items-center gap-2 text-[12px] text-text-dim">
<input type="checkbox" checked={f.keywordInvert === "1"}
onChange={(e) => setF((s) => ({ ...s, keywordInvert: e.target.checked ? "1" : "" }))} />
Invert (down when found)
</label>
</div>
)}
{type === "json" && (
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor="mon-jq">JSON path</Label>
<Input id="mon-jq" value={f.jsonQuery ?? ""} onChange={set("jsonQuery")} placeholder="data.status" className="font-mono text-[12px]" />
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="mon-exp">Expected</Label>
<Input id="mon-exp" value={f.expected ?? ""} onChange={set("expected")} placeholder="ok" className="font-mono text-[12px]" />
</div>
</div>
)}
</>
)}
{(type === "tcp" || type === "ping" || type === "dns") && (
<div className="grid grid-cols-[1fr_110px] gap-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor="mon-host">Hostname</Label>
<Input id="mon-host" value={f.hostname ?? ""} onChange={set("hostname")} placeholder="nas.local" className="font-mono text-[12px]" />
</div>
{type === "tcp" ? (
<div className="flex flex-col gap-1.5">
<Label htmlFor="mon-port">Port</Label>
<Input id="mon-port" value={f.port ?? ""} onChange={set("port")} placeholder="443" className="font-mono text-[12px]" />
</div>
) : type === "dns" ? (
<div className="flex flex-col gap-1.5">
<Label>Record</Label>
<Select value={f.dnsType || "A"} onValueChange={(v) => setF((s) => ({ ...s, dnsType: v }))}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{["A", "AAAA", "CNAME", "MX", "NS", "TXT"].map((t) => <SelectItem key={t} value={t}>{t}</SelectItem>)}
</SelectContent>
</Select>
</div>
) : <div />}
</div>
)}
{type === "dns" && (
<div className="flex flex-col gap-1.5">
<Label htmlFor="mon-dnsexp">Expected record contains (optional)</Label>
<Input id="mon-dnsexp" value={f.expected ?? ""} onChange={set("expected")} className="font-mono text-[12px]" />
</div>
)}
{type === "push" && (
<p className="rounded-[8px] border border-border bg-surface px-3 py-2 text-[12px] text-text-dim">
After saving, your jobs call the shown push URL on a schedule.
If it goes quiet for 2× the interval, the monitor flips down.
</p>
)}
<div className="grid grid-cols-3 gap-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor="mon-int">Interval (s)</Label>
<Input id="mon-int" value={f.intervalS ?? "60"} onChange={set("intervalS")} className="font-mono text-[12px]" />
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="mon-to">Timeout (s)</Label>
<Input id="mon-to" value={f.timeoutS ?? "10"} onChange={set("timeoutS")} className="font-mono text-[12px]" />
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="mon-re">Retries</Label>
<Input id="mon-re" value={f.retries ?? "0"} onChange={set("retries")} className="font-mono text-[12px]" />
</div>
</div>
{monitor?.pushToken ? (
<div className="flex flex-col gap-1.5">
<Label>Push URL</Label>
<code className="rounded-[8px] border border-border bg-surface px-3 py-2 font-mono text-[11px] text-text-dim">
{location.origin}/api/push/{monitor.pushToken}
</code>
</div>
) : null}
{err ? <p className="text-[12px] text-destructive">{err}</p> : null}
</div>
<DialogFooter className="items-center">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={busy}>
Cancel
</Button>
<Button type="button" onClick={() => void submit()} disabled={busy}>
{busy ? "Saving…" : editing ? "Save" : "Add monitor"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from "react";
import { Plus, Trash2, Upload, X } from "lucide-react";
import { useBoard } from "@/board/store";
import { api } from "@/api";
import type { Item, UrlInput } from "@/types";
import { Button } from "@/components/ui/button";
import {
@@ -33,6 +34,7 @@ export function ServiceDialog({
const [icon, setIcon] = useState("");
const [urls, setUrls] = useState<UrlInput[]>([{ url: "", label: "local" }]);
const [sectionId, setSectionId] = useState("");
const [alsoMonitor, setAlsoMonitor] = useState(false);
const [busy, setBusy] = useState(false);
const [err, setErr] = useState("");
const fileRef = useRef<HTMLInputElement>(null);
@@ -48,6 +50,7 @@ export function ServiceDialog({
: [{ url: "", label: "local" }],
);
setSectionId(item?.sectionId ?? board.sections[0]?.id ?? "");
setAlsoMonitor(false);
}, [open, item, board.sections]);
const setUrl = (i: number, patch: Partial<UrlInput>) =>
@@ -81,6 +84,12 @@ export function ServiceDialog({
urls: cleanUrls,
});
if (created && file) await board.uploadIcon(created.id, file);
if (alsoMonitor && cleanUrls[0]) {
// Uptime check on the primary URL — appears under /monitors.
await api.POST("/api/monitors", {
body: { name: name.trim(), type: "http", url: cleanUrls[0].url },
});
}
}
onOpenChange(false);
} catch (e) {
@@ -193,6 +202,17 @@ export function ServiceDialog({
<p className="text-[11px] text-text-faint">URL or uploaded file; blank shows a letter tile.</p>
</div>
{!editing && (
<label className="flex items-center gap-2 text-[12.5px] text-text-dim">
<input
type="checkbox"
checked={alsoMonitor}
onChange={(e) => setAlsoMonitor(e.target.checked)}
/>
Also create an uptime monitor for the first URL
</label>
)}
{err ? <p className="text-[12px] text-destructive">{err}</p> : null}
</div>
@@ -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<HTMLInputElement>(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
</Select>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="notify-webhook">Notification webhook</Label>
<div className="flex gap-2">
<Input
id="notify-webhook"
value={webhook}
onChange={(e) => { setWebhook(e.target.value); setWebhookMsg(""); }}
placeholder="https://ntfy.sh/dash or Slack/Discord hook"
className="font-mono text-[12px]"
/>
<Button type="button" variant="outline" size="sm" onClick={() => void saveWebhook()}>
Save
</Button>
<Button
type="button" variant="outline" size="icon" aria-label="Send test notification"
disabled={!webhook.trim()}
onClick={() => {
setWebhookMsg("");
void api.POST("/api/notify/test").then(({ error }) =>
setWebhookMsg(error ? "failed" : "sent"));
}}
>
<Bell size={12} />
</Button>
</div>
<p className="text-[11px] text-text-faint">
Monitor up/down flips and domain/certificate expiry post JSON here.
Works with Slack, Discord, ntfy, or any JSON receiver.
</p>
{webhookMsg ? <p className="text-[11px] text-text-faint">{webhookMsg}</p> : null}
</div>
<div className="flex gap-2 pt-1">
<Button type="button" variant="outline" size="sm" onClick={() => void exportJson()}>
<Download size={12} /> Export JSON
@@ -90,7 +152,7 @@ export function SettingsDialog({ open, onOpenChange }: { open: boolean; onOpenCh
<Upload size={12} /> Import
</Button>
<input
ref={fileRef} type="file" accept="application/json" className="hidden"
ref={fileRef} type="file" accept=".json,.yml,.yaml" className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) void importJson(f);
@@ -98,6 +160,7 @@ export function SettingsDialog({ open, onOpenChange }: { open: boolean; onOpenCh
}}
/>
</div>
<p className="text-[11px] text-text-faint">Import accepts Dash exports plus Homepage, Homarr, and Dashy configs.</p>
{msg ? <p className="text-[12px] text-destructive">{msg}</p> : null}
</div>
</DialogContent>
@@ -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<Record<string, string>>({});
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<WidgetType[]>)
.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,6 +148,19 @@ export function WidgetDialog({
<Label htmlFor={`wdg-${f.key}`}>
{f.label}{f.required ? "" : " (optional)"}
</Label>
{f.key === "monitorId" || f.key === "domainId" || f.key === "systemId" ? (
<Select
value={values[f.key] ?? ""}
onValueChange={(v) => setValues((s) => ({ ...s, [f.key]: v }))}
>
<SelectTrigger><SelectValue placeholder="Pick one…" /></SelectTrigger>
<SelectContent>
{(f.key === "monitorId" ? monitors : f.key === "domainId" ? domains : systems).map((m) => (
<SelectItem key={m.id} value={m.id ?? ""}>{m.name}</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
id={`wdg-${f.key}`}
type={f.secret ? "password" : "text"}
@@ -149,6 +169,7 @@ export function WidgetDialog({
onChange={(e) => setValues((v) => ({ ...v, [f.key]: e.target.value }))}
className="font-mono text-[12px]"
/>
)}
</div>
))}
+27
View File
@@ -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;
+13
View File
@@ -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<Key, string> = en;
export function t(key: Key): string {
return dict[key] ?? key;
}
@@ -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 (
<div className="flex items-baseline justify-between gap-4 border-b border-border/50 px-4 py-2 last:border-0">
<span className="shrink-0 font-mono text-[10.5px] uppercase tracking-[0.08em] text-text-faint">{k}</span>
<span className={cn("truncate text-right text-[12px] text-text", mono && "font-mono text-[11px]")}>{v}</span>
</div>
);
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="overflow-hidden rounded-[10px] border border-border bg-surface">
<h2 className="border-b border-border px-4 py-2.5 font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">{title}</h2>
<div>{children}</div>
</div>
);
}
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<DomainView | null>(null);
const [checks, setChecks] = useState<DomainCheck[]>([]);
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 <main className="mx-auto max-w-5xl px-7 py-8 text-[13px] text-text-faint">Loading…</main>;
const expiring = d.expiring || (d.daysUntilExpiry ?? Infinity) < 0;
return (
<main className="mx-auto w-full max-w-5xl px-7 py-8">
<Link href="/domains" className="mb-4 inline-flex items-center gap-1.5 text-[12px] text-text-faint hover:text-text">
<ArrowLeft size={12} /> Domains
</Link>
<div className="mb-6 flex items-start justify-between">
<div className="flex items-center gap-3">
{d.faviconUrl ? <img src={d.faviconUrl} alt="" className="size-8 rounded-[7px]" /> : null}
<div>
<h1 className="text-[17px] font-semibold tracking-tight">{d.name}</h1>
<p className="mt-0.5 flex items-center gap-2 font-mono text-[11px] text-text-faint">
{d.tld ? `.${d.tld}` : ""}
{d.dnssec === "signed" && <span className="flex items-center gap-1 text-up"><Shield size={10} /> dnssec</span>}
{d.transferLock ? <span>transfer-locked</span> : null}
{d.privacyEnabled ? <span>whois privacy</span> : null}
{d.autoRenew ? <span>auto-renew</span> : null}
</p>
</div>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" disabled={refreshing}
onClick={() => {
setRefreshing(true);
void api.POST("/api/domains/{id}/refresh", { params: { path: { id } } })
.then(load).finally(() => setRefreshing(false));
}}>
<RefreshCw size={12} className={cn(refreshing && "animate-spin")} /> Refresh
</Button>
<Button variant="outline" size="sm"
onClick={() => void api.PATCH("/api/domains/{id}", { params: { path: { id } }, body: { active: !d.active } }).then(load)}>
{d.active ? <Pause size={12} /> : <Play size={12} />} {d.active ? "Pause" : "Resume"}
</Button>
<Button variant="outline" size="sm" onClick={() => setDlgOpen(true)}>Edit</Button>
<Button variant="outline" size="sm" aria-label="Delete"
onClick={() => {
if (confirm(`Stop tracking "${d.name}"?`)) {
void api.DELETE("/api/domains/{id}", { params: { path: { id } } }).then(() => { location.href = "/domains"; });
}
}}>
<Trash2 size={12} />
</Button>
</div>
</div>
{d.lookupError ? (
<p className="mb-4 rounded-[8px] border border-down/40 bg-down/10 px-3 py-2 font-mono text-[11px] text-down">
lookup: {d.lookupError}
</p>
) : null}
<div className="mb-6 grid grid-cols-4 gap-3 max-[720px]:grid-cols-2">
<div className="rounded-[10px] border border-border bg-surface px-4 py-3">
<div className="font-mono text-[10px] uppercase tracking-[0.1em] text-text-faint">Domain expires</div>
<div className={cn("mt-1 font-mono text-[17px] font-medium", expiring && "text-down")}>
{d.daysUntilExpiry !== null && d.daysUntilExpiry !== undefined
? (d.daysUntilExpiry < 0 ? "expired" : `${d.daysUntilExpiry}d`)
: "—"}
</div>
<div className="font-mono text-[10px] text-text-faint">{fmtDate(d.expiryDate)}</div>
</div>
<div className="rounded-[10px] border border-border bg-surface px-4 py-3">
<div className="font-mono text-[10px] uppercase tracking-[0.1em] text-text-faint">SSL expires</div>
<div className={cn("mt-1 font-mono text-[17px] font-medium", d.sslExpiring && "text-down")}>
{d.sslDaysUntilExpiry !== null && d.sslDaysUntilExpiry !== undefined
? (d.sslDaysUntilExpiry < 0 ? "expired" : `${d.sslDaysUntilExpiry}d`)
: "—"}
</div>
<div className="font-mono text-[10px] text-text-faint">{fmtDate(d.sslValidTo)}</div>
</div>
<div className="rounded-[10px] border border-border bg-surface px-4 py-3">
<div className="font-mono text-[10px] uppercase tracking-[0.1em] text-text-faint">Registrar</div>
<div className="mt-1 truncate text-[13px] font-medium">{d.registrarName || "—"}</div>
<div className="font-mono text-[10px] text-text-faint">{d.caProvider || "—"}</div>
</div>
<div className="rounded-[10px] border border-border bg-surface px-4 py-3">
<div className="font-mono text-[10px] uppercase tracking-[0.1em] text-text-faint">Providers</div>
<div className="mt-1 truncate text-[13px] font-medium">{d.dnsProvider || "—"}</div>
<div className="font-mono text-[10px] text-text-faint">{d.hostingProvider || d.emailProvider || "—"}</div>
</div>
</div>
<div className="grid grid-cols-2 gap-3 max-[820px]:grid-cols-1">
<Section title="Registration">
<Row k="Created" v={fmtDate(d.creationDate)} />
<Row k="Updated" v={fmtDate(d.updatedDate)} />
<Row k="Registrar" v={d.registrarName} />
<Row k="IANA ID" v={d.registrarId} mono />
<Row k="Registry ID" v={d.registryDomainId} mono />
<Row k="Registrant" v={[d.registrantName, d.registrantOrg].filter(Boolean).join(" / ")} />
<Row k="Country" v={d.registrantCountry} />
<Row k="Abuse" v={d.abuseEmail} mono />
{d.statuses && d.statuses.length > 0 ? <Row k="Status" v={d.statuses.join(", ")} mono /> : null}
</Section>
<Section title="DNS">
{d.nameServers?.map((n) => <Row key={n} k="NS" v={n} mono />)}
{d.mxRecords?.map((m) => <Row key={m} k="MX" v={m} mono />)}
{d.ipv4?.map((a) => <Row key={a} k="A" v={a} mono />)}
{d.ipv6?.map((a) => <Row key={a} k="AAAA" v={a} mono />)}
{d.cname ? <Row k="CNAME" v={d.cname} mono /> : null}
{d.txtRecords?.slice(0, 4).map((t, i) => <Row key={i} k="TXT" v={t.length > 60 ? t.slice(0, 60) + "…" : t} mono />)}
{d.dnsProvider ? <Row k="DNS provider" v={d.dnsProvider} /> : null}
{d.emailProvider ? <Row k="Email" v={d.emailProvider} /> : null}
</Section>
<Section title="TLS certificate">
<Row k="Issuer" v={d.sslIssuer} />
<Row k="CA" v={d.caProvider} />
<Row k="Valid" v={`${fmtDate(d.sslValidFrom)} → ${fmtDate(d.sslValidTo)}`} mono />
<Row k="Subject" v={d.sslSubject} mono />
<Row k="Key" v={d.sslKeySize ? `${d.sslKeySize} bit` : ""} mono />
<Row k="Algorithm" v={d.sslSigAlgo} mono />
{d.sslAltNames && d.sslAltNames.length > 0 ? (
<Row k="SANs" v={d.sslAltNames.slice(0, 5).join(", ") + (d.sslAltNames.length > 5 ? ` +${d.sslAltNames.length - 5}` : "")} mono />
) : null}
<Row k="SHA-256" v={d.sslFingerprint ? d.sslFingerprint.slice(0, 29) + "…" : ""} mono />
</Section>
<Section title="Host">
<Row k="Location" v={[d.hostCity, d.hostRegion, d.hostCountry].filter(Boolean).join(", ")} />
<Row k="ISP" v={d.hostIsp} />
<Row k="Org" v={d.hostOrg} />
<Row k="AS" v={d.hostAs} mono />
<Row k="Hosting" v={d.hostingProvider} />
<Row k="Checked" v={d.lastChecked ? new Date(d.lastChecked).toLocaleString() : "—"} mono />
</Section>
</div>
{Object.keys(d.headers ?? {}).length > 0 ? (
<details className="mt-3 overflow-hidden rounded-[10px] border border-border bg-surface">
<summary className="cursor-pointer px-4 py-2.5 font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">
Response headers ({Object.keys(d.headers ?? {}).length})
</summary>
<div className="border-t border-border px-4 py-2">
{Object.entries(d.headers ?? {}).map(([k, v]) => (
<div key={k} className="flex gap-3 py-0.5 font-mono text-[11px]">
<span className="w-44 shrink-0 text-text-faint">{k}</span>
<span className="truncate text-text-dim">{v}</span>
</div>
))}
</div>
</details>
) : null}
<h2 className="mb-2 mt-6 font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">Check history</h2>
<div className="overflow-hidden rounded-[10px] border border-border">
<table className="w-full text-[12px]">
<tbody>
{checks.map((c) => (
<tr key={c.id} className="border-b border-border/60 last:border-0">
<td className="px-4 py-2 font-mono text-[10.5px] text-text-faint">{new Date(c.checkedAt ?? "").toLocaleString()}</td>
<td className="px-4 py-2 font-mono text-[11px] text-text-dim">exp {fmtDate(c.expiryDate)}</td>
<td className="px-4 py-2 font-mono text-[11px] text-text-dim">ssl {fmtDate(c.sslValidTo)}</td>
<td className="px-4 py-2 font-mono text-[11px] text-text-faint">{(c.ipv4 ?? []).join(", ")}</td>
</tr>
))}
{checks.length === 0 && (
<tr><td className="px-4 py-6 text-center text-text-faint">No snapshots yet.</td></tr>
)}
</tbody>
</table>
</div>
<DomainDialog open={dlgOpen} onOpenChange={setDlgOpen} domain={d} onSaved={load} />
</main>
);
}
+127
View File
@@ -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 <span className={cn("font-mono text-[11px]", cls)}>{days < 0 ? "expired" : `${days}d`}</span>;
}
export function DomainsPage() {
const [domains, setDomains] = useState<DomainView[]>([]);
const [loaded, setLoaded] = useState(false);
const [dlgOpen, setDlgOpen] = useState(false);
const [editing, setEditing] = useState<DomainView | undefined>();
const [refreshing, setRefreshing] = useState<string | null>(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<unknown>) {
if (!id) return;
await fn();
await load();
}
return (
<main className="mx-auto w-full max-w-5xl px-7 py-8">
<div className="mb-5 flex items-center justify-between">
<div>
<h1 className="text-[15px] font-semibold tracking-tight">Domains</h1>
<p className="text-[12px] text-text-faint">WHOIS, DNS, and certificate watch.</p>
</div>
<Button onClick={() => { setEditing(undefined); setDlgOpen(true); }}>
<Plus size={13} strokeWidth={2.2} /> Track domain
</Button>
</div>
{loaded && domains.length === 0 ? (
<div className="rounded-[10px] border border-dashed border-border px-6 py-14 text-center">
<p className="text-[13px] text-text-dim">No domains tracked yet.</p>
<p className="mt-1 text-[11.5px] text-text-faint">
Expiry, registrar, DNS records, SSL certificates, and provider detection.
</p>
</div>
) : (
<div className="overflow-hidden rounded-[10px] border border-border">
<table className="w-full text-[13px]">
<thead>
<tr className="border-b border-border bg-surface text-left font-mono text-[10px] uppercase tracking-[0.1em] text-text-faint">
<th className="px-4 py-2.5 font-medium">Domain</th>
<th className="px-4 py-2.5 font-medium">Registrar</th>
<th className="px-4 py-2.5 font-medium">Expires</th>
<th className="px-4 py-2.5 font-medium">SSL</th>
<th className="px-4 py-2.5 font-medium">Providers</th>
<th className="px-4 py-2.5" />
</tr>
</thead>
<tbody>
{domains.map((d) => (
<tr key={d.id} className={cn("border-b border-border/60 last:border-0 hover:bg-surface-hover/60", !d.active && "opacity-50")}>
<td className="px-4 py-3">
<span className="flex items-center gap-2.5 font-medium">
{d.faviconUrl ? (
<img src={d.faviconUrl} alt="" className="size-4 rounded-sm" loading="lazy" />
) : null}
<Link href={`/domains/${d.id}`} className="hover:underline">{d.name}</Link>
{d.lookupError ? (
<span className="font-mono text-[10px] text-down" title={d.lookupError}>lookup error</span>
) : null}
</span>
</td>
<td className="px-4 py-3 text-[11.5px] text-text-dim">{d.registrarName || "—"}</td>
<td className="px-4 py-3">{daysLabel(d.daysUntilExpiry, d.expiring)}</td>
<td className="px-4 py-3">{daysLabel(d.sslDaysUntilExpiry, d.sslExpiring)}</td>
<td className="px-4 py-3 font-mono text-[10.5px] text-text-faint">
{[d.dnsProvider, d.hostingProvider].filter(Boolean).join(" · ") || "—"}
</td>
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-1">
<Button variant="outline" size="icon" aria-label="Refresh now" disabled={refreshing === d.id}
onClick={() => {
if (!d.id) return;
setRefreshing(d.id);
void act(d.id, () => api.POST("/api/domains/{id}/refresh", { params: { path: { id: d.id } } }))
.finally(() => setRefreshing(null));
}}>
<RefreshCw size={12} className={cn(refreshing === d.id && "animate-spin")} />
</Button>
<Button variant="outline" size="icon" aria-label={d.active ? "Pause" : "Resume"}
onClick={() => void act(d.id, () => api.PATCH("/api/domains/{id}", { params: { path: { id: d.id! } }, body: { active: !d.active } }))}>
{d.active ? <Pause size={12} /> : <Play size={12} />}
</Button>
<Button variant="outline" size="icon" aria-label="Delete"
onClick={() => {
if (confirm(`Stop tracking "${d.name}"?`)) {
void act(d.id, () => api.DELETE("/api/domains/{id}", { params: { path: { id: d.id! } } }));
}
}}>
<Trash2 size={12} />
</Button>
<Button variant="outline" size="sm" onClick={() => { setEditing(d); setDlgOpen(true); }}>
Edit
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<DomainDialog open={dlgOpen} onOpenChange={setDlgOpen} domain={editing} onSaved={load} />
</main>
);
}
+203
View File
@@ -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<NonNullable<Incident["severity"]>, string>;
type Status = NonNullable<Incident["status"]>;
const NEXT: Partial<Record<Status, { label: string; to: Status }[]>> = {
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<Incident[]>([]);
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<string | null>(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 (
<main className="mx-auto w-full max-w-5xl px-7 py-8">
<div className="mb-5 flex items-center justify-between">
<div>
<h1 className="text-[15px] font-semibold tracking-tight">Incidents</h1>
<p className="text-[12px] text-text-faint">Auto-opened on monitor outages, or tracked manually.</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => setShowAll((v) => !v)}>
{showAll ? "Open only" : "Show all"}
</Button>
<Button onClick={() => setDlgOpen(true)}>
<Plus size={13} strokeWidth={2.2} /> New incident
</Button>
</div>
</div>
{loaded && incidents.length === 0 ? (
<div className="rounded-[10px] border border-dashed border-border px-6 py-14 text-center">
<p className="text-[13px] text-text-dim">No open incidents.</p>
</div>
) : (
<div className="flex flex-col gap-2.5">
{incidents.map((inc) => (
<div key={inc.id} className="rounded-[10px] border border-border bg-surface px-4 py-3">
<div className="flex items-center gap-3">
<span className={cn("font-mono text-[10px] uppercase tracking-[0.08em]",
inc.severity ? SEV_COLOR[inc.severity] : "text-text-faint")}>
{inc.severity}
</span>
<button type="button" className="min-w-0 flex-1 truncate text-left text-[13px] font-medium hover:underline"
onClick={() => setExpanded(expanded === inc.id ? null : (inc.id ?? null))}>
{inc.title}
</button>
<span className="font-mono text-[10.5px] uppercase text-text-faint">{inc.status}</span>
<span className="font-mono text-[10.5px] text-text-faint">
{inc.createdAt ? new Date(inc.createdAt).toLocaleString() : ""}
</span>
{(inc.status ? (NEXT[inc.status] ?? []) : []).map((n) => (
<Button key={n.to} variant="outline" size="sm" onClick={() => void transition(inc.id, n.to)}>
{n.label}
</Button>
))}
<Button variant="outline" size="icon" aria-label="Delete"
onClick={() => {
if (inc.id && confirm(`Delete incident "${inc.title}"?`)) {
void api.DELETE("/api/incidents/{id}", { params: { path: { id: inc.id } } }).then(load);
}
}}>
<Trash2 size={12} />
</Button>
</div>
{expanded === inc.id && (
<div className="mt-3 border-t border-border/60 pt-3">
<div className="mb-2.5 flex flex-col gap-1.5">
{(inc.updates ?? []).map((u) => (
<div key={u.id} className="flex items-baseline gap-2.5 text-[12px]">
<span className="shrink-0 font-mono text-[10px] uppercase tracking-wide text-text-faint">{u.status}</span>
<span className="flex-1 text-text-dim">{u.message}</span>
<span className="shrink-0 font-mono text-[10px] text-text-faint">
{u.at ? new Date(u.at).toLocaleString() : ""}
</span>
</div>
))}
{(inc.updates ?? []).length === 0 && (
<p className="text-[11.5px] text-text-faint">No updates yet.</p>
)}
</div>
<div className="flex gap-2">
<Input value={note} onChange={(e) => setNote(e.target.value)}
placeholder="Add a note…" className="h-8 text-[12px]"
onKeyDown={(e) => { if (e.key === "Enter") void addNote(inc.id); }} />
<Button variant="outline" size="sm" onClick={() => void addNote(inc.id)}>Post</Button>
</div>
</div>
)}
</div>
))}
</div>
)}
<Dialog open={dlgOpen} onOpenChange={setDlgOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>New incident</DialogTitle>
<DialogDescription>Track an outage or degradation manually.</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<Label htmlFor="inc-title">Title</Label>
<Input id="inc-title" value={title} onChange={(e) => setTitle(e.target.value)}
placeholder="NAS offline" autoFocus />
</div>
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5">
<Label>Severity</Label>
<Select value={sev} onValueChange={setSev}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="minor">minor</SelectItem>
<SelectItem value="major">major</SelectItem>
<SelectItem value="critical">critical</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="inc-msg">Note (optional)</Label>
<Input id="inc-msg" value={msg} onChange={(e) => setMsg(e.target.value)} placeholder="First observed…" />
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDlgOpen(false)} disabled={busy}>Cancel</Button>
<Button onClick={() => void create()} disabled={busy || !title.trim()}>
{busy ? "Creating…" : "Create"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</main>
);
}
@@ -0,0 +1,146 @@
import { useCallback, useEffect, useState } from "react";
import { Link } from "wouter";
import { ArrowLeft, Pause, Play, RefreshCw, Trash2 } from "lucide-react";
import { Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import { api } from "@/api";
import type { Heartbeat, MonitorView } from "@/types";
import { MonitorDialog } from "@/components/MonitorDialog";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
export function MonitorDetailPage({ id }: { id: string }) {
const [m, setM] = useState<MonitorView | null>(null);
const [hbs, setHbs] = useState<Heartbeat[]>([]);
const [hours, setHours] = useState(24);
const [dlgOpen, setDlgOpen] = useState(false);
const load = useCallback(async () => {
const [{ data: mv }, { data: hb }] = await Promise.all([
api.GET("/api/monitors/{id}", { params: { path: { id } } }),
api.GET("/api/monitors/{id}/heartbeats", { params: { path: { id }, query: { hours } } }),
]);
if (mv) setM(mv);
if (hb) setHbs(hb);
}, [id, hours]);
useEffect(() => {
void load();
const t = setInterval(() => void load(), 30_000);
return () => clearInterval(t);
}, [load]);
if (!m) return <main className="mx-auto max-w-5xl px-7 py-8 text-[13px] text-text-faint">Loading…</main>;
const chart = hbs.map((h) => ({
t: new Date(h.checkedAt ?? "").toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }),
ping: h.pingMs,
up: h.status === "up",
}));
return (
<main className="mx-auto w-full max-w-5xl px-7 py-8">
<Link href="/monitors" className="mb-4 inline-flex items-center gap-1.5 text-[12px] text-text-faint hover:text-text">
<ArrowLeft size={12} /> Monitors
</Link>
<div className="mb-6 flex items-start justify-between">
<div>
<div className="flex items-center gap-2.5">
<span className={cn("size-2 rounded-full", m.status === "up" ? "bg-up" : m.status === "down" ? "bg-down" : "bg-text-faint/40")} />
<h1 className="text-[17px] font-semibold tracking-tight">{m.name}</h1>
<span className="font-mono text-[10.5px] uppercase tracking-[0.08em] text-text-faint">{m.status}</span>
</div>
<p className="mt-1 font-mono text-[11.5px] text-text-faint">
{m.type} · {m.url || m.hostname}{m.port ? `:${m.port}` : ""} · every {m.intervalS}s
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => void api.POST("/api/monitors/{id}/check", { params: { path: { id } } }).then(load)}>
<RefreshCw size={12} /> Check now
</Button>
<Button variant="outline" size="sm"
onClick={() => void api.PATCH("/api/monitors/{id}", { params: { path: { id } }, body: { active: !m.active } }).then(load)}>
{m.active ? <Pause size={12} /> : <Play size={12} />} {m.active ? "Pause" : "Resume"}
</Button>
<Button variant="outline" size="sm" onClick={() => setDlgOpen(true)}>Edit</Button>
<Button variant="outline" size="sm" aria-label="Delete"
onClick={() => {
if (confirm(`Delete monitor "${m.name}"?`)) {
void api.DELETE("/api/monitors/{id}", { params: { path: { id } } }).then(() => { location.href = "/monitors"; });
}
}}>
<Trash2 size={12} />
</Button>
</div>
</div>
<div className="mb-6 grid grid-cols-4 gap-3 max-[720px]:grid-cols-2">
{[
["Uptime 24h", `${m.stats?.uptime24h?.toFixed(2)}%`],
["Uptime 30d", `${m.stats?.uptime30d?.toFixed(2)}%`],
["Avg ping 24h", m.stats?.avgPing24h ? `${m.stats.avgPing24h} ms` : "—"],
["Checks", String((m.stats?.up ?? 0) + (m.stats?.down ?? 0))],
].map(([k, v]) => (
<div key={k} className="rounded-[10px] border border-border bg-surface px-4 py-3">
<div className="font-mono text-[10px] uppercase tracking-[0.1em] text-text-faint">{k}</div>
<div className="mt-1 font-mono text-[17px] font-medium">{v}</div>
</div>
))}
</div>
<div className="mb-2 flex items-center justify-between">
<h2 className="font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">Response time</h2>
<div className="flex gap-1">
{[24, 168, 720].map((h) => (
<button key={h} type="button" onClick={() => setHours(h)}
className={cn("rounded-[6px] px-2 py-1 font-mono text-[10.5px]",
hours === h ? "bg-surface-hover text-text" : "text-text-faint hover:text-text")}>
{h === 24 ? "24h" : h === 168 ? "7d" : "30d"}
</button>
))}
</div>
</div>
<div className="mb-6 h-56 rounded-[10px] border border-border bg-surface p-3">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={chart} margin={{ top: 4, right: 4, bottom: 0, left: -18 }}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="t" tick={{ fontSize: 10, fill: "var(--text-faint)" }} tickLine={false} axisLine={false} minTickGap={40} />
<YAxis tick={{ fontSize: 10, fill: "var(--text-faint)" }} tickLine={false} axisLine={false} unit=" ms" width={62} />
<Tooltip
contentStyle={{ background: "var(--surface)", border: "1px solid var(--border)", borderRadius: 8, fontSize: 11 }}
labelStyle={{ color: "var(--text-faint)" }}
/>
<Area type="monotone" dataKey="ping" stroke="var(--up)" fill="var(--up)" fillOpacity={0.12} strokeWidth={1.5} dot={false} isAnimationActive={false} />
</AreaChart>
</ResponsiveContainer>
</div>
<h2 className="mb-2 font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">Recent checks</h2>
<div className="overflow-hidden rounded-[10px] border border-border">
<table className="w-full text-[12px]">
<tbody>
{[...hbs].reverse().slice(0, 40).map((h) => (
<tr key={h.id} className="border-b border-border/60 last:border-0">
<td className="px-4 py-2 font-mono text-[10.5px] text-text-faint">
{new Date(h.checkedAt ?? "").toLocaleString()}
</td>
<td className="px-4 py-2">
<span className={cn("font-mono text-[10.5px] uppercase", h.status === "up" ? "text-up" : "text-down")}>
{h.status}
</span>
</td>
<td className="px-4 py-2 font-mono text-[11px] text-text-dim">{h.pingMs ? `${h.pingMs}ms` : "—"}</td>
<td className="px-4 py-2 font-mono text-[11px] text-text-faint">{h.msg}</td>
</tr>
))}
{hbs.length === 0 && (
<tr><td className="px-4 py-6 text-center text-text-faint">No checks in range.</td></tr>
)}
</tbody>
</table>
</div>
<MonitorDialog open={dlgOpen} onOpenChange={setDlgOpen} monitor={m} onSaved={load} />
</main>
);
}
+135
View File
@@ -0,0 +1,135 @@
import { useCallback, useEffect, useState } from "react";
import { Link } from "wouter";
import { Pause, Play, Plus, RefreshCw, Trash2 } from "lucide-react";
import { api } from "@/api";
import type { MonitorView } from "@/types";
import { MonitorDialog } from "@/components/MonitorDialog";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
function StatusDot({ status }: { status?: string }) {
return (
<span
className={cn(
"size-1.5 shrink-0 rounded-full",
status === "up" ? "bg-up" : status === "down" ? "bg-down" : "bg-text-faint/40",
)}
/>
);
}
export function MonitorsPage() {
const [monitors, setMonitors] = useState<MonitorView[]>([]);
const [loaded, setLoaded] = useState(false);
const [dlgOpen, setDlgOpen] = useState(false);
const [editing, setEditing] = useState<MonitorView | undefined>();
const load = useCallback(async () => {
const { data } = await api.GET("/api/monitors");
if (data) setMonitors(data);
setLoaded(true);
}, []);
useEffect(() => {
void load();
const t = setInterval(() => void load(), 30_000);
return () => clearInterval(t);
}, [load]);
async function act(id: string | undefined, fn: () => Promise<unknown>) {
if (!id) return;
await fn();
await load();
}
return (
<main className="mx-auto w-full max-w-5xl px-7 py-8">
<div className="mb-5 flex items-center justify-between">
<div>
<h1 className="text-[15px] font-semibold tracking-tight">Monitors</h1>
<p className="text-[12px] text-text-faint">Uptime checks on a schedule.</p>
</div>
<Button onClick={() => { setEditing(undefined); setDlgOpen(true); }}>
<Plus size={13} strokeWidth={2.2} /> New monitor
</Button>
</div>
{loaded && monitors.length === 0 ? (
<div className="rounded-[10px] border border-dashed border-border px-6 py-14 text-center">
<p className="text-[13px] text-text-dim">No monitors yet.</p>
<p className="mt-1 text-[11.5px] text-text-faint">
HTTP, TCP, ping, DNS, keyword, JSON path, or push — checks run server-side on your interval.
</p>
</div>
) : (
<div className="overflow-hidden rounded-[10px] border border-border">
<table className="w-full text-[13px]">
<thead>
<tr className="border-b border-border bg-surface text-left font-mono text-[10px] uppercase tracking-[0.1em] text-text-faint">
<th className="px-4 py-2.5 font-medium">Status</th>
<th className="px-4 py-2.5 font-medium">Name</th>
<th className="px-4 py-2.5 font-medium">Type</th>
<th className="px-4 py-2.5 font-medium">Uptime 24h</th>
<th className="px-4 py-2.5 font-medium">Uptime 30d</th>
<th className="px-4 py-2.5 font-medium">Ping</th>
<th className="px-4 py-2.5 font-medium">Every</th>
<th className="px-4 py-2.5" />
</tr>
</thead>
<tbody>
{monitors.map((m) => (
<tr key={m.id} className="border-b border-border/60 last:border-0 hover:bg-surface-hover/60">
<td className="px-4 py-3">
<span className="flex items-center gap-2 font-mono text-[10.5px] uppercase tracking-[0.06em] text-text-faint">
<StatusDot status={m.status} />
{m.status}
</span>
</td>
<td className="px-4 py-3 font-medium">
<Link href={`/monitors/${m.id}`} className="hover:underline">{m.name}</Link>
</td>
<td className="px-4 py-3 font-mono text-[11px] text-text-dim">{m.type}</td>
<td className="px-4 py-3 font-mono text-[11px] text-text-dim">
{m.stats?.uptime24h?.toFixed(1)}%
</td>
<td className="px-4 py-3 font-mono text-[11px] text-text-dim">
{m.stats?.uptime30d?.toFixed(1)}%
</td>
<td className="px-4 py-3 font-mono text-[11px] text-text-dim">
{m.stats?.avgPing24h ? `${m.stats.avgPing24h}ms` : "—"}
</td>
<td className="px-4 py-3 font-mono text-[11px] text-text-dim">{m.intervalS}s</td>
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-1">
<Button variant="outline" size="icon" aria-label="Check now"
onClick={() => void act(m.id, () => api.POST("/api/monitors/{id}/check", { params: { path: { id: m.id! } } }))}>
<RefreshCw size={12} />
</Button>
<Button variant="outline" size="icon" aria-label={m.active ? "Pause" : "Resume"}
onClick={() => void act(m.id, () => api.PATCH("/api/monitors/{id}", { params: { path: { id: m.id! } }, body: { active: !m.active } }))}>
{m.active ? <Pause size={12} /> : <Play size={12} />}
</Button>
<Button variant="outline" size="icon" aria-label="Delete"
onClick={() => {
if (confirm(`Delete monitor "${m.name}"?`)) {
void act(m.id, () => api.DELETE("/api/monitors/{id}", { params: { path: { id: m.id! } } }));
}
}}>
<Trash2 size={12} />
</Button>
<Button variant="outline" size="sm" onClick={() => { setEditing(m); setDlgOpen(true); }}>
Edit
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<MonitorDialog open={dlgOpen} onOpenChange={setDlgOpen} monitor={editing} onSaved={load} />
</main>
);
}
+245
View File
@@ -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<StatusPage[]>([]);
const [windows, setWindows] = useState<MaintenanceWindow[]>([]);
const [monitors, setMonitors] = useState<MonitorView[]>([]);
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<string[]>([]);
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 (
<main className="mx-auto w-full max-w-5xl px-7 py-8">
<div className="mb-5 flex items-center justify-between">
<div>
<h1 className="text-[15px] font-semibold tracking-tight">Status pages</h1>
<p className="text-[12px] text-text-faint">Public live views at <code className="font-mono">/status/&lt;slug&gt;</code>.</p>
</div>
<Button onClick={() => setPageOpen(true)}>
<Plus size={13} strokeWidth={2.2} /> New page
</Button>
</div>
{loaded && pages.length === 0 ? (
<div className="mb-8 rounded-[10px] border border-dashed border-border px-6 py-10 text-center">
<p className="text-[13px] text-text-dim">No status pages yet.</p>
</div>
) : (
<div className="mb-8 flex flex-col gap-2">
{pages.map((p) => (
<div key={p.id} className="flex items-center gap-3 rounded-[10px] border border-border bg-surface px-4 py-3">
<span className="min-w-0 flex-1 truncate text-[13px] font-medium">{p.title}</span>
<code className="font-mono text-[11px] text-text-faint">/status/{p.slug}</code>
<Link href={`/status/${p.slug}`} className="shrink-0">
<Button variant="outline" size="icon" aria-label="Open public page"><ExternalLink size={12} /></Button>
</Link>
<Button variant="outline" size="icon" aria-label="Delete"
onClick={() => {
if (p.id && confirm(`Delete status page "${p.title}"?`)) {
void api.DELETE("/api/status-pages/{id}", { params: { path: { id: p.id } } }).then(load);
}
}}>
<Trash2 size={12} />
</Button>
</div>
))}
</div>
)}
<div className="mb-5 flex items-center justify-between">
<div>
<h2 className="text-[15px] font-semibold tracking-tight">Maintenance windows</h2>
<p className="text-[12px] text-text-faint">Active windows mute alerts + auto-incidents and mark covered monitors.</p>
</div>
<Button variant="outline" onClick={() => setWinOpen(true)}>
<Plus size={13} strokeWidth={2.2} /> Schedule
</Button>
</div>
{loaded && windows.length === 0 ? (
<div className="rounded-[10px] border border-dashed border-border px-6 py-10 text-center">
<p className="text-[13px] text-text-dim">No maintenance scheduled.</p>
</div>
) : (
<div className="flex flex-col gap-2">
{windows.map((w) => (
<div key={w.id} className="flex items-center gap-3 rounded-[10px] border border-border bg-surface px-4 py-3">
<span className={cn("size-1.5 shrink-0 rounded-full", w.active ? "bg-up" : "bg-text-faint/40")} />
<span className="min-w-0 flex-1 truncate text-[13px] font-medium">{w.title}</span>
<span className="font-mono text-[10.5px] text-text-faint">
{new Date(w.startsAt ?? "").toLocaleString()} → {new Date(w.endsAt ?? "").toLocaleString()}
</span>
<span className={cn("font-mono text-[10px] uppercase", w.active ? "text-up" : "text-text-faint")}>
{w.active ? "active" : "scheduled"}
</span>
<Button variant="outline" size="icon" aria-label="Delete"
onClick={() => {
if (w.id && confirm(`Delete window "${w.title}"?`)) {
void api.DELETE("/api/maintenance/{id}", { params: { path: { id: w.id } } }).then(load);
}
}}>
<Trash2 size={12} />
</Button>
</div>
))}
</div>
)}
<Dialog open={pageOpen} onOpenChange={setPageOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>New status page</DialogTitle>
<DialogDescription>Public live view over selected monitors.</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4">
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor="sp-title">Title</Label>
<Input id="sp-title" value={pTitle} onChange={(e) => setPTitle(e.target.value)} placeholder="Homelab status" autoFocus />
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="sp-slug">Slug</Label>
<Input id="sp-slug" value={pSlug} onChange={(e) => setPSlug(e.target.value)} placeholder="auto" className="font-mono text-[12px]" />
</div>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="sp-desc">Description</Label>
<Input id="sp-desc" value={pDesc} onChange={(e) => setPDesc(e.target.value)} placeholder="optional" />
</div>
<div className="flex flex-col gap-1.5">
<Label>Monitors</Label>
<div className="max-h-40 overflow-y-auto rounded-[7px] border border-border p-2">
{monitors.map((m) => (
<label key={m.id} className="flex items-center gap-2 px-1 py-1 text-[12px]">
<input type="checkbox" className="accent-current"
checked={pMonitors.includes(m.id ?? "")}
onChange={(e) => setPMonitors((v) =>
e.target.checked ? [...v, m.id ?? ""] : v.filter((x) => x !== m.id))} />
{m.name}
</label>
))}
{monitors.length === 0 && <p className="px-1 py-1 text-[11.5px] text-text-faint">No monitors yet — empty selection shows all.</p>}
</div>
<p className="text-[11px] text-text-faint">Leave all unchecked to include every active monitor.</p>
</div>
{err ? <p className="text-[12px] text-destructive">{err}</p> : null}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setPageOpen(false)} disabled={busy}>Cancel</Button>
<Button onClick={() => void createPage()} disabled={busy || !pTitle.trim()}>
{busy ? "Creating…" : "Create"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={winOpen} onOpenChange={setWinOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Schedule maintenance</DialogTitle>
<DialogDescription>Suppresses alerts and auto-incidents for the window.</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<Label htmlFor="mw-title">Title</Label>
<Input id="mw-title" value={wTitle} onChange={(e) => setWTitle(e.target.value)} placeholder="Network upgrade" autoFocus />
</div>
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor="mw-start">Starts</Label>
<Input id="mw-start" type="datetime-local" value={wStart} onChange={(e) => setWStart(e.target.value)} />
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="mw-end">Ends</Label>
<Input id="mw-end" type="datetime-local" value={wEnd} onChange={(e) => setWEnd(e.target.value)} />
</div>
</div>
{err ? <p className="text-[12px] text-destructive">{err}</p> : null}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setWinOpen(false)} disabled={busy}>Cancel</Button>
<Button onClick={() => void createWindow()} disabled={busy || !wTitle.trim() || !wStart || !wEnd}>
{busy ? "Saving…" : "Schedule"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</main>
);
}
@@ -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<PublicStatus | null>(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 <main className="mx-auto max-w-2xl px-7 py-16 text-center text-[13px] text-text-faint">Status page not found.</main>;
if (!st) return <main className="mx-auto max-w-2xl px-7 py-16 text-center text-[13px] text-text-faint">Loading…</main>;
const banner = overallBanner(st.overall);
return (
<main className="mx-auto w-full max-w-2xl px-7 py-10">
<div className="mb-6 text-center">
<h1 className="text-[17px] font-semibold tracking-tight">{st.title}</h1>
{st.description ? <p className="mt-1 text-[12px] text-text-faint">{st.description}</p> : null}
</div>
<div className={cn("mb-8 rounded-[10px] px-5 py-3.5 text-center text-[13px] font-medium text-white", banner.cls)}>
{banner.label}
</div>
{(st.monitors ?? []).length > 0 && (
<section className="mb-6">
<h2 className="mb-2 font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">Services</h2>
<div className="overflow-hidden rounded-[10px] border border-border">
{(st.monitors ?? []).map((m) => (
<div key={m.id} className="flex items-center gap-3 border-b border-border/60 px-4 py-2.5 last:border-0">
<span className={cn("size-1.5 shrink-0 rounded-full", dotCls(m.status))} />
<span className="min-w-0 flex-1 truncate text-[13px]">{m.name}</span>
<span className="font-mono text-[10.5px] text-text-faint">{m.uptime24h?.toFixed(1)}%</span>
<span className="w-20 text-right font-mono text-[10.5px] uppercase text-text-faint">{m.status}</span>
</div>
))}
</div>
</section>
)}
{(st.systems ?? []).length > 0 && (
<section className="mb-6">
<h2 className="mb-2 font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">Systems</h2>
<div className="overflow-hidden rounded-[10px] border border-border">
{(st.systems ?? []).map((s) => (
<div key={s.id} className="flex items-center gap-3 border-b border-border/60 px-4 py-2.5 last:border-0">
<span className={cn("size-1.5 shrink-0 rounded-full", dotCls(s.status))} />
<span className="min-w-0 flex-1 truncate text-[13px]">{s.name}</span>
<span className="font-mono text-[10.5px] uppercase text-text-faint">{s.status}</span>
</div>
))}
</div>
</section>
)}
{(st.maintenance ?? []).length > 0 && (
<section className="mb-6">
<h2 className="mb-2 font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">Maintenance</h2>
<div className="overflow-hidden rounded-[10px] border border-border">
{(st.maintenance ?? []).map((w) => (
<div key={w.id} className="flex items-center gap-3 border-b border-border/60 px-4 py-2.5 last:border-0">
<span className="min-w-0 flex-1 truncate text-[13px]">{w.title}</span>
<span className="font-mono text-[10.5px] text-text-faint">
{new Date(w.startsAt ?? "").toLocaleString()} → {new Date(w.endsAt ?? "").toLocaleString()}
</span>
</div>
))}
</div>
</section>
)}
{(st.incidents ?? []).length > 0 && (
<section className="mb-6">
<h2 className="mb-2 font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">Incidents</h2>
<div className="flex flex-col gap-2">
{(st.incidents ?? []).map((inc) => (
<div key={inc.id} className="rounded-[10px] border border-border px-4 py-3">
<div className="flex items-center gap-3">
<span className="min-w-0 flex-1 truncate text-[13px] font-medium">{inc.title}</span>
<span className="font-mono text-[10.5px] uppercase text-text-faint">{inc.status}</span>
</div>
{(inc.updates ?? []).slice(-3).map((u) => (
<p key={u.id} className="mt-1.5 text-[11.5px] text-text-faint">
<span className="font-mono text-[10px] uppercase">{u.status}</span> — {u.message}
</p>
))}
</div>
))}
</div>
</section>
)}
<p className="mt-10 text-center font-mono text-[10px] text-text-faint">powered by dash</p>
</main>
);
}
@@ -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<System | null>(null);
const [stats, setStats] = useState<SystemStat[]>([]);
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 (
<main className="mx-auto max-w-5xl px-7 py-8 text-[13px] text-text-faint">
<Link href="/systems" className="inline-flex items-center gap-1.5 hover:text-text"><ArrowLeft size={12} /> Systems</Link>
<p className="mt-4">System not found.</p>
</main>
);
if (!sys) return <main className="mx-auto max-w-5xl px-7 py-8 text-[13px] text-text-faint">Loading…</main>;
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 (
<main className="mx-auto w-full max-w-5xl px-7 py-8">
<Link href="/systems" className="mb-4 inline-flex items-center gap-1.5 text-[12px] text-text-faint hover:text-text">
<ArrowLeft size={12} /> Systems
</Link>
<div className="mb-6 flex items-start justify-between">
<div>
<div className="flex items-center gap-2.5">
<span className={cn("size-2 rounded-full",
sys.status === "up" ? "bg-up" : sys.status === "down" ? "bg-down" : "bg-text-faint/40")} />
<h1 className="text-[17px] font-semibold tracking-tight">{sys.name}</h1>
<span className="font-mono text-[10.5px] uppercase tracking-[0.08em] text-text-faint">{sys.status}</span>
</div>
<p className="mt-1 font-mono text-[11.5px] text-text-faint">
{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"}
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" aria-label="Delete"
onClick={() => {
if (confirm(`Delete system "${sys.name}"?`)) {
void api.DELETE("/api/systems/{id}", { params: { path: { id } } }).then(() => { location.href = "/systems"; });
}
}}>
<Trash2 size={12} />
</Button>
</div>
</div>
<div className="mb-6 grid grid-cols-4 gap-3 max-[720px]:grid-cols-2">
{[
["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]) => (
<div key={k} className="rounded-[10px] border border-border bg-surface px-4 py-3">
<div className="font-mono text-[10px] uppercase tracking-[0.1em] text-text-faint">{k}</div>
<div className="mt-1 font-mono text-[15px] font-medium">{v}</div>
</div>
))}
</div>
<div className="mb-2 flex items-center justify-between">
<h2 className="font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">Usage</h2>
<div className="flex gap-1">
{[1, 6, 24, 168].map((h) => (
<button key={h} type="button" onClick={() => setHours(h)}
className={cn("rounded-[6px] px-2 py-1 font-mono text-[10.5px]",
hours === h ? "bg-surface-hover text-text" : "text-text-faint hover:text-text")}>
{h === 1 ? "1h" : h === 6 ? "6h" : h === 24 ? "24h" : "7d"}
</button>
))}
</div>
</div>
<div className="mb-6 grid grid-cols-2 gap-3 max-[720px]:grid-cols-1">
<div className="h-44 rounded-[10px] border border-border bg-surface p-3">
<div className="mb-1 font-mono text-[9.5px] uppercase tracking-[0.1em] text-text-faint">CPU %</div>
<ResponsiveContainer width="100%" height="85%">
<AreaChart data={chart} margin={{ top: 4, right: 4, bottom: 0, left: -22 }}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="t" tick={axisTick} tickLine={false} axisLine={false} minTickGap={40} />
<YAxis tick={axisTick} tickLine={false} axisLine={false} domain={[0, 100]} unit="%" width={48} />
<Tooltip contentStyle={tipStyle} labelStyle={{ color: "var(--text-faint)" }} />
<Area type="monotone" dataKey="cpu" stroke="var(--up)" fill="var(--up)" fillOpacity={0.12} strokeWidth={1.5} dot={false} isAnimationActive={false} />
</AreaChart>
</ResponsiveContainer>
</div>
<div className="h-44 rounded-[10px] border border-border bg-surface p-3">
<div className="mb-1 font-mono text-[9.5px] uppercase tracking-[0.1em] text-text-faint">Memory %</div>
<ResponsiveContainer width="100%" height="85%">
<AreaChart data={chart} margin={{ top: 4, right: 4, bottom: 0, left: -22 }}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="t" tick={axisTick} tickLine={false} axisLine={false} minTickGap={40} />
<YAxis tick={axisTick} tickLine={false} axisLine={false} domain={[0, 100]} unit="%" width={48} />
<Tooltip contentStyle={tipStyle} labelStyle={{ color: "var(--text-faint)" }} />
<Area type="monotone" dataKey="memPct" stroke="var(--up)" fill="var(--up)" fillOpacity={0.12} strokeWidth={1.5} dot={false} isAnimationActive={false} />
</AreaChart>
</ResponsiveContainer>
</div>
<div className="h-44 rounded-[10px] border border-border bg-surface p-3">
<div className="mb-1 font-mono text-[9.5px] uppercase tracking-[0.1em] text-text-faint">Network KB/s</div>
<ResponsiveContainer width="100%" height="85%">
<LineChart data={chart} margin={{ top: 4, right: 4, bottom: 0, left: -18 }}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="t" tick={axisTick} tickLine={false} axisLine={false} minTickGap={40} />
<YAxis tick={axisTick} tickLine={false} axisLine={false} width={58} />
<Tooltip contentStyle={tipStyle} labelStyle={{ color: "var(--text-faint)" }} />
<Line type="monotone" dataKey="rx" name="rx" stroke="var(--up)" strokeWidth={1.5} dot={false} isAnimationActive={false} />
<Line type="monotone" dataKey="tx" name="tx" stroke="var(--text-faint)" strokeWidth={1.5} dot={false} isAnimationActive={false} />
</LineChart>
</ResponsiveContainer>
</div>
<div className="h-44 rounded-[10px] border border-border bg-surface p-3">
<div className="mb-1 font-mono text-[9.5px] uppercase tracking-[0.1em] text-text-faint">Load 1m</div>
<ResponsiveContainer width="100%" height="85%">
<AreaChart data={chart} margin={{ top: 4, right: 4, bottom: 0, left: -22 }}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="t" tick={axisTick} tickLine={false} axisLine={false} minTickGap={40} />
<YAxis tick={axisTick} tickLine={false} axisLine={false} width={48} />
<Tooltip contentStyle={tipStyle} labelStyle={{ color: "var(--text-faint)" }} />
<Area type="monotone" dataKey="load" stroke="var(--up)" fill="var(--up)" fillOpacity={0.12} strokeWidth={1.5} dot={false} isAnimationActive={false} />
</AreaChart>
</ResponsiveContainer>
</div>
</div>
<div className="grid grid-cols-2 gap-3 max-[720px]:grid-cols-1">
{l?.temps && Object.keys(l.temps).length > 0 && (
<div className="rounded-[10px] border border-border">
<h2 className="border-b border-border bg-surface px-4 py-2.5 font-mono text-[10px] uppercase tracking-[0.1em] text-text-faint">
Temperatures
</h2>
<table className="w-full text-[12px]">
<tbody>
{Object.entries(l.temps).map(([k, v]) => (
<tr key={k} className="border-b border-border/60 last:border-0">
<td className="px-4 py-2 text-text-dim">{k}</td>
<td className="px-4 py-2 text-right font-mono text-[11px]">{v.toFixed(1)}°C</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{l?.containers && l.containers.length > 0 && (
<div className="rounded-[10px] border border-border">
<h2 className="border-b border-border bg-surface px-4 py-2.5 font-mono text-[10px] uppercase tracking-[0.1em] text-text-faint">
Containers
</h2>
<table className="w-full text-[12px]">
<tbody>
{l.containers.map((ctr) => (
<tr key={ctr.name} className="border-b border-border/60 last:border-0">
<td className="px-4 py-2 font-mono text-[11px] text-text-dim">{ctr.name}</td>
<td className="px-4 py-2 text-right">
<span className={cn("font-mono text-[10.5px] uppercase",
ctr.state === "running" ? "text-up" : "text-text-faint")}>
{ctr.state}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
<div className="mt-6 rounded-[10px] border border-border bg-surface px-4 py-3">
<div className="mb-1.5 font-mono text-[10px] uppercase tracking-[0.1em] text-text-faint">Agent command</div>
<div className="flex items-center gap-2">
<code className="flex-1 overflow-x-auto whitespace-nowrap font-mono text-[11px] text-text-dim">
{agentCmd(sys)}
</code>
<Button variant="outline" size="icon" aria-label="Copy"
onClick={() => void navigator.clipboard.writeText(agentCmd(sys))}>
<Copy size={12} />
</Button>
</div>
</div>
</main>
);
}
+201
View File
@@ -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 (
<span className="h-1.5 w-full overflow-hidden rounded-full bg-surface-hover">
<span
className={cn("block h-full rounded-full", pct !== undefined && pct > warnAt ? "bg-down" : "bg-up")}
style={{ width: `${Math.min(pct ?? 0, 100)}%` }}
/>
</span>
);
}
/** 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<System[]>([]);
const [loaded, setLoaded] = useState(false);
const [dlgOpen, setDlgOpen] = useState(false);
const [name, setName] = useState("");
const [busy, setBusy] = useState(false);
const [created, setCreated] = useState<System | null>(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 (
<main className="mx-auto w-full max-w-5xl px-7 py-8">
<div className="mb-5 flex items-center justify-between">
<div>
<h1 className="text-[15px] font-semibold tracking-tight">Systems</h1>
<p className="text-[12px] text-text-faint">Servers and PCs reporting via dash-agent.</p>
</div>
<Button onClick={() => { setCreated(null); setDlgOpen(true); }}>
<Plus size={13} strokeWidth={2.2} /> New system
</Button>
</div>
{loaded && systems.length === 0 ? (
<div className="rounded-[10px] border border-dashed border-border px-6 py-14 text-center">
<p className="text-[13px] text-text-dim">No systems yet.</p>
<p className="mt-1 text-[11.5px] text-text-faint">
Add one, then run <code className="font-mono">dash-agent</code> on the host with the issued token.
</p>
</div>
) : (
<div className="grid grid-cols-3 gap-3 max-[900px]:grid-cols-2 max-[560px]:grid-cols-1">
{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 (
<div key={sys.id} className="rounded-[10px] border border-border bg-surface px-4 py-3.5">
<div className="mb-2.5 flex items-center gap-2">
<span className={cn("size-1.5 shrink-0 rounded-full",
sys.status === "up" ? "bg-up" : sys.status === "down" ? "bg-down" : "bg-text-faint/40")} />
<Link href={`/systems/${sys.id}`} className="min-w-0 flex-1 truncate text-[13px] font-medium hover:underline">
{sys.name}
</Link>
<Button variant="outline" size="icon" aria-label="Delete"
onClick={() => void remove(sys.id, sys.name)}>
<Trash2 size={11} />
</Button>
</div>
{sys.host ? (
<p className="mb-2.5 truncate font-mono text-[10.5px] text-text-faint">
{sys.host} · {sys.cpuModel || `${sys.cores} cores`}
{l?.uptimeS ? ` · up ${fmtUptime(l.uptimeS)}` : ""}
</p>
) : (
<p className="mb-2.5 font-mono text-[10.5px] text-text-faint">waiting for agent…</p>
)}
<div className="flex flex-col gap-1.5">
{rows.map(([label, pct, txt]) => (
<div key={label} className="flex items-center gap-2">
<span className="w-7 font-mono text-[9.5px] uppercase text-text-faint">{label}</span>
<span className="flex-1"><Bar pct={pct} /></span>
<span className="w-24 text-right font-mono text-[10px] text-text-dim">{txt}</span>
</div>
))}
</div>
</div>
);
})}
</div>
)}
<Dialog open={dlgOpen} onOpenChange={setDlgOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>New system</DialogTitle>
<DialogDescription>Name the host, then run dash-agent on it with the issued token.</DialogDescription>
</DialogHeader>
{created ? (
<div className="flex flex-col gap-3">
<p className="text-[12px] text-text-dim">
Agent command for <span className="font-medium text-text">{created.name}</span>:
</p>
<div className="flex items-center gap-2">
<code className="flex-1 overflow-x-auto whitespace-nowrap rounded-[7px] border border-border bg-bg px-3 py-2 font-mono text-[11px]">
{agentCmd(created)}
</code>
<Button variant="outline" size="icon" aria-label="Copy"
onClick={() => void navigator.clipboard.writeText(agentCmd(created))}>
<Copy size={12} />
</Button>
</div>
<p className="text-[11px] text-text-faint">
The token identifies this system — keep it out of public repos.
</p>
</div>
) : (
<div className="flex flex-col gap-1.5">
<Label htmlFor="sys-name">Name</Label>
<Input id="sys-name" value={name} onChange={(e) => setName(e.target.value)}
placeholder="nas" autoFocus
onKeyDown={(e) => { if (e.key === "Enter") void create(); }} />
</div>
)}
<DialogFooter>
{created ? (
<Button onClick={() => setDlgOpen(false)}>Done</Button>
) : (
<>
<Button variant="outline" onClick={() => setDlgOpen(false)} disabled={busy}>Cancel</Button>
<Button onClick={() => void create()} disabled={busy || !name.trim()}>
{busy ? "Creating…" : "Create"}
</Button>
</>
)}
</DialogFooter>
</DialogContent>
</Dialog>
</main>
);
}
+19
View File
@@ -11,6 +11,25 @@ export type Status = components["schemas"]["Status"];
export type StatusMap = Record<string, Status>;
export type ExportPayload = components["schemas"]["Export"];
// Monitoring (merge program) — same generated-schema rule applies.
export type Monitor = components["schemas"]["Monitor"];
export type MonitorInput = components["schemas"]["MonitorInput"];
export type MonitorView = components["schemas"]["MonitorView"];
export type Heartbeat = components["schemas"]["Heartbeat"];
export type 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";
+37
View File
@@ -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 (
<a
href={domainId ? `/domains/${domainId}` : "/domains"}
className="flex w-full items-center gap-2.5 text-left"
onClick={(e) => e.stopPropagation()}
>
{typeof data?.faviconUrl === "string" && data.faviconUrl ? (
<img src={data.faviconUrl} alt="" className="size-4 shrink-0 rounded-sm" loading="lazy" />
) : null}
<span className="min-w-0 flex-1 truncate text-[12.5px] font-medium">
{typeof data?.name === "string" ? data.name : item.name}
</span>
<span className={cn(
"shrink-0 font-mono text-[10.5px]",
error ? "text-down" : expiring || (days !== undefined && days < 0) ? "text-down" : "text-text-faint",
)}>
{error ? "err" : days === undefined ? "…" : days < 0 ? "expired" : `${days}d`}
{sslDays !== undefined && sslDays <= 30 ? ` · ssl ${sslDays}d` : ""}
</span>
</a>
);
}
+6
View File
@@ -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 {
+38
View File
@@ -0,0 +1,38 @@
import type { WidgetData } from "@/widgets";
import type { Item } from "@/types";
import { cn } from "@/lib/utils";
// Board tile for an uptime monitor: status dot, uptime %, avg ping.
// config.monitorId binds it; data comes from the monitor fetcher.
export function MonitorWidget({ item, data, error }: {
item: Item;
data: WidgetData | undefined;
error: string | undefined;
}) {
const status = typeof data?.status === "string" ? data.status : undefined;
const uptime = typeof data?.uptime24 === "number" ? data.uptime24 : undefined;
const ping = typeof data?.ping === "number" ? data.ping : undefined;
const monId = typeof item.config?.monitorId === "string" ? item.config.monitorId : "";
return (
<a
href={monId ? `/monitors/${monId}` : "/monitors"}
className="flex w-full items-center gap-2.5 text-left"
onClick={(e) => e.stopPropagation()}
>
<span
className={cn(
"size-1.5 shrink-0 rounded-full",
error ? "bg-down" : status === "up" ? "bg-up" : status === "down" ? "bg-down" : "bg-text-faint/40",
)}
/>
<span className="min-w-0 flex-1 truncate text-[12.5px] font-medium">
{typeof data?.name === "string" ? data.name : item.name}
</span>
<span className="shrink-0 font-mono text-[10.5px] text-text-faint">
{error ? "err" : uptime !== undefined ? `${uptime.toFixed(1)}%` : "…"}
{ping ? ` · ${Math.round(ping)}ms` : ""}
</span>
</a>
);
}
+60
View File
@@ -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) => (
<span className="flex items-center gap-1" title={`${label}: ${pct !== undefined ? pct.toFixed(0) + "%" : "—"}`}>
<span className="font-mono text-[9px] uppercase text-text-faint">{label}</span>
<span className="h-1 w-8 overflow-hidden rounded-full bg-surface-hover">
<span
className={cn("block h-full rounded-full", pct !== undefined && pct > 90 ? "bg-down" : "bg-up")}
style={{ width: `${Math.min(pct ?? 0, 100)}%` }}
/>
</span>
</span>
);
return (
<a
href={sysId ? `/systems/${sysId}` : "/systems"}
className="flex w-full items-center gap-2.5 text-left"
onClick={(e) => e.stopPropagation()}
>
<span
className={cn(
"size-1.5 shrink-0 rounded-full",
error ? "bg-down" : status === "up" ? "bg-up" : status === "down" ? "bg-down" : "bg-text-faint/40",
)}
/>
<span className="min-w-0 flex-1 truncate text-[12.5px] font-medium">
{typeof data?.name === "string" ? data.name : item.name}
</span>
{error ? (
<span className="shrink-0 font-mono text-[10.5px] text-text-faint">err</span>
) : (
<span className="flex shrink-0 items-center gap-2">
{bar(cpu, "cpu")}
{bar(memPct, "mem")}
{bar(diskPct, "dsk")}
</span>
)}
</a>
);
}
+57
View File
@@ -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
+71
View File
@@ -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.
+124
View File
@@ -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 <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=<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=<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
```
Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

+89
View File
@@ -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 <TileError msg={error} />;
if (!data) return <TileLoading />;
return <div>{data.blockedPercent}%</div>;
}
```
```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/<name>.go` — fetcher + `init()` + `Meta()`
- [ ] `apps/frontend/src/widgets/<name>.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)
+15
View File
@@ -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"]
+1246
View File
File diff suppressed because it is too large. Load diff
+414 -1
View File
@@ -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",
+18
View File
@@ -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