27 Commits
Author SHA1 Message Date
Tomáš Dvořák a59d2eb558 Merge pull request #13 from Dvorinka/feat/casaos-compose
feat(infra): CasaOS compose with persistent /DATA bind
2026-10-03 10:38:29 +02:00
Tomas DvorakandDevin a94ae1889a feat(infra): add CasaOS compose with persistent /DATA bind
CasaOS copies imported compose files to /tmp/casaos-compose-app-* and
runs them from there, so the repo compose's relative ./data bind fails
at install ("bind source path does not exist") and would be wiped on
reboot. infra/compose.casaos.yaml mounts /DATA/AppData/dash instead and
carries x-casaos metadata for the app tile.

Authored By: TDvorak <[email protected]>

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-03 10:21:18 +02:00
Tomáš Dvořák 3ffc01d710 Merge pull request #12 from Dvorinka/feat/landing-page
ci / backend (push) Canceled after 0s
ci / frontend (push) Canceled after 0s
feat(landing): static marketing page for Vercel
2026-09-25 13:23:43 +02:00
Tomas DvorakandDevin d1486d5ec8 feat(landing): rebuild as Next.js app with live product mocks
Replaces the static HTML page with a Next.js 15 + Tailwind v4 + Motion
app. The hero renders the dashboard itself - live tiles, ticking clock,
wandering metrics, pulsing status dots - instead of screenshots. Adds a
playable four-renderer switcher (bento/cards/index/console) with layout
animations, live monitor/domain/system mocks, and a cycling alert feed.

Vercel project dash is now git-connected with root dir landing/; pushes
deploy automatically. Canonical alias: dash-homelab.vercel.app.

Authored By: TDvorak <[email protected]>

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-25 13:19:06 +02:00
Tomas DvorakandDevin e88429514b feat(landing): static marketing page for Vercel
Single self-contained index.html under landing/ — no build step.
Product design tokens (dark theme, Geist + Geist Mono self-hosted,
#3b82f6 accent), hero with framed board screenshot, feature bento,
screenshots strip, install commands with copy buttons.

Deployed as Vercel project "dash" -> https://dash.vercel.app

Authored By: TDvorak <[email protected]>

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-25 12:57:14 +02:00
Tomáš Dvořák d18b4f6825 Merge pull request #11 from Dvorinka/ci/release-on-main
ci / backend (push) Canceled after 0s
ci / frontend (push) Canceled after 0s
ci(release): auto-tag and release when ci passes on main
2026-09-23 11:35:31 +02:00
Tomas DvorakandDevin 0612beb37e ci(release): auto-tag and release when ci passes on main
release.yml now triggers on workflow_run(ci, main, success): bumps
semver from conventional commits since the latest tag (feat -> minor,
fix -> patch, BREAKING -> major), pushes the tag, then goreleaser and
the GHCR docker build release it. Merging this PR cuts v2.1.0.

Authored By: TDvorak <[email protected]>

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-23 11:34:31 +02:00
Tomáš Dvořák a54ba4c544 Merge pull request #10 from Dvorinka/docs/readme-intel
ci / backend (push) Canceled after 0s
ci / frontend (push) Canceled after 0s
docs: README domain intel refresh
2026-09-22 19:52:58 +02:00
Tomas DvorakandDevin 1bb5441eea docs(readme): domain intel screenshot + feature refresh
Domain watch now advertises per-record vendor attribution and the
provider strip; system monitoring mentions the new chart set. Adds the
domain-detail screenshot to the collapsible grid.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-22 19:50:44 +02:00
Tomáš Dvořák cfacc4ed7c Merge pull request #9 from Dvorinka/fix/domain-intel-types
fix: exactOptionalPropertyTypes on provider badge props
2026-09-22 18:29:42 +02:00
Tomas DvorakandDevin 10d9171031 fix(frontend): exactOptionalPropertyTypes on provider props
p?: Provider rejects an explicit undefined under
exactOptionalPropertyTypes — generated schema fields are optional.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-22 18:28:48 +02:00
Tomáš Dvořák 8ff13f74f7 Merge pull request #8 from Dvorinka/feat/domain-intel
Domain intel: per-record provider badges + vendor strip
2026-09-22 18:27:48 +02:00
Tomas DvorakandDevin 041c54c18c feat(domains): per-record provider intel + vendor strip
Structured DNSRecord list carries the detected vendor per row (NS/MX/CNAME/
TXT value, ASN org, response headers, SPF includes, verification strings)
and a Providers strip shows registrar/DNS/hosting/email/CA with favicons —
the domainstack-style view. Detection tables expanded to ~70 NS, ~45 mail,
~60 CNAME, ~130 registrar, and ~50 TXT patterns.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-22 18:26:24 +02:00
Tomáš Dvořák f4ebdf1f33 Merge pull request #7 from Dvorinka/feat/settings-dialog
feat(frontend): regroup settings dialog, icon renderer tiles
2026-09-22 18:05:57 +02:00
Tomas DvorakandDevin c5339a24b4 feat(frontend): regroup settings dialog, icon renderer tiles
Settings splits into labelled sections — renderer, appearance, wallpaper
and CSS, notifications, auth, backup — each with an icon header. Renderer
options gain a per-style glyph and accent-highlighted selection; theme
becomes a segmented Dark/Light control; the dialog widens to max-w-xl.

Renderer switching itself verified working end-to-end (live switch +
persistence across reload); unchanged code path since v2.0.0.

Authored By: TDvorak <[email protected]>

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-22 17:59:19 +02:00
Tomáš Dvořák 5473881f93 Merge pull request #6 from Dvorinka/feat/beszel-charts
feat(frontend): restyle monitoring charts on the Beszel school
2026-09-22 17:28:35 +02:00
Tomas DvorakandDevin c336408152 docs: retake README screenshots with seeded homelab data
All five shots recaptured against a live instance seeded with realistic
data: 20 services with dashboard-icons, 8 monitors, 4 real domains via
live WHOIS, 3 systems with the new chart grid, and a public status page.

Also references ghcr.io/dvorinka/dash:latest in docker-compose.yml so
`docker compose pull` works without a local build.

Authored By: TDvorak <[email protected]>

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-22 17:26:09 +02:00
Tomas DvorakandDevin ddd29aad24 feat(frontend): restyle monitoring charts on the Beszel school
Ports Beszel's chart design language onto our recharts primitives:
titled card grid, area fills on a true time axis, unit-aware Y ticks,
sorted dotted tooltips, and legend chips. System detail gains stacked
per-container Docker CPU/memory, per-sensor temperature lines, load
1/5/15, and GPU usage/VRAM — all from data the agent already sends.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-22 17:05:34 +02:00
Tomas DvorakandDevin 35602c2e52 fix(ci): track web/dist .keep so go:embed resolves on bare checkout
The blanket dist/ pattern in .gitignore excluded the
apps/backend/internal/web/dist directory itself, making the earlier
.keep negation unreachable — the file was never committed, and CI's
clean checkout left //go:embed all:dist with no matching files.

Re-include the directory (contents remain ignored) and commit .keep.
Verified: go build ./... succeeds with dist containing only .keep.

Authored By: TDvorak <[email protected]>

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-22 16:46:37 +02:00
Tomáš Dvořák eb84939a19 Merge pull request #4 from Dvorinka/feat/oss-readiness
docs: OSS readiness — README hero, one-line installer
2026-09-22 16:43:27 +02:00
Tomas DvorakandDevin afa0c931b9 docs: OSS readiness — README hero, one-line installer, stale doc fixes
Restructure README to the standard OSS layout (logo, badges, screenshots
grid, architecture, quick start). Add install.sh for curl|sh installs with
a named-volume fallback for Docker Desktop file-sharing limits, and
scripts/setup.sh for clone-based setup. Correct SECURITY.md (no longer
pre-1.0) and CONTRIBUTING.md (auth shipped in 2.0.0).

Authored By: TDvorak <[email protected]>

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-22 16:42:20 +02:00
Tomas DvorakandDevin 54b5497121 docs: v2.0.0 changelog, configuration for auth/boards, announcement refresh
ci / backend (push) Canceled after 0s
ci / frontend (push) Canceled after 0s
- CHANGELOG 2.0.0 entry covering all Phase 5 waves
- configuration.md: transports, per-monitor alert rules, auth, boards,
  appearance/language, PWA
- announcement.md: updated for shipped features and opt-in auth
- AGENTS.md: "no auth" line was stale
- ROADMAP: tick shipped M1–M3 planning checkboxes

Authored By: TDvorak <[email protected]>

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-22 13:10:29 +02:00
Tomáš Dvořák 0fa2ea82d8 Merge pull request #3 from Dvorinka/feat/phase5
Phase 5: alerts, agent depth, subdomains, auth, boards, i18n
2026-09-22 12:51:44 +02:00
Tomas DvorakandDevin e0ef60eca6 feat(ui): phase 5 — graphs, widgets, theming, PWA, boards, i18n
P5a: 24h uptime bar strip + latency chart on monitor detail, iframe
embed widget, generic JSON-path widget, accent/wallpaper/custom CSS
settings, PWA manifest + service worker, demo GIF.

P5b: transport picker + per-transport fields + test button in
settings; per-monitor alert-rule fields in dialogs.

P5c: container cpu/mem columns, SMART/ZFS/GPU sections on system
detail, subdomains section on domain detail.

P5d: login/setup page + auth middleware client handling, board
switcher + /b/:slug routes, full string extraction to typed t() keys
with {param} interpolation, complete cs locale, language select.

Authored By: TDvorak <[email protected]>

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-22 12:49:51 +02:00
Tomas DvorakandDevin f3143af351 feat(api): phase 5 — alerts, agent depth, subdomains, auth, boards
P5b: notifier transport registry (webhook/slack/discord/telegram/
gotify/ntfy/smtp via net/smtp), per-monitor+domain alert rules
(failsBeforeDown, latencyWarn, certDays, mute).

P5c: per-container cpu/mem, optional SMART/ZFS/GPU exec-or-skip
collectors, crt.sh subdomain discovery with daily sweep.

P5d: opt-in auth (bcrypt, sqlite sessions, 7d rotating cookie, login
rate limit, public-route bypass) and multiple boards (boards table,
sections.board_id, /api/boards CRUD, slug routing).

Authored By: TDvorak <[email protected]>

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-22 12:49:44 +02:00
Tomas DvorakandDevin 8973be3b41 docs: Phase 5 plan — P5a quick wins, P5b alerts, P5c agent depth, P5d structure
ci / backend (push) Canceled after 0s
ci / frontend (push) Canceled after 0s
All demand-confirmed items mapped with scope and sequencing: uptime graphs,
iframe/JSON widgets, theming, PWA, demo GIF → SMTP + notifier presets +
alert rules → per-container metrics, SMART/ZFS/GPU, subdomain discovery →
opt-in auth (local accounts), multiple boards, full i18n.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-21 22:33:50 +02:00
Tomas DvorakandDevin fbc4ed4f23 docs: close out Phase 4 — v0.7.0 released
ci / backend (push) Canceled after 0s
ci / frontend (push) Canceled after 0s
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-21 21:37:58 +02:00
129 changed files with 9913 additions and 821 deletions

No files matched your search

+77 -4
View File
@@ -1,20 +1,89 @@
name: release
# Releases are cut automatically from main: after the ci workflow passes on a
# main push, the version job bumps semver from conventional commit subjects
# (BREAKING/! -> major, feat -> minor, otherwise patch), pushes the tag, and
# the build jobs release it. Manual tags are not used; workflow_dispatch
# re-runs the same flow on demand.
on:
push:
tags: ["v*"]
workflow_run:
workflows: [ci]
types: [completed]
branches: [main]
workflow_dispatch:
permissions:
contents: write
packages: write
# Two merges racing would compute the same tag; serialize instead.
concurrency:
group: release
cancel-in-progress: false
jobs:
version:
runs-on: ubuntu-latest
if: github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success'
outputs:
version: ${{ steps.v.outputs.version }}
semver: ${{ steps.v.outputs.semver }}
major_minor: ${{ steps.v.outputs.major_minor }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
- id: v
run: |
set -euo pipefail
latest="$(git tag -l 'v*' --sort=-v:refname | head -n1)"
if [ -z "$latest" ]; then
version="v0.1.0"; bump="minor"
else
subjects="$(git log "$latest"..HEAD --format=%s)"
if grep -qiE 'BREAKING[ -]CHANGE|^[a-z]+(\([^)]*\))?!:' <<< "$subjects"; then
bump="major"
elif grep -qE '^feat(\([^)]*\))?:' <<< "$subjects"; then
bump="minor"
else
bump="patch"
fi
IFS=. read -r major minor patch <<< "${latest#v}"
case "$bump" in
major) major=$((major+1)); minor=0; patch=0 ;;
minor) minor=$((minor+1)); patch=0 ;;
*) patch=$((patch+1)) ;;
esac
version="v${major}.${minor}.${patch}"
fi
semver="${version#v}"
IFS=. read -r mj mn _ <<< "$semver"
{
echo "version=$version"
echo "semver=$semver"
echo "major_minor=$mj.$mn"
} >> "$GITHUB_OUTPUT"
echo "latest=${latest:-none} bump=$bump -> $version"
- name: Push tag
env:
VERSION: ${{ steps.v.outputs.version }}
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git tag "$VERSION"
git push origin "$VERSION"
binaries:
needs: version
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # goreleaser needs full history for tag + changelog
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
- uses: actions/setup-node@v4
with:
@@ -40,11 +109,15 @@ jobs:
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GORELEASER_CURRENT_TAG: ${{ needs.version.outputs.version }}
docker:
needs: version
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
- uses: docker/setup-buildx-action@v3
@@ -59,8 +132,8 @@ jobs:
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=${{ needs.version.outputs.semver }}
type=raw,value=${{ needs.version.outputs.major_minor }}
type=raw,value=latest
- uses: docker/build-push-action@v6
+5
View File
@@ -20,7 +20,12 @@ data/*
.DS_Store
coverage/
dist/
# the blanket dist/ above excludes the embedded-UI dir itself, which would
# make the .keep negation unreachable — re-include the dir, contents stay ignored
!apps/backend/internal/web/dist/
# local tooling artifacts
.playwright-mcp/
.playwright-cli/
.vercel
.env*
+4 -1
View File
@@ -1,13 +1,16 @@
# Dash
Self-hosted homelab dashboard. Single Go binary serving an embedded React UI.
No auth, no YAML. See `ROADMAP.md` (canonical) and `docs/specs/` for design.
Optional auth (off by default), no YAML. See `ROADMAP.md` (canonical) and
`docs/specs/` for design.
## Layout
- `apps/frontend` — React 18 + Vite + TS strict + Tailwind v4 + shadcn/ui
- `apps/backend` — Go + Gin + zap, CGO-free, SQLite via `modernc.org/sqlite`
- `packages/api-client` — generated TS client; `openapi.yaml` at repo root is the contract
- `landing/` — Next.js 15 marketing site (own package.json, not a workspace);
Vercel project `dash`, root dir `landing`, git-connected to this repo
- `infra/` — Dockerfile (multi-stage, one image)
- `tools/oxlint/anti-slop` — vendored lint plugin (do not extend casually)
- `data/` — runtime SQLite + uploaded icons (gitignored)
+59 -1
View File
@@ -6,6 +6,62 @@ All notable changes to Dash. Format follows
## [Unreleased]
### Added
- CasaOS deployment file `infra/compose.casaos.yaml` with app-store
(`x-casaos`) metadata and a persistent `/DATA/AppData/dash` bind mount;
the repo compose's relative `./data` fails under CasaOS, which runs
compose from `/tmp` and loses it on reboot
## [2.1.0] — 2026-09-23
### Added
- Domain detail now identifies vendors per record: A/AAAA/CNAME/NS/MX/TXT
rows carry provider badges (Cloudflare, Google, Purelymail, …) with favicon
icons, plus a five-role provider strip (registrar, DNS, hosting, email, CA)
above the detail sections. Detection covers ~70 NS providers, ~45 mail
providers, ~60 CNAME targets, ASN/org matching, response-header signals,
and TXT verification/SPF attribution
### Changed
- Settings dialog regrouped into icon-labelled sections (renderer, appearance,
wallpaper/CSS, notifications, auth, backup); renderer tiles now show a
per-style glyph and the theme picker is a segmented control
- Monitoring charts restyled to the Beszel school: titled chart cards with
subtitles, soft area fills on a true time-scaled axis, unit-aware Y ticks,
sorted dotted tooltips, and legend chips on multi-series charts
- System detail gains stacked per-container Docker CPU/memory charts,
per-sensor temperature lines, load 1/5/15, and GPU usage/VRAM charts —
all from data the agent already sends
## [2.0.0] — 2026-09-22
### Added
- Opt-in local auth: first-user setup, bcrypt passwords, SQLite sessions,
rotating 7-day HttpOnly cookie, login rate limit, sign-out/disable in
settings. Off by default; status pages, badges, metrics, push, and agent
ingest stay public
- Multiple boards: named boards with slugs, header switcher, `/b/:slug`
routes, per-board sections, board-aware export
- Full i18n: every UI string via typed `t()` keys with interpolation;
English + Czech locales, language select in settings
- Uptime bar strip + latency chart on monitor detail
- iframe embed widget and generic JSON-path widget (backend fetcher)
- Custom accent color, wallpaper URL, and custom CSS in settings
- PWA: manifest, icons, minimal service worker
- Alert transports: webhook, Slack, Discord, Telegram, Gotify, ntfy, SMTP
(stdlib `net/smtp`); transport picker + test button in settings
- Per-monitor alert rules: consecutive-failure threshold, latency warn, mute;
per-domain cert-days threshold and mute
- Per-container CPU/memory in `dash-agent` (Docker stats when socket present);
optional SMART, ZFS, and GPU collectors (exec-or-sysfs, skipped when absent)
- Subdomain discovery via Certificate Transparency + DNS, daily sweep,
subdomains section on domain detail
## [0.7.0] — 2026-09-21
### Added
@@ -77,7 +133,9 @@ All notable changes to Dash. Format follows
- 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
[Unreleased]: https://github.com/Dvorinka/Dash/compare/v2.1.0...HEAD
[2.1.0]: https://github.com/Dvorinka/Dash/compare/v2.0.0...v2.1.0
[2.0.0]: https://github.com/Dvorinka/Dash/compare/v0.7.0...v2.0.0
[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
+2 -2
View File
@@ -28,8 +28,8 @@ DASH_DEV=1 go run ./cmd/dash # API on :8080
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.
- Auth is opt-in and local-only: no OIDC/SSO, no multi-user RBAC. No
YAML-as-source-of-truth, no external database. These are non-goals.
- 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.
+104 -50
View File
@@ -1,46 +1,106 @@
# Dash
<p align="center">
<img src="./dash.svg" alt="Dash" width="120">
</p>
A self-hosted homelab dashboard. CasaOS-grade ease of use, Vercel-grade design.
Single container, UI-managed, no YAML, no auth.
<h1 align="center">Dash</h1>
![Dash board](docs/screenshots/board.png)
<p align="center">
Self-hosted homelab dashboard.<br>
Service boards, uptime monitors, domain watch, and system metrics in one container.
</p>
<p align="center">
<a href="#quick-start">Quick Start</a> ·
<a href="docs/installation.md">Documentation</a> ·
<a href="https://github.com/Dvorinka/Dash/releases">Releases</a> ·
<a href="CONTRIBUTING.md">Contributing</a>
</p>
<p align="center">
<a href="https://github.com/Dvorinka/Dash/actions/workflows/ci.yml"><img src="https://github.com/Dvorinka/Dash/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
<a href="https://github.com/Dvorinka/Dash/releases"><img src="https://img.shields.io/github/v/release/Dvorinka/Dash" alt="Release"></a>
<a href="LICENSE"><img src="https://img.shields.io/github/license/Dvorinka/Dash" alt="License"></a>
</p>
## What is Dash?
Dash is a self-hosted homelab dashboard: service boards with drag-and-drop,
uptime monitors, domain intelligence, push-based system metrics, incidents,
and public status pages — one static Go binary serving an embedded React UI.
No YAML, no external database; auth is optional and off by default.
## Screenshots
| Board | Monitors | Systems |
|:-:|:-:|:-:|
| ![Service board](docs/screenshots/board.png) | ![Uptime monitors](docs/screenshots/monitors.png) | ![System metrics](docs/screenshots/systems.png) |
<details>
<summary>More screenshots</summary>
| Domain intel | Domains | Status page |
|:-:|:-:|:-:|
| ![Domain detail with provider detection](docs/screenshots/domain-detail.png) | ![Domain watch](docs/screenshots/domains.png) | ![Public status page](docs/screenshots/status-page.png) |
</details>
## 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
- **Service boards** — multiple boards, sections, drag-and-drop, multi-URL services, icon upload, four renderers (Bento, Cards, Index, Console), dark/light themes, custom accent/wallpaper/CSS.
- **Uptime monitors** — HTTP, TCP, ping, DNS, keyword, JSON-path, and push checks on a schedule; heartbeat history, uptime graphs, per-monitor alert rules.
- **Domain watch** — WHOIS/RDAP, TLS certificate, host geolocation, and CT-log subdomain discovery; every DNS record is attributed to its vendor (Cloudflare, Google Workspace, Purelymail, …) with provider badges across registrar, DNS, hosting, email, and CA.
- **System monitoring** — lightweight `dash-agent` pushes CPU, memory, disk, network, load, temperatures, per-container stats, and SMART/ZFS/GPU when available; rendered as stacked per-container charts, per-sensor temperature lines, and load/bandwidth series.
- **Incidents & status pages** — automatic incidents on monitor down, maintenance windows, public `/status/:slug` pages, SVG badges.
- **Alerts** — webhook, Slack, Discord, Telegram, Gotify, ntfy, and SMTP transports.
- **Observability** — Prometheus `/api/metrics`, JSON export, CSV bulk import, importers for Homepage, Homarr, and Dashy configs.
- **Optional auth** — local username/password, bcrypt + SQLite sessions, off by default; public status pages stay open either way.
- **i18n + PWA** — English and Czech UI, installable manifest/service worker.
## Run
## Architecture
```
Browser ──▶ dash (single static Go binary)
├── Embedded React UI (embed.FS)
├── REST API (Gin) ──▶ SQLite
└── Schedulers ──▶ monitors, domain refresh, alerts, retention
│
▼ outbound
HTTP/TCP/ICMP/DNS, WHOIS/RDAP, TLS, SMTP
dash-agent (one binary per host) ──push JSON──▶ POST /api/systems/ingest
```
## Quick Start
Prerequisites: Docker.
```bash
curl -fsSL https://raw.githubusercontent.com/Dvorinka/Dash/main/install.sh | sh
```
Installs into `./dash` and starts the stack on port 3000. Or from a clone:
```bash
git clone https://github.com/Dvorinka/Dash.git && cd Dash
./scripts/setup.sh # builds the image and starts the stack
```
Or plain `docker run`, no script needed:
```sh
docker run -d -v ./data:/data -p 3000:3000 ghcr.io/dvorinka/dash:latest
```
or with compose:
Then open `http://localhost:3000` — everything is configured from the UI.
For the system agent, binary installs, reverse proxies, and upgrades see
[docs/installation.md](docs/installation.md).
```yaml
services:
dash:
image: ghcr.io/dvorinka/dash:latest
ports:
- "3000:3000"
volumes:
- ./data:/data
restart: unless-stopped
```
## Configuration
Then open http://localhost:3000. Everything is configured from the UI.
Dash is configured from the UI; the server reads three environment
variables (`DASH_PORT`, `DASH_DATA_DIR`, `DASH_DEV`). All state lives in one
directory — a SQLite database plus uploaded icons. See
[docs/configuration.md](docs/configuration.md).
## System agent
@@ -50,22 +110,16 @@ Monitor a Linux host: create a system in the UI, copy the token, then:
dash-agent -url http://dash:3000 -token <token>
```
One static binary, no dependencies. See
[docs/installation.md](docs/installation.md) for systemd and Docker setups.
One static binary, no dependencies. systemd unit and container image covered
in [docs/installation.md](docs/installation.md#dash-agent-system-monitoring).
## Screenshots
## Documentation
| | |
|---|---|
| ![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)
- [docs/installation.md](docs/installation.md) — Docker, binary, agent, reverse proxy, upgrades
- [docs/configuration.md](docs/configuration.md) — env vars, notifications, auth, boards, operations
- [docs/widgets.md](docs/widgets.md) — widget development guide
- `openapi.yaml` — API contract (generated TS client in `packages/api-client`)
- [ROADMAP.md](ROADMAP.md) — design decisions and release history
## Develop
@@ -73,21 +127,21 @@ Requires Go 1.24+ and Node 20+.
```sh
npm install # workspaces: frontend + api-client
npm run generate # regenerate TS client from openapi.yaml
npm run dev # vite dev on :5173, proxies /api to :8080
```
In another shell:
```sh
cd apps/backend
DASH_DEV=1 go run ./cmd/dash # API on :8080 (DASH_DEV skips embedded UI)
```
## Stack
## Contributing
Go + Gin + SQLite (single static binary, embeds the frontend) · React 18 +
Vite + TS strict + Tailwind + shadcn/ui + dnd-kit · OpenAPI-generated client.
See [CONTRIBUTING.md](CONTRIBUTING.md) for the development workflow.
## Security
See [SECURITY.md](SECURITY.md) for reporting vulnerabilities.
## License
+105 -29
View File
@@ -39,7 +39,10 @@ or popover logic.
| `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 |
| `v1.1.0` | P5a — uptime graphs, iframe/JSON widgets, theming, PWA, demo GIF | per-item ships |
| `v1.2.0` | P5b — SMTP + notifier presets + alert rules | alert pipeline live |
| `v1.3.0` | P5c — per-container metrics, SMART/ZFS/GPU, subdomain discovery | agent payload v2 |
| `v2.0.0` | P5d — opt-in auth, multiple boards, full i18n | structure change |
---
@@ -146,27 +149,27 @@ becomes the summary layer over the monitoring pages.
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.
- [x] 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)
- [x] In-process scheduler: due-monitor sweep, bounded worker pool, heartbeat write, status transitions
- [x] 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)
- [x] API: `/api/monitors` CRUD, `/:id/heartbeats?range=`, pause/resume, check-now
- [x] Monitors page (status, uptime 24h/30d, ping, interval) + detail (response chart, heartbeat log)
- [x] Board widget type `monitor` bound to a monitor id
- [x] ServiceDialog checkbox: create matching http monitor from a service URL
- [x] 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
- [x] Schema: `domains` (core columns + `extra` JSON for the long tail), `domain_history` (domain_id, change_type, field, old, new, at)
- [x] 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
- [x] Daily scheduler + manual refresh; field diffs recorded to `domain_history`
- [x] 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)
- [x] Domains page + detail (expiry countdown, registrar, SSL, DNS, subdomains)
- [x] Board widget `domain` (expiry countdown)
- [x] Subdomain discovery: port `subdomain_discovery.go`, opt-in per domain
- [x] Not ported (v1): whoisxml API, EURid web scraping, SEO/robots parsing, valuation estimates — demand only
### Phase M3 — Systems → v0.6.0
@@ -198,22 +201,95 @@ Beszel-style server monitoring, push-based.
- [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
- [x] Public repo (already flipped) + announcement draft (`docs/announcement.md`) — v0.7.0 released: binaries + GHCR image live
**Exit:** a stranger can `docker run`, import their Homepage config, and have a working board in under two minutes.
## Phase 5 — Post-1.0 (demand-driven only)
## Phase 5 — Post-1.0 (shipped 2026-09-22)
Build only what users actually ask for. Candidates, unordered:
All items shipped on `feat/phase5`.
- Optional auth (local password + OIDC) — most-requested, likely first
- Health-check history + uptime graphs
- Custom CSS / accent color / wallpaper
- Iframe/embed widgets, API-status widgets (generic JSON path)
- PWA install, mobile layout polish
- Multiple boards
### P5a — Quick wins (frontend-heavy, no schema risk)
## Non-goals (v1)
- [x] **Uptime graphs** — monitor detail renders 24h/30d heartbeat bars +
latency line (recharts, already a dep). Data: existing
`/api/monitors/:id/heartbeats` + uptime stats. Frontend only.
- [x] **iframe widget** — `config.url`, sandboxed `<iframe>` tile in the
widget registry; local type, no backend fetcher.
- [x] **Generic JSON-path widget** — backend fetcher: GET `config.url` with
optional headers, extract `config.path` (dot/bracket path), display
value + optional label/format. Covers most "is API X alive" asks.
- [x] **Custom CSS / accent / wallpaper** — settings keys `accent`,
`custom_css`, `wallpaper_url`; injected as CSS var override + `<style>`
+ board background layer. Respect reduced-motion/contrast defaults.
- [x] **PWA** — `manifest.webmanifest` (icons, name, theme color), minimal
service worker: cache-first for static assets, network-first for /api.
No offline board editing — read-only graceful degradation.
- [x] **Demo GIF** — scripted playwright walkthrough (board → monitors →
status page) rendered via ffmpeg; committed to `docs/screenshots/`.
Auth, Postgres/external DB, multi-user, mobile app, container lifecycle
management, YAML-as-source-of-truth. Revisit only with evidence of demand.
### P5b — Alert depth
- [x] **SMTP transport** — `net/smtp` stdlib (STARTTLS + plain-auth only,
no external dep). Settings: `smtp_host`, `smtp_port`, `smtp_user`,
`smtp_pass` (secret), `smtp_from`, `smtp_to`. Same event pipeline as
the webhook; test button reuses `/api/notify/test`.
- [x] **Notifier presets** — refactor `notify.go` into a transport registry:
`webhook` (generic, current), `slack`, `discord`, `telegram`, `gotify`,
`ntfy`, `smtp`. Presets = field set + payload template per transport;
settings store per-transport config. UI: transport picker + fields.
- [x] **Alert rules** — per-monitor JSON `alerts` config: consecutive
failures before down (default 1), latency-warn ms, cert-days threshold
for domains, mute flag. Scheduler evaluates rules before firing events;
maintenance windows keep priority. Migration adds nullable
`alerts TEXT` on `monitors`/`domains`.
### P5c — Agent + domain depth
- [x] **Per-container metrics** — agent calls Docker `/containers/{id}/stats?stream=false`
(cpu%, mem used/limit) when docker.sock present; payload adds
`containers[].cpu`, `.memUsed`, `.memLimit` (JSON — no migration).
UI: containers table gets cpu/mem columns + optional sparkline.
- [x] **SMART / ZFS / GPU** — optional collectors, all best-effort and
silently skipped when tooling is absent:
SMART via `smartctl -j` exec if installed; ZFS via `zpool status`/`-j`
parse; GPU via `/sys/class/drm` (amd/intel basic) and `nvidia-smi -j`
when present. No new hard deps — exec-or-skip.
- [x] **Subdomain discovery** — crt.sh CT-log query + A/AAAA resolve for
discovered names; `subdomains` table (domain_id, name, ips, first_seen,
last_seen). Domain detail: subdomains tab with probe status. Daily
sweep alongside the domain refresh job.
### P5d — Structure (big, changes product shape)
- [x] **Auth (opt-in, email/username + password)** — deliberate constraint:
stays disabled by default; enabling is a settings toggle. First enable
creates the admin user. `users` table (id, email, name, passhash
bcrypt/argon2id, created_at), session cookie (httpOnly, signed,
SQLite-backed sessions or HMAC token), `POST /api/auth/login|logout`,
middleware gating all /api + non-public UI routes; public status pages
and badges stay open. No OIDC, no SSO — local accounts only.
Security pass required: rate-limit login, constant-time compare,
session rotation.
- [x] **Multiple boards** — `boards` table (id, name, slug, position),
`sections.board_id` FK (migration backfills all rows to board 1).
Board switcher in the header (dropdown + `/b/:slug` route), settings
for default board. Monitors/domains/systems pages stay global — only
the service board multiplies.
- [x] **Full i18n extraction** — every UI string moves into `en.ts` via the
existing `t()` seam; mechanical pass over pages/dialogs/widgets.
Second locale (cs) lands in the same pass to prove the seam end-to-end.
### Ordering rationale
P5a items are isolated and shippable individually. P5b builds on the existing
event pipeline. P5c grows the agent payload (backward-compatible JSON
additions — old servers ignore new fields). P5d is last because auth +
multi-board touch routing, middleware, and schema globally; doing them first
would force rework of everything above.
## Non-goals (post-1.0)
Postgres/external DB, multi-user/RBAC, mobile app, container lifecycle
management, YAML-as-source-of-truth, OIDC/SSO (local accounts only),
Beszel-protocol agent compatibility. Revisit only with evidence of demand.
+1 -1
View File
@@ -7,7 +7,7 @@
| latest release | yes |
| older releases | no |
Dash is pre-1.0; only the latest release receives fixes.
Only the latest release receives fixes.
## Reporting a vulnerability
+121
View File
@@ -0,0 +1,121 @@
package main
// Docker collector: container liveness plus per-container CPU/memory from
// /containers/{id}/stats?stream=false. All best-effort — no socket, no data.
import (
"context"
"encoding/json"
"io"
"net"
"net/http"
"strings"
"sync"
"time"
)
const dockerSock = "/var/run/docker.sock"
var dockerClient = &http.Client{
Timeout: 4 * time.Second,
Transport: &http.Transport{
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
return (&net.Dialer{Timeout: 2 * time.Second}).DialContext(ctx, "unix", dockerSock)
},
},
}
// dockerStatsJSON is the subset of /containers/{id}/stats we consume.
type dockerStatsJSON struct {
CPUStats struct {
CPUUsage struct {
TotalUsage uint64 `json:"total_usage"`
} `json:"cpu_usage"`
SystemCPUUsage uint64 `json:"system_cpu_usage"`
OnlineCPUs uint32 `json:"online_cpus"`
} `json:"cpu_stats"`
PreCPUStats struct {
CPUUsage struct {
TotalUsage uint64 `json:"total_usage"`
} `json:"cpu_usage"`
SystemCPUUsage uint64 `json:"system_cpu_usage"`
} `json:"precpu_stats"`
MemoryStats struct {
Usage uint64 `json:"usage"`
Limit uint64 `json:"limit"`
} `json:"memory_stats"`
}
// containerCPUPct applies the Docker dashboard formula: cpu delta over
// system delta, scaled by online CPUs. 0 when deltas don't advance.
func containerCPUPct(s *dockerStatsJSON) float64 {
cpuDelta := float64(s.CPUStats.CPUUsage.TotalUsage) - float64(s.PreCPUStats.CPUUsage.TotalUsage)
sysDelta := float64(s.CPUStats.SystemCPUUsage) - float64(s.PreCPUStats.SystemCPUUsage)
online := s.CPUStats.OnlineCPUs
if online == 0 {
online = 1
}
if cpuDelta <= 0 || sysDelta <= 0 {
return 0
}
return cpuDelta / sysDelta * float64(online) * 100
}
// readContainers lists containers and fans out to per-container stats.
// Stats fetches are bounded: 8 concurrent, each under the client timeout.
func readContainers() []containerStat {
if _, err := net.DialTimeout("unix", dockerSock, time.Second); err != nil {
return nil
}
res, err := dockerClient.Get("http://d/containers/json?all=1")
if err != nil {
return nil
}
defer res.Body.Close()
var raw []struct {
ID string `json:"Id"`
Names []string `json:"Names"`
State string `json:"State"`
}
if json.NewDecoder(io.LimitReader(res.Body, 4<<20)).Decode(&raw) != nil {
return nil
}
out := make([]containerStat, len(raw))
var wg sync.WaitGroup
sem := make(chan struct{}, 8)
for i, r := range raw {
name := ""
if len(r.Names) > 0 {
name = strings.TrimPrefix(r.Names[0], "/")
}
out[i] = containerStat{Name: name, State: r.State}
if r.State != "running" || r.ID == "" {
continue
}
wg.Add(1)
sem <- struct{}{}
go func(i int, id string) {
defer wg.Done()
defer func() { <-sem }()
fillContainerStats(&out[i], id)
}(i, r.ID)
}
wg.Wait()
return out
}
// fillContainerStats fetches one container's one-shot stats into cs.
func fillContainerStats(cs *containerStat, id string) {
res, err := dockerClient.Get("http://d/containers/" + id + "/stats?stream=false")
if err != nil {
return
}
defer res.Body.Close()
var st dockerStatsJSON
if json.NewDecoder(io.LimitReader(res.Body, 4<<20)).Decode(&st) != nil {
return
}
cs.CPU = containerCPUPct(&st)
cs.MemUsed = float64(st.MemoryStats.Usage)
cs.MemLimit = float64(st.MemoryStats.Limit)
}
+230
View File
@@ -0,0 +1,230 @@
package main
// Optional collectors: SMART (smartctl), ZFS (zpool), GPU (nvidia-smi or
// /sys/class/drm). Every collector is exec-or-skip — missing tools or
// permissions produce nil, never errors, and each exec carries a timeout so a
// wedged tool can't stall reporting.
import (
"context"
"encoding/json"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
)
// runOut executes a command with a timeout; empty output on any failure.
func runOut(timeout time.Duration, name string, args ...string) []byte {
if _, err := exec.LookPath(name); err != nil {
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
out, err := exec.CommandContext(ctx, name, args...).Output()
if err != nil {
return nil
}
return out
}
// ---------- SMART ----------
type smartctlScan struct {
Devices []struct {
Name string `json:"name"`
} `json:"devices"`
}
type smartctlAll struct {
ModelName string `json:"model_name"`
SmartStatus *struct {
Passed bool `json:"passed"`
} `json:"smart_status"`
Temperature *struct {
Current float64 `json:"current"`
} `json:"temperature"`
}
// readSMART scans devices via smartctl -j --scan, then reads each drive.
// Needs smartmontools installed and usually root or sudo rights for /dev.
func readSMART() []smartDisk {
scan := runOut(5*time.Second, "smartctl", "-j", "--scan")
if scan == nil {
return nil
}
var sc smartctlScan
if json.Unmarshal(scan, &sc) != nil || len(sc.Devices) == 0 {
return nil
}
out := make([]smartDisk, 0, len(sc.Devices))
for i, d := range sc.Devices {
if i >= 8 { // bound the fan-out
break
}
b := runOut(4*time.Second, "smartctl", "-j", "-a", d.Name)
if b == nil {
continue
}
var a smartctlAll
if json.Unmarshal(b, &a) != nil {
continue
}
sd := smartDisk{Device: d.Name, Model: a.ModelName}
if a.SmartStatus != nil {
p := a.SmartStatus.Passed
sd.Passed = &p
}
if a.Temperature != nil {
sd.TempC = a.Temperature.Current
}
out = append(out, sd)
}
return out
}
// ---------- ZFS ----------
// readZFS prefers `zpool list -j` (OpenZFS 2.3+), falls back to the stable
// `-H -p` text format. Parse failures degrade to nil.
func readZFS() []zfsPool {
if b := runOut(4*time.Second, "zpool", "list", "-j", "--json-int",
"-o", "name,health,size,free"); b != nil {
if pools := parseZpoolJSON(b); pools != nil {
return pools
}
}
return parseZpoolText(runOut(4*time.Second,
"zpool", "list", "-H", "-p", "-o", "name,health,size,free"))
}
// parseZpoolJSON reads {"pools":{"name":{"properties":{...}}}}.
func parseZpoolJSON(b []byte) []zfsPool {
var doc struct {
Pools map[string]struct {
Properties map[string]struct {
Value any `json:"value"`
} `json:"properties"`
} `json:"pools"`
}
if json.Unmarshal(b, &doc) != nil || len(doc.Pools) == 0 {
return nil
}
var out []zfsPool
num := func(v any) float64 {
switch n := v.(type) {
case float64:
return n
case string:
f, _ := strconv.ParseFloat(n, 64)
return f
}
return 0
}
for name, p := range doc.Pools {
z := zfsPool{Name: name}
if v, ok := p.Properties["health"]; ok {
if s, ok := v.Value.(string); ok {
z.Health = s
}
}
if v, ok := p.Properties["size"]; ok {
z.Size = num(v.Value)
}
if v, ok := p.Properties["free"]; ok {
z.Free = num(v.Value)
}
out = append(out, z)
}
return out
}
// parseZpoolText reads `zpool list -H -p` rows: name\thealth\tsize\tfree.
func parseZpoolText(b []byte) []zfsPool {
var out []zfsPool
for _, line := range strings.Split(string(b), "\n") {
f := strings.Split(line, "\t")
if len(f) < 4 {
continue
}
size, _ := strconv.ParseFloat(f[2], 64)
free, _ := strconv.ParseFloat(f[3], 64)
out = append(out, zfsPool{Name: f[0], Health: f[1], Size: size, Free: free})
}
return out
}
// ---------- GPU ----------
// readGPU prefers nvidia-smi (CSV query); otherwise walks /sys/class/drm for
// AMD/Intel cards exposing gpu_busy_percent and hwmon temps.
func readGPU() []gpuStat {
if _, err := exec.LookPath("nvidia-smi"); err == nil {
return readNvidiaGPU()
}
return readGPUSysfs()
}
func readNvidiaGPU() []gpuStat {
return parseNvidiaCSV(runOut(4*time.Second, "nvidia-smi",
"--query-gpu=name,temperature.gpu,utilization.gpu,memory.used,memory.total",
"--format=csv,noheader,nounits"))
}
// parseNvidiaCSV reads rows: name, tempC, util%, memUsed MiB, memTotal MiB.
func parseNvidiaCSV(b []byte) []gpuStat {
var out []gpuStat
for _, line := range strings.Split(strings.TrimSpace(string(b)), "\n") {
f := strings.Split(line, ",")
if len(f) < 5 {
continue
}
g := gpuStat{Name: strings.TrimSpace(f[0])}
g.TempC, _ = strconv.ParseFloat(strings.TrimSpace(f[1]), 64)
g.UtilPct, _ = strconv.ParseFloat(strings.TrimSpace(f[2]), 64)
g.MemUsed, _ = strconv.ParseFloat(strings.TrimSpace(f[3]), 64)
g.MemTotal, _ = strconv.ParseFloat(strings.TrimSpace(f[4]), 64)
g.MemUsed *= 1 << 20
g.MemTotal *= 1 << 20
out = append(out, g)
}
return out
}
// readGPUSysfs pulls util/temp for each card from DRM sysfs.
func readGPUSysfs() []gpuStat {
cards, _ := filepath.Glob("/sys/class/drm/card[0-9]")
var out []gpuStat
for _, card := range cards {
dev := card + "/device"
g := gpuStat{Name: filepath.Base(card)}
if b, err := os.ReadFile(dev + "/gpu_busy_percent"); err == nil {
g.UtilPct, _ = strconv.ParseFloat(strings.TrimSpace(string(b)), 64)
}
// hwmon: first temp1_input under device/hwmon/hwmonN.
if hws, err := filepath.Glob(dev + "/hwmon/hwmon*"); err == nil {
for _, hw := range hws {
if b, err := os.ReadFile(hw + "/temp1_input"); err == nil {
if milli, err := strconv.ParseFloat(strings.TrimSpace(string(b)), 64); err == nil {
g.TempC = milli / 1000
}
break
}
}
}
// Driver name via uevent DRIVER= for a friendlier label.
if b, err := os.ReadFile(dev + "/uevent"); err == nil {
for _, line := range strings.Split(string(b), "\n") {
if v, ok := strings.CutPrefix(line, "DRIVER="); ok {
g.Name += " (" + v + ")"
}
}
}
if g.UtilPct > 0 || g.TempC > 0 {
out = append(out, g)
}
}
return out
}
+33 -37
View File
@@ -9,13 +9,11 @@ package main
import (
"bytes"
"context"
"encoding/json"
"flag"
"fmt"
"io"
"log"
"net"
"net/http"
"os"
"runtime"
@@ -96,11 +94,40 @@ type sample struct {
Load15 float64 `json:"load15"`
Temps map[string]float64 `json:"temps,omitempty"`
Containers []containerStat `json:"containers,omitempty"`
Smart []smartDisk `json:"smart,omitempty"`
ZFS []zfsPool `json:"zfs,omitempty"`
GPU []gpuStat `json:"gpu,omitempty"`
}
type containerStat struct {
Name string `json:"name"`
State string `json:"state"`
CPU float64 `json:"cpu,omitempty"`
MemUsed float64 `json:"memUsed,omitempty"`
MemLimit float64 `json:"memLimit,omitempty"`
}
// Optional-collector shapes — mirror api.SmartDisk/ZFSPool/GPUStat.
type smartDisk struct {
Device string `json:"device"`
Model string `json:"model"`
Passed *bool `json:"passed,omitempty"`
TempC float64 `json:"tempC,omitempty"`
}
type zfsPool struct {
Name string `json:"name"`
Health string `json:"health"`
Size float64 `json:"size"`
Free float64 `json:"free"`
}
type gpuStat struct {
Name string `json:"name"`
TempC float64 `json:"tempC,omitempty"`
UtilPct float64 `json:"utilPct,omitempty"`
MemUsed float64 `json:"memUsed,omitempty"`
MemTotal float64 `json:"memTotal,omitempty"`
}
type collector struct {
@@ -141,6 +168,9 @@ func (c *collector) sample(interval int) sample {
s.UptimeS = readUptime()
s.Temps = readTemps()
s.Containers = readContainers()
s.Smart = readSMART()
s.ZFS = readZFS()
s.GPU = readGPU()
return s
}
@@ -325,38 +355,4 @@ func readTemps() map[string]float64 {
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
}
+48
View File
@@ -0,0 +1,48 @@
package main
import "testing"
func TestContainerCPUPct(t *testing.T) {
var s dockerStatsJSON
s.CPUStats.CPUUsage.TotalUsage = 300
s.PreCPUStats.CPUUsage.TotalUsage = 100
s.CPUStats.SystemCPUUsage = 1000
s.PreCPUStats.SystemCPUUsage = 500
s.CPUStats.OnlineCPUs = 4
if got := containerCPUPct(&s); got != 160 {
t.Fatalf("expected 160%%, got %v", got)
}
// No deltas -> 0, no div-by-zero.
s.PreCPUStats.CPUUsage.TotalUsage = s.CPUStats.CPUUsage.TotalUsage
s.PreCPUStats.SystemCPUUsage = s.CPUStats.SystemCPUUsage
if got := containerCPUPct(&s); got != 0 {
t.Fatalf("expected 0, got %v", got)
}
}
func TestParseZpoolText(t *testing.T) {
out := parseZpoolText([]byte("tank\tONLINE\t1000000000\t400000000\nfast\tDEGRADED\t500\t100\n"))
if len(out) != 2 || out[0].Name != "tank" || out[0].Size != 1e9 || out[1].Health != "DEGRADED" {
t.Fatalf("bad parse: %+v", out)
}
if parseZpoolText(nil) != nil {
t.Fatal("nil input should yield nil")
}
}
func TestParseZpoolJSON(t *testing.T) {
b := []byte(`{"output_version":{"major":0},"pools":{"tank":{"name":"tank","properties":{
"health":{"value":"ONLINE"},"size":{"value":1000000000},"free":{"value":250000000}}}}}`)
out := parseZpoolJSON(b)
if len(out) != 1 || out[0].Health != "ONLINE" || out[0].Free != 250000000 {
t.Fatalf("bad parse: %+v", out)
}
}
func TestParseNvidiaCSV(t *testing.T) {
out := parseNvidiaCSV([]byte("RTX 3080, 62, 14, 2048, 10240\n"))
if len(out) != 1 || out[0].Name != "RTX 3080" || out[0].TempC != 62 ||
out[0].MemUsed != 2048*1<<20 || out[0].MemTotal != 10240*1<<20 {
t.Fatalf("bad parse: %+v", out)
}
}
+4 -4
View File
@@ -42,10 +42,10 @@ require (
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
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/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.41.0 // indirect
golang.org/x/crypto v0.57.0 // indirect
golang.org/x/sync v0.23.0 // indirect
golang.org/x/sys v0.48.0 // indirect
golang.org/x/text v0.42.0 // indirect
google.golang.org/protobuf v1.36.12 // indirect
modernc.org/libc v1.75.7 // indirect
modernc.org/mathutil v1.7.1 // indirect
+10
View File
@@ -99,18 +99,28 @@ golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+46
View File
@@ -0,0 +1,46 @@
package api
import "encoding/json"
// alertRules is the per-target rules blob stored in monitors.alerts /
// domains.alerts. Zero values keep pre-rules behavior: first failure alerts,
// no latency rule, cert threshold = alertDaysBefore, unmuted.
//
// consecutiveFailures — monitor must fail N checks in a row before it flips down
// latencyWarnMs — notify once when an up check's ping crosses above this
// certDays — domain TLS-expiry threshold (overrides alertDaysBefore)
// mute — suppress notifications; incidents/status still record
type alertRules struct {
ConsecutiveFailures int `json:"consecutiveFailures"`
LatencyWarnMs int `json:"latencyWarnMs"`
CertDays int `json:"certDays"`
Mute bool `json:"mute"`
}
func parseAlertRules(raw json.RawMessage) alertRules {
var r alertRules
_ = json.Unmarshal(raw, &r)
return r
}
// consecutiveDowns counts failed heartbeats, newest-first, stopping at the
// first up. The just-written heartbeat is included.
func (s *Server) consecutiveDowns(monitorID string, limit int) int {
var n int
_ = s.db.QueryRow(`SELECT COUNT(*) FROM (
SELECT status FROM heartbeats WHERE monitor_id = ?
ORDER BY checked_at DESC, rowid DESC LIMIT ?)
WHERE status = 'down'`, monitorID, limit).Scan(&n)
return n
}
// prevPing returns the ping of the heartbeat before the latest, or -1.
func (s *Server) prevPing(monitorID string) int {
var p int
if err := s.db.QueryRow(`SELECT ping_ms FROM heartbeats
WHERE monitor_id = ? ORDER BY checked_at DESC, rowid DESC
LIMIT 1 OFFSET 1`, monitorID).Scan(&p); err != nil {
return -1
}
return p
}
+324
View File
@@ -0,0 +1,324 @@
package api
import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/tdvorak/dash/internal/domain"
)
// ---------- transports ----------
func TestNotifyTransports(t *testing.T) {
srv := testServer(t)
defer srv.Close()
// One receiver records body + headers + query per request.
type hit struct {
body map[string]any
raw string
title string
path string
}
got := make(chan hit, 8)
hook := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var m map[string]any
buf, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(buf, &m)
got <- hit{body: m, raw: string(buf), title: r.Header.Get("Title"), path: r.URL.RequestURI()}
w.WriteHeader(http.StatusOK)
}))
defer hook.Close()
put := func(kv string) {
if res, _ := do(t, "PUT", srv.URL+"/api/settings", kv); res.StatusCode != http.StatusOK {
t.Fatalf("put %s: %d", kv, res.StatusCode)
}
}
// slack + discord + gotify + ntfy all configured; test fires each.
put(`{"notify_slack":{"url":"` + hook.URL + `/slack"}}`)
put(`{"notify_discord":{"url":"` + hook.URL + `/discord"}}`)
put(`{"notify_gotify":{"server":"` + hook.URL + `","token":"gtok"}}`)
put(`{"notify_ntfy":{"server":"` + hook.URL + `","topic":"alerts"}}`)
res, out := do(t, "POST", srv.URL+"/api/notify/test", "")
if res.StatusCode != http.StatusOK {
t.Fatalf("notify test: %d", res.StatusCode)
}
results := out["results"].(map[string]any)
for _, name := range []string{"slack", "discord", "gotify", "ntfy"} {
if results[name] != "ok" {
t.Fatalf("%s: %v", name, results[name])
}
}
seen := map[string]hit{}
for i := 0; i < 4; i++ {
select {
case h := <-got:
seen[h.path] = h
case <-time.After(3 * time.Second):
t.Fatal("timed out waiting for transports")
}
}
if seen["/slack"].body["text"] == "" {
t.Fatalf("slack payload missing text: %v", seen["/slack"].body)
}
if seen["/discord"].body["content"] == "" {
t.Fatalf("discord payload missing content: %v", seen["/discord"].body)
}
if !strings.HasPrefix(seen["/message?token=gtok"].path, "/message?token=") {
t.Fatalf("gotify hit missing: %v", seen)
}
if h := seen["/alerts"]; h.raw != "Dash test notification" || h.title != "dash" {
t.Fatalf("ntfy payload wrong: %q title=%q", h.raw, h.title)
}
// Legacy string webhook still works alongside presets.
put(`{"notify_webhook":"` + hook.URL + `/legacy"}`)
res, _ = do(t, "POST", srv.URL+"/api/notify/test", `{"transport":"webhook"}`)
if res.StatusCode != http.StatusOK {
t.Fatalf("webhook test: %d", res.StatusCode)
}
select {
case h := <-got:
if h.path != "/legacy" || h.body["event"] != "test" {
t.Fatalf("legacy webhook payload: %v", h.body)
}
case <-time.After(3 * time.Second):
t.Fatal("legacy webhook never fired")
}
// Named unconfigured transport -> 400.
res, _ = do(t, "POST", srv.URL+"/api/notify/test", `{"transport":"smtp"}`)
if res.StatusCode != http.StatusBadRequest {
t.Fatalf("expected 400 for unconfigured smtp, got %d", res.StatusCode)
}
}
// fakeSMTP is the smallest lawful SMTP server: EHLO/MAIL/RCPT/DATA/QUIT.
func fakeSMTP(t *testing.T) (addr string, gotMsg <-chan string) {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { ln.Close() })
msgs := make(chan string, 1)
go func() {
conn, err := ln.Accept()
if err != nil {
return
}
defer conn.Close()
r := bufio.NewReader(conn)
fmt.Fprint(conn, "220 fake\r\n")
var data strings.Builder
inData := false
for {
line, err := r.ReadString('\n')
if err != nil {
return
}
line = strings.TrimRight(line, "\r\n")
if inData {
if line == "." {
inData = false
fmt.Fprint(conn, "250 queued\r\n")
continue
}
data.WriteString(line + "\n")
continue
}
switch {
case strings.HasPrefix(line, "EHLO"):
fmt.Fprint(conn, "250-fake\r\n250 OK\r\n")
case strings.HasPrefix(line, "MAIL"), strings.HasPrefix(line, "RCPT"):
fmt.Fprint(conn, "250 OK\r\n")
case strings.HasPrefix(line, "DATA"):
fmt.Fprint(conn, "354 go\r\n")
inData = true
case strings.HasPrefix(line, "QUIT"):
fmt.Fprint(conn, "221 bye\r\n")
msgs <- data.String()
return
default:
fmt.Fprint(conn, "250 OK\r\n")
}
}
}()
return ln.Addr().String(), msgs
}
func TestNotifySMTP(t *testing.T) {
srv := testServer(t)
defer srv.Close()
addr, gotMsg := fakeSMTP(t)
host, port, _ := net.SplitHostPort(addr)
res, _ := do(t, "PUT", srv.URL+"/api/settings",
`{"notify_smtp":{"host":"`+host+`","port":"`+port+`","from":"dash@local","to":"me@local"}}`)
if res.StatusCode != http.StatusOK {
t.Fatalf("put smtp: %d", res.StatusCode)
}
res, out := do(t, "POST", srv.URL+"/api/notify/test", `{"transport":"smtp"}`)
if res.StatusCode != http.StatusOK || out["results"].(map[string]any)["smtp"] != "ok" {
t.Fatalf("smtp test: %d %v", res.StatusCode, out)
}
select {
case msg := <-gotMsg:
if !strings.Contains(msg, "Subject: [Dash] Dash test notification") {
t.Fatalf("smtp message missing subject: %q", msg)
}
case <-time.After(3 * time.Second):
t.Fatal("smtp server never received the message")
}
}
// ---------- alert rules ----------
func TestAlertRulesConsecutiveAndMute(t *testing.T) {
srv := testServer(t)
defer srv.Close()
got := make(chan map[string]any, 8)
hook := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var m map[string]any
_ = json.NewDecoder(r.Body).Decode(&m)
got <- m
w.WriteHeader(http.StatusOK)
}))
defer hook.Close()
do(t, "PUT", srv.URL+"/api/settings", `{"notify_webhook":"`+hook.URL+`"}`)
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) }))
defer up.Close()
down := `{"url":"http://127.0.0.1:1","timeoutS":1}`
// Monitor requiring 2 consecutive failures before going down.
res, m := do(t, "POST", srv.URL+"/api/monitors",
`{"name":"Flap","type":"http","url":"`+up.URL+`","timeoutS":1,"alerts":{"consecutiveFailures":2}}`)
if res.StatusCode != http.StatusCreated {
t.Fatalf("create: %v", m)
}
id := m["id"].(string)
do(t, "POST", srv.URL+"/api/monitors/"+id+"/check", "") // -> up
do(t, "PATCH", srv.URL+"/api/monitors/"+id, down)
do(t, "POST", srv.URL+"/api/monitors/"+id+"/check", "") // 1st failure: hold
_, mv := do(t, "GET", srv.URL+"/api/monitors/"+id, "")
if mv["status"] != "up" {
t.Fatalf("expected held status up after 1 failure, got %v", mv["status"])
}
do(t, "POST", srv.URL+"/api/monitors/"+id+"/check", "") // 2nd failure: down
_, mv = do(t, "GET", srv.URL+"/api/monitors/"+id, "")
if mv["status"] != "down" {
t.Fatalf("expected down after 2 failures, got %v", mv["status"])
}
select {
case body := <-got:
if body["event"] != "monitor.down" {
t.Fatalf("expected monitor.down, got %v", body["event"])
}
case <-time.After(3 * time.Second):
t.Fatal("monitor.down never fired after threshold")
}
// Muted monitor: status still flips, no notification.
res, m2 := do(t, "POST", srv.URL+"/api/monitors",
`{"name":"Quiet","type":"http","url":"`+up.URL+`","timeoutS":1,"alerts":{"mute":true}}`)
if res.StatusCode != http.StatusCreated {
t.Fatalf("create muted: %v", m2)
}
id2 := m2["id"].(string)
do(t, "POST", srv.URL+"/api/monitors/"+id2+"/check", "")
do(t, "PATCH", srv.URL+"/api/monitors/"+id2, down)
do(t, "POST", srv.URL+"/api/monitors/"+id2+"/check", "")
_, mv = do(t, "GET", srv.URL+"/api/monitors/"+id2, "")
if mv["status"] != "down" {
t.Fatalf("muted monitor should still go down, got %v", mv["status"])
}
select {
case body := <-got:
if body["name"] == "Quiet" {
t.Fatal("muted monitor still notified")
}
case <-time.After(1500 * time.Millisecond):
}
}
func TestAlertRulesBadJSON(t *testing.T) {
srv := testServer(t)
defer srv.Close()
res, _ := do(t, "POST", srv.URL+"/api/monitors",
`{"name":"X","type":"http","url":"http://127.0.0.1:1","alerts":"nope"}`)
if res.StatusCode != http.StatusBadRequest {
t.Fatalf("expected 400 for invalid alerts, got %d", res.StatusCode)
}
}
// ---------- subdomains ----------
func TestSubdomainDiscovery(t *testing.T) {
srv := testServer(t)
defer srv.Close()
// Stub the network seams: CT returns candidates, resolver answers a few.
oldCT, oldRes := domain.CTNames, domain.ResolveIPs
domain.CTNames = func(_ context.Context, d string) ([]string, error) {
return []string{"api." + d + "\nwww." + d, "*." + d, "other.com"}, nil
}
domain.ResolveIPs = func(_ context.Context, name string) ([]string, error) {
if strings.HasPrefix(name, "api.") {
return []string{"10.0.0.1"}, nil
}
return []string{"93.184.216.34"}, nil
}
t.Cleanup(func() { domain.CTNames, domain.ResolveIPs = oldCT, oldRes })
res, d := do(t, "POST", srv.URL+"/api/domains", `{"name":"example.com"}`)
if res.StatusCode != http.StatusCreated {
t.Fatalf("create domain: %v", d)
}
id := d["id"].(string)
// First refresh ran discovery inline (sub_checked_at was NULL).
res, subs := do(t, "GET", srv.URL+"/api/domains/"+id+"/subdomains", "")
if res.StatusCode != http.StatusOK {
t.Fatalf("list subdomains: %d", res.StatusCode)
}
list, _ := subs["subdomains"].([]any) // response is a bare array — check below
_ = list
// do() decodes objects only; re-fetch raw for the array.
raw, err := http.Get(srv.URL + "/api/domains/" + id + "/subdomains")
if err != nil {
t.Fatal(err)
}
var arr []map[string]any
_ = json.NewDecoder(raw.Body).Decode(&arr)
raw.Body.Close()
names := map[string][]string{}
for _, s := range arr {
ips := []string{}
for _, ip := range s["ips"].([]any) {
ips = append(ips, ip.(string))
}
names[s["name"].(string)] = ips
}
if len(names) != 3 || len(names["api.example.com"]) != 1 || names["api.example.com"][0] != "10.0.0.1" {
t.Fatalf("unexpected subdomains: %v", names)
}
if _, bad := names["other.com"]; bad {
t.Fatal("foreign name leaked into subdomains")
}
}
+15
View File
@@ -60,8 +60,20 @@ func (s *Server) routes() *gin.Engine {
r.Use(accessLog(s.log), gin.Recovery())
v1 := r.Group("/api")
v1.Use(s.requireAuth())
v1.GET("/healthz", s.healthz)
v1.GET("/auth/session", s.authSession)
v1.POST("/auth/setup", s.authSetup)
v1.POST("/auth/login", s.authLogin)
v1.POST("/auth/logout", s.authLogout)
v1.POST("/auth/disable", s.authDisable)
v1.GET("/boards", s.listBoards)
v1.POST("/boards", s.createBoard)
v1.PATCH("/boards/:id", s.updateBoard)
v1.DELETE("/boards/:id", s.deleteBoard)
v1.GET("/sections", s.listSections)
v1.POST("/sections", s.createSection)
v1.PATCH("/sections/:id", s.updateSection)
@@ -91,6 +103,8 @@ func (s *Server) routes() *gin.Engine {
v1.DELETE("/domains/:id", s.deleteDomain)
v1.POST("/domains/:id/refresh", s.refreshDomainH)
v1.GET("/domains/:id/checks", s.domainChecks)
v1.GET("/domains/:id/subdomains", s.listSubdomains)
v1.POST("/domains/:id/subdomains/refresh", s.refreshSubdomains)
v1.POST("/notify/test", s.notifyTest)
v1.GET("/systems", s.listSystemsH)
@@ -167,6 +181,7 @@ type Section struct {
Name string `json:"name"`
Position float64 `json:"position"`
Collapsed bool `json:"collapsed"`
BoardID string `json:"boardId"`
Items []Item `json:"items"`
}
+70
View File
@@ -786,3 +786,73 @@ func TestValidURL(t *testing.T) {
}
}
}
func TestBoards(t *testing.T) {
srv := testServer(t)
defer srv.Close()
// Default board exists and owns new sections.
var boards []Board
resp, err := http.Get(srv.URL + "/api/boards")
if err != nil || json.NewDecoder(resp.Body).Decode(&boards) != nil {
t.Fatalf("list boards: %v", err)
}
resp.Body.Close()
if len(boards) != 1 || boards[0].ID != "b_home" {
t.Fatalf("expected seeded home board, got %+v", boards)
}
_, sec := do(t, "POST", srv.URL+"/api/sections", `{"name":"A"}`)
if sec["boardId"] != "b_home" {
t.Fatalf("section should default to home board: %v", sec["boardId"])
}
// Create a second board; slug is derived and unique.
_, b2 := do(t, "POST", srv.URL+"/api/boards", `{"name":"Lab Net"}`)
if b2["slug"] != "lab-net" {
t.Fatalf("bad slug: %v", b2["slug"])
}
_, b3 := do(t, "POST", srv.URL+"/api/boards", `{"name":"Lab Net"}`)
if b3["slug"] == b2["slug"] {
t.Fatalf("slug collision: %v", b3["slug"])
}
// Sections scope to their board.
_, s2 := do(t, "POST", srv.URL+"/api/sections", `{"name":"B","boardId":"`+b2["id"].(string)+`"}`)
if s2["boardId"] != b2["id"] {
t.Fatalf("section not scoped to board: %v", s2["boardId"])
}
var home, lab []Section
r1, _ := http.Get(srv.URL + "/api/sections?board=b_home")
_ = json.NewDecoder(r1.Body).Decode(&home)
r1.Body.Close()
r2, _ := http.Get(srv.URL + "/api/sections?board=" + b2["id"].(string))
_ = json.NewDecoder(r2.Body).Decode(&lab)
r2.Body.Close()
if len(home) != 1 || home[0].Name != "A" || len(lab) != 1 || lab[0].Name != "B" {
t.Fatalf("board filter broken: home=%v lab=%v", home, lab)
}
// Rename keeps the slug; deleting cascades sections.
_, rb := do(t, "PATCH", srv.URL+"/api/boards/"+b3["id"].(string), `{"name":"Renamed"}`)
if rb["name"] != "Renamed" || rb["slug"] == "renamed" {
t.Fatalf("rename should not change slug: %+v", rb)
}
res, _ := do(t, "DELETE", srv.URL+"/api/boards/"+b2["id"].(string), "")
if res.StatusCode != http.StatusNoContent {
t.Fatalf("delete board: %d", res.StatusCode)
}
var labAfter []Section
r3, _ := http.Get(srv.URL + "/api/sections?board=" + b2["id"].(string))
_ = json.NewDecoder(r3.Body).Decode(&labAfter)
r3.Body.Close()
if len(labAfter) != 0 {
t.Fatalf("sections should cascade with board: %v", labAfter)
}
// Down to two boards — delete both and the last delete must refuse.
do(t, "DELETE", srv.URL+"/api/boards/"+b3["id"].(string), "")
res, _ = do(t, "DELETE", srv.URL+"/api/boards/b_home", "")
if res.StatusCode != http.StatusBadRequest {
t.Fatalf("deleting last board should 400, got %d", res.StatusCode)
}
}
+261
View File
@@ -0,0 +1,261 @@
package api
// Opt-in local auth — disabled by default; enabling creates the first admin.
// Local username + bcrypt password only (no OIDC/SSO — deliberate non-goal).
// Sessions are random tokens in SQLite behind an httpOnly SameSite=Lax cookie;
// login is rate-limited per IP. Public endpoints (status pages, badges, push,
// agent ingest, metrics) bypass the middleware.
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"net/http"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"golang.org/x/crypto/bcrypt"
)
const (
sessionCookie = "dash_session"
sessionTTL = 7 * 24 * time.Hour
// Rotate when less than half the TTL remains.
sessionRotate = sessionTTL / 2
)
func (s *Server) authEnabled() bool {
var raw string
if err := s.db.QueryRow(`SELECT value FROM settings WHERE key = 'auth_enabled'`).Scan(&raw); err != nil {
return false
}
var on bool
_ = json.Unmarshal([]byte(raw), &on)
return on
}
func (s *Server) userCount() int {
var n int
_ = s.db.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&n)
return n
}
// ---------- sessions ----------
func (s *Server) createSession(c *gin.Context, userID string) {
var tok [32]byte
_, _ = rand.Read(tok[:])
token := hex.EncodeToString(tok[:])
exp := time.Now().Add(sessionTTL)
_, _ = s.db.Exec(`INSERT INTO sessions (token, user_id, expires_at) VALUES (?,?,?)`,
token, userID, exp.UTC().Format("2006-01-02T15:04:05.000Z"))
http.SetCookie(c.Writer, &http.Cookie{
Name: sessionCookie,
Value: token,
Path: "/",
Expires: exp,
HttpOnly: true,
Secure: c.Request.TLS != nil,
SameSite: http.SameSiteLaxMode,
})
}
// sessionUser resolves the cookie to a user, sliding-rotating near expiry.
// Returns "" when absent/expired.
func (s *Server) sessionUser(c *gin.Context) string {
ck, err := c.Cookie(sessionCookie)
if err != nil || ck == "" {
return ""
}
var userID, expires string
err = s.db.QueryRow(`SELECT user_id, expires_at FROM sessions WHERE token = ?`, ck).Scan(&userID, &expires)
if err != nil {
return ""
}
exp, err := time.Parse("2006-01-02T15:04:05.000Z", expires)
if err != nil || time.Now().After(exp) {
_, _ = s.db.Exec(`DELETE FROM sessions WHERE token = ?`, ck)
return ""
}
if time.Until(exp) < sessionRotate {
// Rotate: drop the old token, issue a fresh one.
_, _ = s.db.Exec(`DELETE FROM sessions WHERE token = ?`, ck)
s.createSession(c, userID)
}
return userID
}
// ---------- rate limiting (login only, in-memory per-IP) ----------
var loginMu sync.Mutex
var loginHits = map[string][]time.Time{}
func loginAllowed(ip string) bool {
loginMu.Lock()
defer loginMu.Unlock()
cut := time.Now().Add(-time.Minute)
kept := loginHits[ip][:0]
for _, t := range loginHits[ip] {
if t.After(cut) {
kept = append(kept, t)
}
}
if len(kept) >= 5 {
loginHits[ip] = kept
return false
}
loginHits[ip] = append(kept, time.Now())
return true
}
// ---------- middleware ----------
// publicAPI lists /api paths reachable without a session. Auth endpoints must
// stay open (login itself); push/ingest/badge/metrics/public status carry
// their own tokens or are intentionally public.
func publicAPI(path string) bool {
switch {
case strings.HasPrefix(path, "/api/auth/"),
strings.HasPrefix(path, "/api/push/"),
path == "/api/systems/ingest",
strings.HasPrefix(path, "/api/badge/"),
path == "/api/metrics",
strings.HasPrefix(path, "/api/healthz"),
strings.HasSuffix(path, "/public"):
return true
}
return false
}
// requireAuth gates every /api route when the auth_enabled setting is on.
func (s *Server) requireAuth() gin.HandlerFunc {
return func(c *gin.Context) {
if !s.authEnabled() || publicAPI(c.Request.URL.Path) {
c.Next()
return
}
if s.sessionUser(c) == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "authentication required"})
return
}
c.Next()
}
}
// ---------- handlers ----------
type credentials struct {
Username string `json:"username"`
Password string `json:"password"`
}
// sessionInfo reports auth state for the frontend shell — always public so
// the UI can decide between the board and the login screen.
func (s *Server) authSession(c *gin.Context) {
on := s.authEnabled()
out := gin.H{"enabled": on, "authed": false, "setup": false}
if !on {
c.JSON(http.StatusOK, out)
return
}
out["setup"] = s.userCount() == 0
if uid := s.sessionUser(c); uid != "" {
var name string
_ = s.db.QueryRow(`SELECT username FROM users WHERE id = ?`, uid).Scan(&name)
out["authed"] = true
out["username"] = name
}
c.JSON(http.StatusOK, out)
}
// setup creates the first user — only while auth is on and no users exist.
func (s *Server) authSetup(c *gin.Context) {
if !s.authEnabled() {
fail(c, http.StatusBadRequest, "auth is not enabled")
return
}
if s.userCount() > 0 {
fail(c, http.StatusConflict, "admin user already exists")
return
}
var in credentials
if err := c.ShouldBindJSON(&in); err != nil ||
len(strings.TrimSpace(in.Username)) < 2 || len(in.Password) < 8 {
fail(c, http.StatusBadRequest, "username (2+) and password (8+) required")
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(in.Password), bcrypt.DefaultCost)
if err != nil {
fail(c, http.StatusInternalServerError, "hash failed")
return
}
uid := newID("u")
if _, err := s.db.Exec(`INSERT INTO users (id, username, pass_hash) VALUES (?,?,?)`,
uid, strings.TrimSpace(in.Username), string(hash)); err != nil {
fail(c, http.StatusConflict, "username taken")
return
}
s.createSession(c, uid)
c.JSON(http.StatusCreated, gin.H{"username": in.Username})
}
func (s *Server) authLogin(c *gin.Context) {
if !s.authEnabled() {
fail(c, http.StatusBadRequest, "auth is not enabled")
return
}
ip := c.ClientIP()
if !loginAllowed(ip) {
fail(c, http.StatusTooManyRequests, "too many attempts — wait a minute")
return
}
var in credentials
if err := c.ShouldBindJSON(&in); err != nil {
fail(c, http.StatusBadRequest, "invalid body")
return
}
var uid, hash string
err := s.db.QueryRow(`SELECT id, pass_hash FROM users WHERE username = ?`,
strings.TrimSpace(in.Username)).Scan(&uid, &hash)
if err != nil {
// Unknown user — run bcrypt anyway so timing doesn't leak existence.
hash = "$2a$10$7EqJtq98hPqEX7fNZaFWoOhi5sA5t6p7K0dGm7ZC4yQ0yCk0cFk0K"
}
// CompareHashAndPassword is constant-time; err != nil covers both
// unknown user (dummy hash above) and wrong password.
if err != nil || bcrypt.CompareHashAndPassword([]byte(hash), []byte(in.Password)) != nil {
fail(c, http.StatusUnauthorized, "invalid credentials")
return
}
s.createSession(c, uid)
c.JSON(http.StatusOK, gin.H{"username": in.Username})
}
func (s *Server) authLogout(c *gin.Context) {
if ck, err := c.Cookie(sessionCookie); err == nil {
_, _ = s.db.Exec(`DELETE FROM sessions WHERE token = ?`, ck)
}
http.SetCookie(c.Writer, &http.Cookie{
Name: sessionCookie, Value: "", Path: "/", MaxAge: -1, HttpOnly: true,
})
c.Status(http.StatusNoContent)
}
// authDisable turns auth off and wipes users+sessions. Requires a session —
// you can't switch the lock off from outside.
func (s *Server) authDisable(c *gin.Context) {
if s.sessionUser(c) == "" {
fail(c, http.StatusUnauthorized, "authentication required")
return
}
_, _ = s.db.Exec(`DELETE FROM sessions`)
_, _ = s.db.Exec(`DELETE FROM users`)
if _, err := s.db.Exec(`INSERT INTO settings (key, value) VALUES ('auth_enabled','false')
ON CONFLICT(key) DO UPDATE SET value = excluded.value`); err != nil {
s.log.Error("auth disable", zap.Error(err))
}
c.Status(http.StatusNoContent)
}
+196
View File
@@ -0,0 +1,196 @@
package api
import (
"encoding/json"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/tdvorak/dash/internal/db"
"go.uber.org/zap"
)
// authServer returns the bare server (no httptest) so tests can plant
// sessions and inspect cookies directly.
func authServer(t *testing.T) *Server {
t.Helper()
d, err := db.Open(filepath.Join(t.TempDir(), "dash.db"))
if err != nil {
t.Fatalf("open db: %v", err)
}
t.Cleanup(func() { d.Close() })
gin.SetMode(gin.TestMode)
// Login rate limits are keyed by IP in a package-global map — reset so
// tests don't count each other's attempts.
loginMu.Lock()
loginHits = map[string][]time.Time{}
loginMu.Unlock()
return newServer(zap.NewNop(), d, t.TempDir())
}
func req(t *testing.T, s *Server, method, path, body, cookie string) *httptest.ResponseRecorder {
t.Helper()
w := httptest.NewRecorder()
r := httptest.NewRequest(method, path, strings.NewReader(body))
r.Header.Set("Content-Type", "application/json")
if cookie != "" {
r.Header.Set("Cookie", sessionCookie+"="+cookie)
}
s.routes().ServeHTTP(w, r)
return w
}
func sessionCookieOf(w *httptest.ResponseRecorder) string {
for _, c := range w.Result().Cookies() {
if c.Name == sessionCookie {
return c.Value
}
}
return ""
}
func enableAuth(t *testing.T, s *Server) {
t.Helper()
if w := req(t, s, "PUT", "/api/settings", `{"auth_enabled":true}`, ""); w.Code != 200 {
t.Fatalf("enable auth: %d", w.Code)
}
}
func TestAuthDisabledByDefault(t *testing.T) {
s := authServer(t)
if w := req(t, s, "GET", "/api/sections", "", ""); w.Code != 200 {
t.Fatalf("sections open when auth off: %d", w.Code)
}
var out map[string]any
w := req(t, s, "GET", "/api/auth/session", "", "")
_ = json.NewDecoder(w.Body).Decode(&out)
if out["enabled"] != false {
t.Fatalf("auth should report disabled: %v", out)
}
}
func TestAuthLifecycle(t *testing.T) {
s := authServer(t)
enableAuth(t, s)
// Everything gates now.
if w := req(t, s, "GET", "/api/sections", "", ""); w.Code != 401 {
t.Fatalf("expected 401, got %d", w.Code)
}
// Session endpoint stays public and reports setup mode.
var sess map[string]any
w := req(t, s, "GET", "/api/auth/session", "", "")
_ = json.NewDecoder(w.Body).Decode(&sess)
if sess["enabled"] != true || sess["setup"] != true || sess["authed"] != false {
t.Fatalf("bad session payload: %v", sess)
}
// Public surfaces stay open even gated.
for _, p := range []string{"/api/healthz", "/api/metrics", "/api/badge/uptime/x.svg"} {
if w := req(t, s, "GET", p, "", ""); w.Code == 401 {
t.Fatalf("public path %s gated", p)
}
}
// Setup creates the first admin and returns a session cookie.
w = req(t, s, "POST", "/api/auth/setup", `{"username":"admin","password":"correct-horse"}`, "")
if w.Code != 201 {
t.Fatalf("setup: %d body=%s", w.Code, w.Body)
}
cookie := sessionCookieOf(w)
if cookie == "" {
t.Fatal("setup did not set a session cookie")
}
// Second setup blocked.
if w := req(t, s, "POST", "/api/auth/setup", `{"username":"x","password":"another-pass"}`, ""); w.Code != 409 {
t.Fatalf("second setup should 409, got %d", w.Code)
}
// Authed request with the cookie.
if w := req(t, s, "GET", "/api/sections", "", cookie); w.Code != 200 {
t.Fatalf("authed request: %d", w.Code)
}
// Logout kills the session.
req(t, s, "POST", "/api/auth/logout", "", cookie)
if w := req(t, s, "GET", "/api/sections", "", cookie); w.Code != 401 {
t.Fatalf("expected 401 after logout, got %d", w.Code)
}
// Login wrong password -> 401; right -> 200 + fresh cookie.
if w := req(t, s, "POST", "/api/auth/login", `{"username":"admin","password":"wrong-password"}`, ""); w.Code != 401 {
t.Fatalf("bad login should 401, got %d", w.Code)
}
w = req(t, s, "POST", "/api/auth/login", `{"username":"admin","password":"correct-horse"}`, "")
if w.Code != 200 {
t.Fatalf("login: %d", w.Code)
}
if sessionCookieOf(w) == "" {
t.Fatal("login set no cookie")
}
}
func TestAuthLoginRateLimit(t *testing.T) {
s := authServer(t)
enableAuth(t, s)
req(t, s, "POST", "/api/auth/setup", `{"username":"admin","password":"correct-horse"}`, "")
req(t, s, "POST", "/api/auth/logout", "", "")
for i := 0; i < 5; i++ {
req(t, s, "POST", "/api/auth/login", `{"username":"admin","password":"nope-nope"}`, "")
}
if w := req(t, s, "POST", "/api/auth/login", `{"username":"admin","password":"correct-horse"}`, ""); w.Code != 429 {
t.Fatalf("6th login should be rate-limited, got %d", w.Code)
}
}
func TestAuthSessionExpiryAndRotation(t *testing.T) {
s := authServer(t)
enableAuth(t, s)
w := req(t, s, "POST", "/api/auth/setup", `{"username":"admin","password":"correct-horse"}`, "")
cookie := sessionCookieOf(w)
// Expired session -> 401.
_, _ = s.db.Exec(`UPDATE sessions SET expires_at = ? WHERE token = ?`,
time.Now().Add(-time.Hour).UTC().Format("2006-01-02T15:04:05.000Z"), cookie)
if w := req(t, s, "GET", "/api/sections", "", cookie); w.Code != 401 {
t.Fatalf("expired session should 401, got %d", w.Code)
}
// Near-expiry session rotates: response carries a new cookie.
fresh := req(t, s, "POST", "/api/auth/login", `{"username":"admin","password":"correct-horse"}`, "")
cookie = sessionCookieOf(fresh)
_, _ = s.db.Exec(`UPDATE sessions SET expires_at = ? WHERE token = ?`,
time.Now().Add(time.Hour).UTC().Format("2006-01-02T15:04:05.000Z"), cookie)
w = req(t, s, "GET", "/api/sections", "", cookie)
if w.Code != 200 {
t.Fatalf("rotating request should pass, got %d", w.Code)
}
newCookie := sessionCookieOf(w)
if newCookie == "" || newCookie == cookie {
t.Fatal("expected session rotation to issue a new token")
}
// Old token is dead.
if w := req(t, s, "GET", "/api/sections", "", cookie); w.Code != 401 {
t.Fatalf("old token should be invalid after rotation, got %d", w.Code)
}
}
func TestAuthDisable(t *testing.T) {
s := authServer(t)
enableAuth(t, s)
w := req(t, s, "POST", "/api/auth/setup", `{"username":"admin","password":"correct-horse"}`, "")
cookie := sessionCookieOf(w)
// Unauthenticated disable is refused.
if w := req(t, s, "POST", "/api/auth/disable", "", ""); w.Code != 401 {
t.Fatalf("disable without session should 401, got %d", w.Code)
}
if w := req(t, s, "POST", "/api/auth/disable", "", cookie); w.Code != 204 {
t.Fatalf("disable: %d", w.Code)
}
if w := req(t, s, "GET", "/api/sections", "", ""); w.Code != 200 {
t.Fatalf("sections should reopen after disable, got %d", w.Code)
}
}
+27 -11
View File
@@ -10,8 +10,16 @@ import (
// board loads the full tree: sections ordered, each with items and urls.
// Three flat queries assembled in memory — the whole board is ~100 rows.
func (s *Server) board() ([]Section, error) {
secRows, err := s.db.Query(`SELECT id, name, position, collapsed FROM sections ORDER BY position`)
// boardID filters to one board; "" returns every section (export, legacy).
// COALESCE maps pre-boards rows onto the default 'b_home' board.
func (s *Server) board(boardID string) ([]Section, error) {
q := `SELECT id, name, position, collapsed, COALESCE(board_id,'b_home') FROM sections`
var args []any
if boardID != "" {
q += ` WHERE board_id = ?`
args = append(args, boardID)
}
secRows, err := s.db.Query(q+` ORDER BY position`, args...)
if err != nil {
return nil, err
}
@@ -21,7 +29,7 @@ func (s *Server) board() ([]Section, error) {
byID := map[string]*Section{}
for secRows.Next() {
var sec Section
if err := secRows.Scan(&sec.ID, &sec.Name, &sec.Position, &sec.Collapsed); err != nil {
if err := secRows.Scan(&sec.ID, &sec.Name, &sec.Position, &sec.Collapsed, &sec.BoardID); err != nil {
return nil, err
}
sec.Items = []Item{}
@@ -89,7 +97,7 @@ func (s *Server) board() ([]Section, error) {
}
func (s *Server) listSections(c *gin.Context) {
sections, err := s.board()
sections, err := s.board(c.Query("board"))
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
@@ -100,18 +108,26 @@ func (s *Server) listSections(c *gin.Context) {
func (s *Server) createSection(c *gin.Context) {
var in struct {
Name string `json:"name"`
BoardID string `json:"boardId"`
}
if err := c.ShouldBindJSON(&in); err != nil || in.Name == "" {
fail(c, http.StatusBadRequest, "name required")
return
}
boardID := in.BoardID
if boardID == "" {
boardID = "b_home"
}
var maxPos sql.NullFloat64
if err := s.db.QueryRow(`SELECT MAX(position) FROM sections`).Scan(&maxPos); err != nil {
if err := s.db.QueryRow(`SELECT MAX(position) FROM sections WHERE board_id = ?`, boardID).Scan(&maxPos); err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
sec := Section{ID: newID("s"), Name: in.Name, Position: midpoint(ptrOr(maxPos), nil), Items: []Item{}}
if _, err := s.db.Exec(`INSERT INTO sections (id, name, position) VALUES (?, ?, ?)`, sec.ID, sec.Name, sec.Position); err != nil {
sec := Section{ID: newID("s"), Name: in.Name, Position: midpoint(ptrOr(maxPos), nil), BoardID: boardID, Items: []Item{}}
// The subquery yields NULL for an unknown board id instead of an FK error;
// board() maps NULL back onto the default board.
if _, err := s.db.Exec(`INSERT INTO sections (id, name, position, board_id)
VALUES (?, ?, ?, (SELECT id FROM boards WHERE id = ?))`, sec.ID, sec.Name, sec.Position, boardID); err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
@@ -140,8 +156,8 @@ func (s *Server) updateSection(c *gin.Context) {
}
}
var sec Section
err := s.db.QueryRow(`SELECT id, name, position, collapsed FROM sections WHERE id = ?`, c.Param("id")).
Scan(&sec.ID, &sec.Name, &sec.Position, &sec.Collapsed)
err := s.db.QueryRow(`SELECT id, name, position, collapsed, COALESCE(board_id,'b_home') FROM sections WHERE id = ?`, c.Param("id")).
Scan(&sec.ID, &sec.Name, &sec.Position, &sec.Collapsed, &sec.BoardID)
if notFound(err) {
fail(c, http.StatusNotFound, "section not found")
return
@@ -192,8 +208,8 @@ func (s *Server) reorderSection(c *gin.Context) {
return
}
var sec Section
if err := s.db.QueryRow(`SELECT id, name, position, collapsed FROM sections WHERE id = ?`, in.ID).
Scan(&sec.ID, &sec.Name, &sec.Position, &sec.Collapsed); err != nil {
if err := s.db.QueryRow(`SELECT id, name, position, collapsed, COALESCE(board_id,'b_home') FROM sections WHERE id = ?`, in.ID).
Scan(&sec.ID, &sec.Name, &sec.Position, &sec.Collapsed, &sec.BoardID); err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
+120
View File
@@ -0,0 +1,120 @@
package api
// Multiple boards — sections carry board_id; the board list is small and
// lives next to the section tree. Deleting a board cascades its sections.
import (
"database/sql"
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
)
// Board is a named dashboard page reachable at /b/<slug>.
type Board struct {
ID string `json:"id"`
Slug string `json:"slug"`
Name string `json:"name"`
Position float64 `json:"position"`
}
func (s *Server) listBoards(c *gin.Context) {
rows, err := s.db.Query(`SELECT id, slug, name, position FROM boards ORDER BY position`)
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
defer rows.Close()
out := []Board{}
for rows.Next() {
var b Board
if err := rows.Scan(&b.ID, &b.Slug, &b.Name, &b.Position); err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
out = append(out, b)
}
c.JSON(http.StatusOK, out)
}
func (s *Server) createBoard(c *gin.Context) {
var in struct {
Name string `json:"name"`
}
if err := c.ShouldBindJSON(&in); err != nil || strings.TrimSpace(in.Name) == "" {
fail(c, http.StatusBadRequest, "name required")
return
}
name := strings.TrimSpace(in.Name)
slug := slugify(name)
if slug == "" {
slug = "board"
}
// Uniquify: 'media', 'media-2', 'media-3'…
base := slug
for i := 2; ; i++ {
var n int
_ = s.db.QueryRow(`SELECT COUNT(*) FROM boards WHERE slug = ?`, slug).Scan(&n)
if n == 0 {
break
}
slug = base + "-" + strconv.Itoa(i)
}
var maxPos sql.NullFloat64
_ = s.db.QueryRow(`SELECT MAX(position) FROM boards`).Scan(&maxPos)
b := Board{ID: newID("b"), Slug: slug, Name: name, Position: midpoint(ptrOr(maxPos), nil)}
if _, err := s.db.Exec(`INSERT INTO boards (id, slug, name, position) VALUES (?,?,?,?)`,
b.ID, b.Slug, b.Name, b.Position); err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
c.JSON(http.StatusCreated, b)
}
func (s *Server) updateBoard(c *gin.Context) {
var in struct {
Name *string `json:"name"`
}
if err := c.ShouldBindJSON(&in); err != nil || in.Name == nil || strings.TrimSpace(*in.Name) == "" {
fail(c, http.StatusBadRequest, "name required")
return
}
// Slug stays fixed on rename — links shouldn't break.
res, err := s.db.Exec(`UPDATE boards SET name = ? WHERE id = ?`, strings.TrimSpace(*in.Name), c.Param("id"))
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
if n, _ := res.RowsAffected(); n == 0 {
fail(c, http.StatusNotFound, "board not found")
return
}
var b Board
if err := s.db.QueryRow(`SELECT id, slug, name, position FROM boards WHERE id = ?`, c.Param("id")).
Scan(&b.ID, &b.Slug, &b.Name, &b.Position); err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
c.JSON(http.StatusOK, b)
}
func (s *Server) deleteBoard(c *gin.Context) {
var n int
_ = s.db.QueryRow(`SELECT COUNT(*) FROM boards`).Scan(&n)
if n <= 1 {
fail(c, http.StatusBadRequest, "cannot delete the last board")
return
}
res, err := s.db.Exec(`DELETE FROM boards 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, "board not found")
return
}
c.Status(http.StatusNoContent)
}
+50 -7
View File
@@ -27,7 +27,8 @@ const domainCols = `id, name, tld, active, auto_renew, alert_days_before, tags,
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`
records, providers,
favicon_url, lookup_error, interval_h, last_checked, position, alerts, created_at`
// domainRow mirrors the domains table; JSON text columns decode on scan.
type domainRow struct {
@@ -82,17 +83,20 @@ type domainRow struct {
HostingProvider string `json:"hostingProvider"`
CAProvider string `json:"caProvider"`
Headers map[string]string `json:"headers"`
Records []domain.DNSRecord `json:"records"`
Providers domain.Providers `json:"providers"`
FaviconURL string `json:"faviconUrl"`
LookupError string `json:"lookupError"`
IntervalH int `json:"intervalH"`
LastChecked *string `json:"lastChecked"`
Position float64 `json:"position"`
Alerts json.RawMessage `json:"alerts"`
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 tags, statuses, ns, mx, txt, v4, v6, altNames, headers, records, providers, alerts 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,
@@ -103,11 +107,12 @@ func scanDomain(row interface{ Scan(...any) error }) (*domainRow, error) {
&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)
&headers, &records, &providers, &d.FaviconURL, &d.LookupError, &d.IntervalH, &d.LastChecked,
&d.Position, &alerts, &d.CreatedAt)
if err != nil {
return nil, err
}
d.Alerts = json.RawMessage(alerts)
d.Active = active != 0
d.AutoRenew = autoRenew != 0
d.PrivacyEnabled = privacy != 0
@@ -122,6 +127,9 @@ func scanDomain(row interface{ Scan(...any) error }) (*domainRow, error) {
d.SSLAltNames = decodeList(altNames)
d.Headers = map[string]string{}
_ = json.Unmarshal([]byte(headers), &d.Headers)
d.Records = []domain.DNSRecord{}
_ = json.Unmarshal([]byte(records), &d.Records)
_ = json.Unmarshal([]byte(providers), &d.Providers)
return &d, nil
}
@@ -200,6 +208,7 @@ type domainInput struct {
IntervalH *int `json:"intervalH"`
Tags []string `json:"tags"`
Notes *string `json:"notes"`
Alerts json.RawMessage `json:"alerts"`
}
func (s *Server) createDomain(c *gin.Context) {
@@ -218,6 +227,13 @@ func (s *Server) createDomain(c *gin.Context) {
if in.Tags == nil {
tags = []byte("[]")
}
if in.Alerts != nil {
var rules alertRules
if err := json.Unmarshal(in.Alerts, &rules); err != nil {
fail(c, http.StatusBadRequest, "alerts must be an object")
return
}
}
_, err := s.db.Exec(`INSERT INTO domains
(id, name, tld, active, auto_renew, alert_days_before, tags, notes, interval_h, position)
VALUES (?,?,?,?,?,?,?,?,?,?)`,
@@ -228,6 +244,9 @@ func (s *Server) createDomain(c *gin.Context) {
fail(c, http.StatusConflict, "domain already tracked or insert failed")
return
}
if in.Alerts != nil {
_, _ = s.db.Exec(`UPDATE domains SET alerts = ? WHERE id = ?`, string(in.Alerts), id)
}
// First lookup runs inline so the response carries real data.
s.refreshDomain(id)
d, err := s.loadDomain(id)
@@ -298,6 +317,14 @@ func (s *Server) patchDomain(c *gin.Context) {
tags, _ := json.Marshal(in.Tags)
_, _ = s.db.Exec(`UPDATE domains SET tags = ? WHERE id = ?`, string(tags), id)
}
if in.Alerts != nil {
var rules alertRules
if err := json.Unmarshal(in.Alerts, &rules); err != nil {
fail(c, http.StatusBadRequest, "alerts must be an object")
return
}
_, _ = s.db.Exec(`UPDATE domains SET alerts = ? WHERE id = ?`, string(in.Alerts), id)
}
d, err := s.loadDomain(id)
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
@@ -338,6 +365,10 @@ func (s *Server) refreshDomain(id string) {
return
}
nv := viewOf(next)
rules := parseAlertRules(next.Alerts)
if rules.Mute {
return
}
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))
@@ -345,10 +376,19 @@ func (s *Server) refreshDomain(id string) {
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 {
// TLS threshold: alerts.certDays overrides alertDaysBefore.
certDays := next.AlertDaysBefore
if rules.CertDays > 0 {
certDays = rules.CertDays
}
sslNow := nv.SSLDaysUntilExpiry != nil && *nv.SSLDaysUntilExpiry <= certDays
sslPrev := prev.SSLDaysUntilExpiry != nil && *prev.SSLDaysUntilExpiry <= certDays
if sslNow && !sslPrev {
s.notify("domain.sslExpiring", next.Name,
fmt.Sprintf("TLS certificate for %s expires in %d days", next.Name, *nv.SSLDaysUntilExpiry))
}
// Subdomain discovery rides the refresh schedule, gated to once/day.
s.maybeSweepSubdomains(next)
}
func (s *Server) refreshDomainH(c *gin.Context) {
@@ -383,6 +423,8 @@ func (s *Server) applyResult(id string, r *domain.Result) {
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)
recs, _ := json.Marshal(r.Records)
provs, _ := json.Marshal(r.Providers)
_, err := s.db.Exec(`UPDATE domains SET
expiry_date=?, creation_date=?, updated_date=?, registrar_name=?,
@@ -394,7 +436,8 @@ func (s *Server) applyResult(id string, r *domain.Result) {
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=?,
hosting_provider=?, ca_provider=?, headers=?, records=?, providers=?,
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,
@@ -406,7 +449,7 @@ func (s *Server) applyResult(id string, r *domain.Result) {
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)
string(hdr), string(recs), string(provs), r.FaviconURL, truncate(r.Error, 300), id)
if err != nil {
s.log.Error("domain apply", zap.Error(err))
return
+2 -2
View File
@@ -30,11 +30,11 @@ func (s *Server) importExternal(c *gin.Context) {
fail(c, http.StatusBadRequest, err.Error())
return
}
if err := s.replaceBoard(sections, nil); err != nil {
if err := s.replaceBoard(sections, nil, nil); err != nil {
failImport(c, err)
return
}
board, err := s.board()
board, err := s.board("")
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
+44 -6
View File
@@ -20,21 +20,22 @@ import (
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`
retries, active, status, push_token, tags, notes, alerts, position, last_check, created_at`
func scanMonitor(row interface{ Scan(...any) error }) (*monitor.Monitor, error) {
var m monitor.Monitor
var tags string
var tags, alerts 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)
&tags, &m.Notes, &alerts, &m.Position, &m.LastCheck, &m.CreatedAt)
if err != nil {
return nil, err
}
m.KeywordInvert = invert != 0
m.Active = active != 0
m.Alerts = json.RawMessage(alerts)
_ = json.Unmarshal([]byte(tags), &m.Tags)
if m.Tags == nil {
m.Tags = []string{}
@@ -142,6 +143,7 @@ type monitorInput struct {
Active *bool `json:"active"`
Tags []string `json:"tags"`
Notes *string `json:"notes"`
Alerts json.RawMessage `json:"alerts"`
}
func validMonitorType(t string) bool {
@@ -209,17 +211,26 @@ func (s *Server) createMonitor(c *gin.Context) {
if in.Tags == nil {
tags = []byte("[]")
}
alerts := "{}"
if in.Alerts != nil {
var rules alertRules
if err := json.Unmarshal(in.Alerts, &rules); err != nil {
fail(c, http.StatusBadRequest, "alerts must be an object")
return
}
alerts = string(in.Alerts)
}
_, 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 (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
retries, active, push_token, tags, notes, alerts, 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)
token, string(tags), strOr(in.Notes), alerts, float64(time.Now().UnixNano())/1e9)
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
@@ -294,6 +305,14 @@ func (s *Server) patchMonitor(c *gin.Context) {
tags, _ := json.Marshal(in.Tags)
_, _ = s.db.Exec(`UPDATE monitors SET tags = ? WHERE id = ?`, string(tags), id)
}
if in.Alerts != nil {
var rules alertRules
if err := json.Unmarshal(in.Alerts, &rules); err != nil {
fail(c, http.StatusBadRequest, "alerts must be an object")
return
}
_, _ = s.db.Exec(`UPDATE monitors SET alerts = ? WHERE id = ?`, string(in.Alerts), id)
}
if in.Active != nil {
// pause/resume flips the stored status back to pending for a fresh probe
status := "pending"
@@ -412,6 +431,9 @@ func (s *Server) runScheduler(done <-chan struct{}) {
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 sessions WHERE expires_at < strftime('%Y-%m-%dT%H:%M:%fZ','now')`); err != nil {
s.log.Error("session prune", zap.Error(err))
}
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))
}
@@ -473,8 +495,13 @@ func (s *Server) runCheck(m *monitor.Monitor) *monitor.Result {
s.writeHeartbeat(m, res)
prev := m.Status
next := "down"
rules := parseAlertRules(m.Alerts)
if res.Up {
next = "up"
} else if n := rules.ConsecutiveFailures; n > 1 && prev != "down" &&
s.consecutiveDowns(m.ID, n) < n {
// Flap damping: hold the previous status until N checks in a row fail.
next = prev
}
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 {
@@ -483,6 +510,14 @@ func (s *Server) runCheck(m *monitor.Monitor) *monitor.Result {
if prev != next && prev != "pending" {
s.onMonitorTransition(m, prev, next)
}
// Latency warn: notify once when ping crosses above the threshold.
if rules.LatencyWarnMs > 0 && res.Up && res.PingMs >= rules.LatencyWarnMs &&
!rules.Mute && prev != "pending" && !s.inMaintenance(m.ID) {
if p := s.prevPing(m.ID); p >= 0 && p < rules.LatencyWarnMs {
s.notify("monitor.slow", m.Name,
fmt.Sprintf("Monitor %q latency %dms (threshold %dms)", m.Name, res.PingMs, rules.LatencyWarnMs))
}
}
return res
}
@@ -507,6 +542,9 @@ func (s *Server) onMonitorTransition(m *monitor.Monitor, prev, next string) {
return
}
s.autoIncident(m.ID, m.Name, next)
if parseAlertRules(m.Alerts).Mute {
return
}
target := m.URL
if target == "" {
target = m.Hostname
+242 -43
View File
@@ -3,19 +3,29 @@ package api
import (
"bytes"
"context"
"crypto/tls"
"encoding/json"
"fmt"
"net"
"net/http"
"net/smtp"
"strings"
"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.
// Notifications: a small transport registry. Each transport is configured via
// the settings key notify_<name> holding a JSON object of fields; a transport
// is active when its required fields are set. Multiple transports may be
// active — notify() fans out to all of them.
//
// notify_webhook keeps back-compat: a plain string value means {"url": value}.
//
// Secrets (smtp_pass, tokens) live in settings like any other value and are
// never logged. Single-user self-hosted is the threat model; P5d auth gates
// the settings endpoint when enabled.
type notifyPayload struct {
Event string `json:"event"`
@@ -26,54 +36,213 @@ type notifyPayload struct {
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
type transport struct {
name string
required []string
send func(ctx context.Context, cfg map[string]string, p notifyPayload) error
}
// notify fires the webhook asynchronously; delivery failures are logged, never fatal.
var transports = []transport{
{"webhook", []string{"url"}, sendWebhook},
{"slack", []string{"url"}, sendSlack},
{"discord", []string{"url"}, sendDiscord},
{"telegram", []string{"token", "chat_id"}, sendTelegram},
{"gotify", []string{"server", "token"}, sendGotify},
{"ntfy", []string{"server", "topic"}, sendNtfy},
{"smtp", []string{"host", "port", "from", "to"}, sendSMTP},
}
// notifyCfg loads notify_<name> from settings. Values are JSON: an object for
// presets, or a bare string (legacy webhook) meaning {"url": s}.
func (s *Server) notifyCfg(name string) map[string]string {
var raw string
if err := s.db.QueryRow(`SELECT value FROM settings WHERE key = 'notify_`+name+`'`).Scan(&raw); err != nil {
return nil
}
var m map[string]any
if err := json.Unmarshal([]byte(raw), &m); err != nil {
var str string
if err := json.Unmarshal([]byte(raw), &str); err == nil && name == "webhook" {
return map[string]string{"url": str}
}
return nil
}
out := make(map[string]string, len(m))
for k, v := range m {
if str, ok := v.(string); ok {
out[k] = str
}
}
return out
}
// configured reports whether all required fields are present. Partially
// configured transports count as configured so their send error surfaces.
func (t transport) configured(cfg map[string]string) bool {
if cfg == nil {
return false
}
for _, k := range t.required {
if strings.TrimSpace(cfg[k]) == "" {
return false
}
}
return true
}
// notify fans an event out to every configured transport, asynchronously.
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)
for _, tr := range transports {
cfg := s.notifyCfg(tr.name)
if !tr.configured(cfg) {
continue
}
tr, cfg := tr, cfg
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := tr.send(ctx, cfg, p); err != nil {
s.log.Warn("notify failed", zap.String("transport", tr.name),
zap.String("event", event), zap.Error(err))
}
}()
}
}
func (s *Server) deliver(url string, p notifyPayload) {
body, _ := json.Marshal(p)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// postJSON posts a JSON body and reports non-2xx as an error.
func postJSON(ctx context.Context, url string, payload any, headers map[string]string) error {
if parseHTTPURL(url) == "" {
return fmt.Errorf("not an http(s) URL")
}
body, _ := json.Marshal(payload)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return
return err
}
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)
for k, v := range headers {
req.Header.Set(k, v)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
s.log.Warn("webhook delivery failed", zap.String("event", p.Event), zap.Error(err))
return
return err
}
resp.Body.Close()
if resp.StatusCode >= 400 {
s.log.Warn("webhook rejected", zap.String("event", p.Event), zap.Int("status", resp.StatusCode))
defer resp.Body.Close()
if resp.StatusCode >= 300 {
return fmt.Errorf("returned %d", resp.StatusCode)
}
return nil
}
func sendWebhook(ctx context.Context, cfg map[string]string, p notifyPayload) error {
return postJSON(ctx, cfg["url"], p, map[string]string{
"Title": p.Text, "X-Dash-Event": p.Event,
})
}
func sendSlack(ctx context.Context, cfg map[string]string, p notifyPayload) error {
return postJSON(ctx, cfg["url"], map[string]any{"text": p.Text}, nil)
}
func sendDiscord(ctx context.Context, cfg map[string]string, p notifyPayload) error {
return postJSON(ctx, cfg["url"], map[string]any{"content": p.Content}, nil)
}
func sendTelegram(ctx context.Context, cfg map[string]string, p notifyPayload) error {
base := strings.TrimRight(cfg["server"], "/") // optional self-hosted bot-api
if base == "" {
base = "https://api.telegram.org"
}
url := base + "/bot" + cfg["token"] + "/sendMessage"
return postJSON(ctx, url, map[string]any{
"chat_id": cfg["chat_id"], "text": p.Text,
}, nil)
}
func sendGotify(ctx context.Context, cfg map[string]string, p notifyPayload) error {
server := strings.TrimRight(cfg["server"], "/")
url := server + "/message?token=" + cfg["token"]
return postJSON(ctx, url, map[string]any{
"title": p.Name, "message": p.Message, "priority": 5,
}, nil)
}
func sendNtfy(ctx context.Context, cfg map[string]string, p notifyPayload) error {
server := strings.TrimRight(cfg["server"], "/")
url := server + "/" + strings.TrimLeft(cfg["topic"], "/")
if parseHTTPURL(url) == "" {
return fmt.Errorf("not an http(s) URL")
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(p.Message))
if err != nil {
return err
}
req.Header.Set("Title", p.Name)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
return fmt.Errorf("returned %d", resp.StatusCode)
}
return nil
}
// sendSMTP uses net/smtp with a hard deadline, opportunistic STARTTLS, and
// plain auth only when a user is configured.
func sendSMTP(ctx context.Context, cfg map[string]string, p notifyPayload) error {
host, port := cfg["host"], cfg["port"]
addr := net.JoinHostPort(host, port)
d := net.Dialer{Timeout: 10 * time.Second}
conn, err := d.DialContext(ctx, "tcp", addr)
if err != nil {
return err
}
defer conn.Close()
if dl, ok := ctx.Deadline(); ok {
_ = conn.SetDeadline(dl)
} else {
_ = conn.SetDeadline(time.Now().Add(15 * time.Second))
}
c, err := smtp.NewClient(conn, host)
if err != nil {
return err
}
defer c.Close()
if ok, _ := c.Extension("STARTTLS"); ok {
if err := c.StartTLS(&tls.Config{ServerName: host}); err != nil {
return err
}
}
if cfg["user"] != "" {
if err := c.Auth(smtp.PlainAuth("", cfg["user"], cfg["pass"], host)); err != nil {
return err
}
}
if err := c.Mail(cfg["from"]); err != nil {
return err
}
for _, rcpt := range strings.Split(cfg["to"], ",") {
if err := c.Rcpt(strings.TrimSpace(rcpt)); err != nil {
return err
}
}
w, err := c.Data()
if err != nil {
return err
}
msg := fmt.Sprintf("From: %s\r\nTo: %s\r\nSubject: [Dash] %s\r\n\r\n%s\r\n",
cfg["from"], cfg["to"], p.Text, p.Message)
if _, err := w.Write([]byte(msg)); err != nil {
return err
}
if err := w.Close(); err != nil {
return err
}
return c.Quit()
}
// parseHTTPURL enforces http/https on the configured webhook — same SSRF
@@ -85,12 +254,42 @@ func parseHTTPURL(raw string) string {
return raw
}
// notifyTest lets the settings UI verify the configured webhook.
// notifyTest sends a test event synchronously so the UI gets a real result.
// Optional body {"transport": "smtp"} limits the test to one transport.
func (s *Server) notifyTest(c *gin.Context) {
if s.webhookURL() == "" {
fail(c, http.StatusBadRequest, "notify_webhook not configured")
var in struct {
Transport string `json:"transport"`
}
_ = c.ShouldBindJSON(&in)
p := notifyPayload{Event: "test", Name: "dash", Text: "Dash test notification",
Content: "Dash test notification", Message: "Dash test notification",
At: time.Now().UTC().Format(time.RFC3339)}
results := map[string]string{}
for _, tr := range transports {
if in.Transport != "" && tr.name != in.Transport {
continue
}
cfg := s.notifyCfg(tr.name)
if !tr.configured(cfg) {
if in.Transport != "" {
fail(c, http.StatusBadRequest, tr.name+" not configured")
return
}
s.notify("test", "dash", "Dash test notification")
c.JSON(http.StatusOK, gin.H{"status": "sent"})
continue
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second)
err := tr.send(ctx, cfg, p)
cancel()
if err != nil {
results[tr.name] = "error: " + err.Error()
} else {
results[tr.name] = "ok"
}
}
if len(results) == 0 {
fail(c, http.StatusBadRequest, "no notification transport configured")
return
}
c.JSON(http.StatusOK, gin.H{"results": results})
}
+95
View File
@@ -0,0 +1,95 @@
package api
import (
"context"
"encoding/json"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/tdvorak/dash/internal/domain"
"go.uber.org/zap"
)
// Subdomain discovery: crt.sh + DNS, persisted per domain and swept at most
// once a day inside the domain refresh. CT names are untrusted strings —
// stored as text, rendered as text, never linked.
type subdomainView struct {
Name string `json:"name"`
IPs []string `json:"ips"`
FirstSeen string `json:"firstSeen"`
LastSeen string `json:"lastSeen"`
}
func (s *Server) listSubdomains(c *gin.Context) {
rows, err := s.db.Query(`SELECT name, ips, first_seen, last_seen
FROM subdomains WHERE domain_id = ? ORDER BY name`, c.Param("id"))
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
defer rows.Close()
out := []subdomainView{}
for rows.Next() {
var v subdomainView
var ips string
if err := rows.Scan(&v.Name, &ips, &v.FirstSeen, &v.LastSeen); err != nil {
continue
}
_ = json.Unmarshal([]byte(ips), &v.IPs)
if v.IPs == nil {
v.IPs = []string{}
}
out = append(out, v)
}
c.JSON(http.StatusOK, out)
}
// refreshSubdomains forces a discovery run for one domain.
func (s *Server) refreshSubdomains(c *gin.Context) {
id := c.Param("id")
d, err := s.loadDomain(id)
if notFound(err) {
fail(c, http.StatusNotFound, "domain not found")
return
}
s.sweepSubdomains(d)
s.listSubdomains(c)
}
// sweepSubdomains runs CT+DNS discovery and upserts rows. Called from
// refreshDomain when sub_checked_at is stale (>24h), and on manual refresh.
func (s *Server) sweepSubdomains(d *domainRow) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
found, err := domain.DiscoverSubdomains(ctx, d.Name)
if err != nil {
s.log.Warn("subdomain discovery", zap.String("domain", d.Name), zap.Error(err))
return
}
for _, f := range found {
ips, _ := json.Marshal(f.IPs)
if _, err := s.db.Exec(`INSERT INTO subdomains (id, domain_id, name, ips)
VALUES (?,?,?,?)
ON CONFLICT(domain_id, name) DO UPDATE SET ips = excluded.ips,
last_seen = strftime('%Y-%m-%dT%H:%M:%fZ','now')`,
newID("sub"), d.ID, f.Name, string(ips)); err != nil {
s.log.Error("subdomain upsert", zap.Error(err))
}
}
_, _ = s.db.Exec(`UPDATE domains SET sub_checked_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`, d.ID)
}
// maybeSweepSubdomains gates discovery to once per day per domain.
func (s *Server) maybeSweepSubdomains(d *domainRow) {
var checked *string
_ = s.db.QueryRow(`SELECT sub_checked_at FROM domains WHERE id = ?`, d.ID).Scan(&checked)
if checked != nil && *checked != "" {
if t, err := time.Parse("2006-01-02T15:04:05.000Z", *checked); err == nil &&
time.Since(t) < 24*time.Hour {
return
}
}
s.sweepSubdomains(d)
}
+35 -2
View File
@@ -41,13 +41,46 @@ type StatSample struct {
Load15 float64 `json:"load15"`
Temps map[string]float64 `json:"temps"`
Containers []ContainerStat `json:"containers"`
// Optional collectors — present only when the host has the tools.
Smart []SmartDisk `json:"smart,omitempty"`
ZFS []ZFSPool `json:"zfs,omitempty"`
GPU []GPUStat `json:"gpu,omitempty"`
}
// ContainerStat is one Docker container's liveness; per-container CPU/mem
// needs cgroup sampling — deferred (v1 ships name+state only).
// ContainerStat is one Docker container's liveness plus resource use.
// CPU is percent of one core (may exceed 100); Mem* are bytes. Zero when the
// agent couldn't reach the stats endpoint.
type ContainerStat struct {
Name string `json:"name"`
State string `json:"state"`
CPU float64 `json:"cpu,omitempty"`
MemUsed float64 `json:"memUsed,omitempty"`
MemLimit float64 `json:"memLimit,omitempty"`
}
// SmartDisk is one drive's SMART summary (smartctl).
type SmartDisk struct {
Device string `json:"device"`
Model string `json:"model"`
Passed *bool `json:"passed,omitempty"`
TempC float64 `json:"tempC,omitempty"`
}
// ZFSPool is one pool's health and space (zpool).
type ZFSPool struct {
Name string `json:"name"`
Health string `json:"health"`
Size float64 `json:"size"`
Free float64 `json:"free"`
}
// GPUStat is one GPU's basic metrics (sysfs or nvidia-smi).
type GPUStat struct {
Name string `json:"name"`
TempC float64 `json:"tempC,omitempty"`
UtilPct float64 `json:"utilPct,omitempty"`
MemUsed float64 `json:"memUsed,omitempty"`
MemTotal float64 `json:"memTotal,omitempty"`
}
// System is one monitored host; `latest` is the newest StatSample.
+42 -7
View File
@@ -28,6 +28,7 @@ type Export struct {
Version int `json:"version"`
Settings map[string]json.RawMessage `json:"settings"`
Sections []Section `json:"sections"`
Boards []Board `json:"boards,omitempty"`
Monitors []json.RawMessage `json:"monitors,omitempty"`
Domains []json.RawMessage `json:"domains,omitempty"`
Systems []json.RawMessage `json:"systems,omitempty"`
@@ -74,12 +75,21 @@ func (s *Server) exportBoard(c *gin.Context) {
fail(c, http.StatusInternalServerError, err.Error())
return
}
sections, err := s.board()
sections, err := s.board("")
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
ex := Export{Version: 2, Settings: settings, Sections: sections}
if bRows, err := s.db.Query(`SELECT id, slug, name, position FROM boards ORDER BY position`); err == nil {
for bRows.Next() {
var b Board
if bRows.Scan(&b.ID, &b.Slug, &b.Name, &b.Position) == nil {
ex.Boards = append(ex.Boards, b)
}
}
bRows.Close()
}
for _, t := range []struct {
name string
dst *[]json.RawMessage
@@ -103,11 +113,11 @@ func (s *Server) importBoard(c *gin.Context) {
fail(c, http.StatusBadRequest, "invalid export payload")
return
}
if err := s.replaceBoard(in.Sections, in.Settings); err != nil {
if err := s.replaceBoard(in.Sections, in.Boards, in.Settings); err != nil {
failImport(c, err)
return
}
sections, err := s.board()
sections, err := s.board("")
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
@@ -116,8 +126,10 @@ func (s *Server) importBoard(c *gin.Context) {
}
// 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 {
// Dash export import and the external-config importers. A payload carrying
// boards replaces the board list too; one without (v1/v2 exports, foreign
// imports) leaves boards alone and sections land on the default board.
func (s *Server) replaceBoard(sections []Section, boards []Board, settings map[string]json.RawMessage) error {
tx, err := s.db.Begin()
if err != nil {
return err
@@ -129,12 +141,35 @@ func (s *Server) replaceBoard(sections []Section, settings map[string]json.RawMe
return err
}
}
if len(boards) > 0 {
if _, err := tx.Exec(`DELETE FROM boards`); err != nil {
return err
}
for _, b := range boards {
id, slug := b.ID, b.Slug
if slug == "" {
slug = slugify(b.Name)
}
if id == "" || slug == "" {
return fmt.Errorf("%w: board missing id or slug", errBadInput)
}
if _, err := tx.Exec(`INSERT INTO boards (id, slug, name, position) VALUES (?, ?, ?, ?)`,
id, slug, orDefault(b.Name, slug), b.Position); err != nil {
return err
}
}
// The default board must survive import — orphaned sections map to it.
if _, err := tx.Exec(`INSERT OR IGNORE INTO boards (id, slug, name, position) VALUES ('b_home','home','Home',0)`); 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 {
if _, err := tx.Exec(`INSERT INTO sections (id, name, position, collapsed, board_id)
VALUES (?, ?, ?, ?, (SELECT id FROM boards WHERE id = ?))`,
sec.ID, sec.Name, sec.Position, sec.Collapsed, sec.BoardID); err != nil {
return err
}
for _, it := range sec.Items {
@@ -0,0 +1,10 @@
-- +goose Up
-- Per-target alert rules as a JSON blob: {consecutiveFailures, latencyWarnMs,
-- certDays, mute}. '{}' = defaults (alert on first failure, no latency rule,
-- cert threshold = alert_days_before, unmuted).
ALTER TABLE monitors ADD COLUMN alerts JSON NOT NULL DEFAULT '{}';
ALTER TABLE domains ADD COLUMN alerts JSON NOT NULL DEFAULT '{}';
-- +goose Down
ALTER TABLE monitors DROP COLUMN alerts;
ALTER TABLE domains DROP COLUMN alerts;
@@ -0,0 +1,18 @@
-- +goose Up
CREATE TABLE subdomains (
id TEXT PRIMARY KEY,
domain_id TEXT NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
name TEXT NOT NULL,
ips JSON NOT NULL DEFAULT '[]',
first_seen TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
last_seen TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
UNIQUE(domain_id, name)
);
CREATE INDEX subdomains_domain_idx ON subdomains(domain_id);
-- Discovery runs at most once per day per domain, inside the domain sweep.
ALTER TABLE domains ADD COLUMN sub_checked_at TEXT;
-- +goose Down
ALTER TABLE domains DROP COLUMN sub_checked_at;
DROP TABLE IF EXISTS subdomains;
@@ -0,0 +1,22 @@
-- +goose Up
-- Opt-in local auth. Empty users table = setup mode; auth_enabled settings
-- key gates the middleware. Sessions live in SQLite — restart-safe, and a
-- `dash-agent`-style bearer token path is unaffected.
CREATE TABLE users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
pass_hash TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
CREATE TABLE sessions (
token TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
expires_at TEXT NOT NULL
);
CREATE INDEX sessions_expiry_idx ON sessions(expires_at);
-- +goose Down
DROP TABLE IF EXISTS sessions;
DROP TABLE IF EXISTS users;
@@ -0,0 +1,32 @@
-- +goose Up
-- Multiple boards. Every section belongs to exactly one board; the seeded
-- 'home' board adopts existing rows. NULL board_id maps to 'home' at read
-- time so old imports/exports keep working without a backfill.
CREATE TABLE boards (
id TEXT PRIMARY KEY,
slug TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
position REAL NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
INSERT INTO boards (id, slug, name, position) VALUES ('b_home', 'home', 'Home', 1024);
ALTER TABLE sections ADD COLUMN board_id TEXT REFERENCES boards(id) ON DELETE CASCADE;
UPDATE sections SET board_id = 'b_home';
CREATE INDEX sections_board_pos ON sections(board_id, position);
-- +goose Down
DROP INDEX IF EXISTS sections_board_pos;
-- SQLite can't drop columns pre-3.35; rebuild instead.
CREATE TABLE sections_new (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
position REAL NOT NULL,
collapsed INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
INSERT INTO sections_new (id, name, position, collapsed, created_at)
SELECT id, name, position, collapsed, created_at FROM sections;
DROP TABLE sections;
ALTER TABLE sections_new RENAME TO sections;
DROP TABLE boards;
@@ -0,0 +1,9 @@
-- +goose Up
-- Structured DNS records + provider identifications for the domain detail
-- page. JSON blobs — the flat columns stay for compat and check history.
ALTER TABLE domains ADD COLUMN records TEXT NOT NULL DEFAULT '[]';
ALTER TABLE domains ADD COLUMN providers TEXT NOT NULL DEFAULT '{}';
-- +goose Down
ALTER TABLE domains DROP COLUMN providers;
ALTER TABLE domains DROP COLUMN records;
+717 -92
View File
@@ -88,9 +88,46 @@ type Result struct {
HostingProvider string `json:"hostingProvider"`
CAProvider string `json:"caProvider"`
// Structured intel — domainstack-style record list + vendor strip.
Records []DNSRecord `json:"records"`
Providers Providers `json:"providers"`
Headers map[string]string `json:"headers"`
FaviconURL string `json:"faviconUrl"`
Error string `json:"error"` // non-fatal partial failure note
mxPairs []mxPair // structured MX; MXRecords keeps the display string
}
// Provider names the vendor behind a record or role. Domain feeds the
// favicon icon rendered next to the name in the UI.
type Provider struct {
Name string `json:"name"`
Domain string `json:"domain"`
Icon string `json:"icon"`
}
// Providers is the detected-vendor strip: registrar, DNS host, hosting,
// email, and certificate authority.
type Providers struct {
Registrar *Provider `json:"registrar,omitempty"`
DNS *Provider `json:"dns,omitempty"`
Email *Provider `json:"email,omitempty"`
Hosting *Provider `json:"hosting,omitempty"`
CA *Provider `json:"ca,omitempty"`
}
// DNSRecord is one resolved record with the vendor its value points at.
type DNSRecord struct {
Type string `json:"type"` // A AAAA CNAME NS MX TXT
Value string `json:"value"`
Priority *int `json:"priority,omitempty"` // MX preference
Provider *Provider `json:"provider,omitempty"`
}
type mxPair struct {
host string
pref int
}
// Clean normalises user input to a bare registrable hostname.
@@ -125,6 +162,7 @@ func (l *Lookup) Run(ctx context.Context, rawName string) *Result {
IPv4: []string{},
IPv6: []string{},
SSLAltNames: []string{},
Records: []DNSRecord{},
Headers: map[string]string{},
FaviconURL: fmt.Sprintf("https://www.google.com/s2/favicons?domain=%s&sz=128", name),
}
@@ -155,14 +193,68 @@ func (l *Lookup) Run(ctx context.Context, rawName string) *Result {
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)
l.identify(r)
return r
}
// identify resolves provider vendors after every probe has run, then builds
// the structured record list the detail page renders.
func (l *Lookup) identify(r *Result) {
r.Providers.Registrar = matchProvider(r.RegistrarName, registrarProviders)
for _, n := range r.NameServers {
if p := matchProvider(n, nsProviders); p != nil {
r.Providers.DNS = p
break
}
}
for _, m := range r.mxPairs {
if p := matchProvider(m.host, mailProviders); p != nil {
r.Providers.Email = p
break
}
}
r.Providers.Hosting = detectHostProvider(r)
r.Providers.CA = matchProvider(r.SSLIssuer, caProviders)
// Legacy flat names mirror the strip for the list page + old clients.
r.DNSProvider = nameOf(r.Providers.DNS)
r.EmailProvider = nameOf(r.Providers.Email)
r.HostingProvider = nameOf(r.Providers.Hosting)
r.CAProvider = nameOf(r.Providers.CA)
recs := make([]DNSRecord, 0,
len(r.IPv4)+len(r.IPv6)+len(r.NameServers)+len(r.mxPairs)+len(r.TXTRecords)+1)
add := func(t, v string, pri *int, p *Provider) {
recs = append(recs, DNSRecord{Type: t, Value: v, Priority: pri, Provider: p})
}
for _, ip := range r.IPv4 {
add("A", ip, nil, r.Providers.Hosting)
}
for _, ip := range r.IPv6 {
add("AAAA", ip, nil, r.Providers.Hosting)
}
if r.CNAME != "" {
add("CNAME", r.CNAME, nil, matchProvider(r.CNAME, hostTargets))
}
for _, n := range r.NameServers {
add("NS", n, nil, matchProvider(n, nsProviders))
}
for _, m := range r.mxPairs {
pri := m.pref
add("MX", m.host, &pri, matchProvider(m.host, mailProviders))
}
for _, t := range r.TXTRecords {
add("TXT", t, nil, matchTXTProvider(t))
}
r.Records = recs
}
func nameOf(p *Provider) string {
if p == nil {
return ""
}
return p.Name
}
// --- DNS --------------------------------------------------------------------
func (l *Lookup) dns(ctx context.Context, name string, r *Result) {
@@ -174,7 +266,9 @@ func (l *Lookup) dns(ctx context.Context, name string, r *Result) {
}
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))
host := strings.TrimSuffix(strings.ToLower(m.Host), ".")
r.mxPairs = append(r.mxPairs, mxPair{host: host, pref: int(m.Pref)})
r.MXRecords = append(r.MXRecords, fmt.Sprintf("%s (pri %d)", host, m.Pref))
}
}
if txt, err := res.LookupTXT(ctx, name); err == nil {
@@ -297,108 +391,639 @@ func (l *Lookup) headers(ctx context.Context, name string, r *Result) {
}
// --- Provider detection (condensed port of beszel's detect package) -----------
//
// Tables map a substring of the record value / org / issuer to a vendor name
// plus the brand domain used for the favicon icon. Order matters — first
// match wins, so specific substrings precede generic ones.
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"},
type provMatch struct{ sub, name, dom string }
func prov(name, dom string) *Provider {
return &Provider{Name: name, Domain: dom,
Icon: fmt.Sprintf("https://www.google.com/s2/favicons?domain=%s&sz=64", dom)}
}
for _, n := range ns {
n = strings.ToLower(n)
func matchProvider(haystack string, table []provMatch) *Provider {
h := strings.ToLower(haystack)
for _, t := range table {
if strings.Contains(n, t.sub) {
return t.name
if strings.Contains(h, t.sub) {
return prov(t.name, t.dom)
}
}
}
return ""
return nil
}
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 ""
var nsProviders = []provMatch{
{"cloudflare", "Cloudflare", "cloudflare.com"},
{"awsdns", "Amazon Route 53", "aws.amazon.com"},
{"googledomains", "Google Domains", "domains.google"},
{"dns.google", "Google Cloud DNS", "cloud.google.com"},
{"azure-dns", "Microsoft Azure", "azure.microsoft.com"},
{"domaincontrol", "GoDaddy", "godaddy.com"},
{"godaddy", "GoDaddy", "godaddy.com"},
{"registrar-servers", "Namecheap", "namecheap.com"},
{"namecheap", "Namecheap", "namecheap.com"},
{"nsone.net", "NS1", "ns1.com"},
{"digitalocean", "DigitalOcean", "digitalocean.com"},
{"linode", "Akamai", "akamai.com"},
{"akamaitech", "Akamai", "akamai.com"},
{"vultr", "Vultr", "vultr.com"},
{"he.net", "Hurricane Electric", "he.net"},
{"dynect", "Dyn (Oracle)", "dyn.com"},
{"ultradns", "UltraDNS", "verisign.com"},
{"dnsimple", "DNSimple", "dnsimple.com"},
{"hover", "Hover", "hover.com"},
{"enom", "eNom", "enom.com"},
{"worldnic", "Network Solutions", "networksolutions.com"},
{"zoneedit", "ZoneEdit", "zoneedit.com"},
{"easydns", "EasyDNS", "easydns.com"},
{"gandi", "Gandi", "gandi.net"},
{"ovh", "OVHcloud", "ovhcloud.com"},
{"hetzner", "Hetzner", "hetzner.com"},
{"your-server.de", "Hetzner", "hetzner.com"},
{"porkbun", "Porkbun", "porkbun.com"},
{"cloudns", "ClouDNS", "cloudns.net"},
{"desec", "deSEC", "desec.io"},
{"nic.cz", "CZ.NIC", "nic.cz"},
{"dnsmadeeasy", "DNS Made Easy", "dnsmadeeasy.com"},
{"ui-dns", "IONOS", "ionos.com"},
{"squarespacedns", "Squarespace", "squarespace.com"},
{"wordpress.com", "WordPress.com", "wordpress.com"},
{"dns-parking.com", "Hostinger", "hostinger.com"},
{"hostinger", "Hostinger", "hostinger.com"},
{"siteground", "SiteGround", "siteground.com"},
{"dreamhost", "DreamHost", "dreamhost.com"},
{"inmotionhosting", "InMotion", "inmotionhosting.com"},
{"bluehost", "Bluehost", "bluehost.com"},
{"hostgator", "HostGator", "hostgator.com"},
{"kinstadns", "Kinsta", "kinsta.com"},
{"kasserver", "ALL-INKL.COM", "all-inkl.com"},
{"all-inkl", "ALL-INKL.COM", "all-inkl.com"},
{"strato", "STRATO", "strato.de"},
{"udagdns", "United Domains", "uniteddomains.com"},
{"udag.de", "United Domains", "uniteddomains.com"},
{"transip", "TransIP", "transip.nl"},
{"infomaniak", "Infomaniak", "infomaniak.com"},
{"one.com", "One.com", "one.com"},
{"aruba", "Aruba", "aruba.it"},
{"technorail", "Aruba", "aruba.it"},
{"inwx", "INWX", "inwx.com"},
{"kasserver.com", "ALL-INKL.COM", "all-inkl.com"},
{"contabo", "Contabo", "contabo.com"},
{"netcup", "netcup", "netcup.eu"},
{"glesys", "GleSYS", "glesys.se"},
{"scaleway", "Scaleway", "scaleway.com"},
{"exoscale", "Exoscale", "exoscale.com"},
{"dnspod", "DNSPod", "dnspod.com"},
{"hicloud", "Huawei Cloud", "huaweicloud.com"},
{"alidns", "Alibaba Cloud", "alibabacloud.com"},
{"purelymail", "Purelymail", "purelymail.com"},
{"migadu", "Migadu", "migadu.com"},
{"improvmx", "ImprovMX", "improvmx.com"},
{"bunny", "Bunny", "bunny.net"},
{"edgekey", "Akamai", "akamai.com"},
{"edgesuite", "Akamai", "akamai.com"},
{"fastly", "Fastly", "fastly.com"},
{"cdn77", "CDN77", "cdn77.com"},
{"stackpath", "StackPath", "stackpath.com"},
{"ns.cloudflare", "Cloudflare", "cloudflare.com"},
{"vercel-dns", "Vercel", "vercel.com"},
}
func detectHostingProvider(h map[string]string) string {
var mailProviders = []provMatch{
{"aspmx", "Google Workspace", "google.com"},
{"googlemail", "Google Workspace", "google.com"},
{"google.com", "Google Workspace", "google.com"},
{"outlook.com", "Microsoft 365", "microsoft.com"},
{"protection.outlook", "Microsoft 365", "microsoft.com"},
{"mail.protection", "Microsoft 365", "microsoft.com"},
{"protonmail", "Proton Mail", "proton.me"},
{"proton.me", "Proton Mail", "proton.me"},
{"zoho", "Zoho Mail", "zoho.com"},
{"fastmail", "Fastmail", "fastmail.com"},
{"messagingengine", "Fastmail", "fastmail.com"},
{"mimecast", "Mimecast", "mimecast.com"},
{"proofpoint", "Proofpoint", "proofpoint.com"},
{"barracuda", "Barracuda", "barracuda.com"},
{"mailgun", "Mailgun", "mailgun.com"},
{"sendgrid", "SendGrid", "sendgrid.com"},
{"amazonses", "Amazon SES", "aws.amazon.com"},
{"yandex", "Yandex", "yandex.com"},
{"tuta", "Tuta", "tuta.com"},
{"mailbox.org", "mailbox.org", "mailbox.org"},
{"purelymail", "Purelymail", "purelymail.com"},
{"migadu", "Migadu", "migadu.com"},
{"mxroute", "MXroute", "mxroute.com"},
{"mymxroute", "MXroute", "mxroute.com"},
{"icloud.com", "iCloud Mail", "apple.com"},
{"infomaniak", "Infomaniak", "infomaniak.com"},
{"mailpod", "One.com", "one.com"},
{"one.com", "One.com", "one.com"},
{"gmx", "GMX", "gmx.net"},
{"forwardemail", "Forward Email", "forwardemail.net"},
{"posteo", "Posteo", "posteo.de"},
{"mailo", "Mailo", "mailo.com"},
{"hey.com", "HEY", "hey.com"},
{"privateemail", "Namecheap Private Email", "namecheap.com"},
{"registrar-servers", "Namecheap", "namecheap.com"},
{"ovh.net", "OVHcloud Mail", "ovhcloud.com"},
{"perfora.net", "IONOS Mail", "ionos.com"},
{"ui-dns", "IONOS", "ionos.com"},
{"improvmx", "ImprovMX", "improvmx.com"},
{"porkbun", "Porkbun Mail", "porkbun.com"},
{"kundenserver", "IONOS Mail", "ionos.com"},
{"t-online", "T-Online", "telekom.de"},
{"web.de", "WEB.DE", "web.de"},
{"mail.ru", "Mail.ru", "mail.ru"},
{"qq.com", "QQ Mail", "qq.com"},
{"163.com", "NetEase Mail", "163.com"},
{"exim.rs", "Exim", "exim.org"},
{"mx.cloudflare", "Cloudflare Email Routing", "cloudflare.com"},
}
// hostTargets: CNAME targets that reveal the hosting platform.
var hostTargets = []provMatch{
{"github.io", "GitHub Pages", "github.com"},
{"vercel-dns", "Vercel", "vercel.com"},
{"vercel.app", "Vercel", "vercel.com"},
{"netlify", "Netlify", "netlify.com"},
{"cloudfront.net", "AWS CloudFront", "aws.amazon.com"},
{"azurefd.net", "Azure Front Door", "azure.microsoft.com"},
{"azureedge.net", "Azure CDN", "azure.microsoft.com"},
{"cloudapp.net", "Microsoft Azure", "azure.microsoft.com"},
{"trafficmanager.net", "Microsoft Azure", "azure.microsoft.com"},
{"herokuapp.com", "Heroku", "heroku.com"},
{"herokudns.com", "Heroku", "heroku.com"},
{"workers.dev", "Cloudflare Workers", "cloudflare.com"},
{"pages.dev", "Cloudflare Pages", "cloudflare.com"},
{"cdn.cloudflare", "Cloudflare", "cloudflare.com"},
{"fly.dev", "Fly.io", "fly.io"},
{"onrender.com", "Render", "render.com"},
{"railway.app", "Railway", "railway.app"},
{"surge.sh", "Surge", "surge.sh"},
{"neocities.org", "Neocities", "neocities.org"},
{"readthedocs", "Read the Docs", "readthedocs.io"},
{"webflow.io", "Webflow", "webflow.com"},
{"squarespace.com", "Squarespace", "squarespace.com"},
{"wixdns.net", "Wix", "wix.com"},
{"wixsite.com", "Wix", "wix.com"},
{"editorx.com", "Wix Studio", "wix.com"},
{"myshopify.com", "Shopify", "shopify.com"},
{"wpengine", "WP Engine", "wpengine.com"},
{"wordpress.com", "WordPress.com", "wordpress.com"},
{"googlehosted.com", "Google", "google.com"},
{"googleapis.com", "Google Cloud", "cloud.google.com"},
{"fastly.net", "Fastly", "fastly.com"},
{"fastlylb.net", "Fastly", "fastly.com"},
{"akamaiedge.net", "Akamai", "akamai.com"},
{"edgekey.net", "Akamai", "akamai.com"},
{"edgesuite.net", "Akamai", "akamai.com"},
{"akamai.net", "Akamai", "akamai.com"},
{"b-cdn.net", "Bunny", "bunny.net"},
{"bunnycdn", "Bunny", "bunny.net"},
{"elb.amazonaws.com", "AWS ELB", "aws.amazon.com"},
{"amazonaws.com", "Amazon AWS", "aws.amazon.com"},
{"stackpathdns", "StackPath", "stackpath.com"},
{"kinsta.cloud", "Kinsta", "kinsta.com"},
{"pantheonsite.io", "Pantheon", "pantheon.io"},
{"pantheon.io", "Pantheon", "pantheon.io"},
{"ghost.io", "Ghost", "ghost.org"},
{"hubspot", "HubSpot", "hubspot.com"},
{"dnsimple.com", "DNSimple", "dnsimple.com"},
{"pressdns.com", "Pressable", "pressable.com"},
{"kinsta", "Kinsta", "kinsta.com"},
{"cdn77", "CDN77", "cdn77.com"},
{"incapsula", "Imperva", "imperva.com"},
{"sucuri", "Sucuri", "sucuri.net"},
{"flywheel", "Flywheel", "getflywheel.com"},
{"gridscale", "gridscale", "gridscale.io"},
{"webflow", "Webflow", "webflow.com"},
{"framer.app", "Framer", "framer.com"},
{"carrd.co", "Carrd", "carrd.co"},
{"super.so", "Super", "super.so"},
{"typedream.app", "Typedream", "typedream.com"},
{"cargo.site", "Cargo", "cargo.site"},
{"gitlab.io", "GitLab Pages", "gitlab.com"},
{"bitbucket.io", "Bitbucket", "bitbucket.org"},
{"codeberg.page", "Codeberg Pages", "codeberg.org"},
{"glitch.me", "Glitch", "glitch.com"},
{"repl.co", "Replit", "replit.com"},
{"replit.app", "Replit", "replit.com"},
{"deno.dev", "Deno Deploy", "deno.com"},
{"supabase.co", "Supabase", "supabase.com"},
{"firebaseapp.com", "Firebase", "firebase.google.com"},
{"web.app", "Firebase", "firebase.google.com"},
{"appspot.com", "Google App Engine", "cloud.google.com"},
}
// asnProviders: ip-api org/AS strings → hosting vendor.
var asnProviders = []provMatch{
{"cloudflare", "Cloudflare", "cloudflare.com"},
{"akamai", "Akamai", "akamai.com"},
{"linode", "Akamai (Linode)", "linode.com"},
{"amazon", "Amazon AWS", "aws.amazon.com"},
{"aws", "Amazon AWS", "aws.amazon.com"},
{"google", "Google Cloud", "cloud.google.com"},
{"microsoft", "Microsoft Azure", "azure.microsoft.com"},
{"azure", "Microsoft Azure", "azure.microsoft.com"},
{"digitalocean", "DigitalOcean", "digitalocean.com"},
{"hetzner", "Hetzner", "hetzner.com"},
{"ovh", "OVHcloud", "ovhcloud.com"},
{"kimsufi", "OVHcloud", "ovhcloud.com"},
{"soyoustart", "OVHcloud", "ovhcloud.com"},
{"vultr", "Vultr", "vultr.com"},
{"choopa", "Vultr", "vultr.com"},
{"fastly", "Fastly", "fastly.com"},
{"vercel", "Vercel", "vercel.com"},
{"netlify", "Netlify", "netlify.com"},
{"github", "GitHub", "github.com"},
{"scaleway", "Scaleway", "scaleway.com"},
{"oracle", "Oracle Cloud", "oracle.com"},
{"alibaba", "Alibaba Cloud", "alibabacloud.com"},
{"tencent", "Tencent Cloud", "cloud.tencent.com"},
{"contabo", "Contabo", "contabo.com"},
{"ionos", "IONOS", "ionos.com"},
{"1&1", "IONOS", "ionos.com"},
{"shopify", "Shopify", "shopify.com"},
{"netcup", "netcup", "netcup.eu"},
{"upcloud", "UpCloud", "upcloud.com"},
{"kamatera", "Kamatera", "kamatera.com"},
{"fly.io", "Fly.io", "fly.io"},
{"render", "Render", "render.com"},
{"railway", "Railway", "railway.app"},
{"bunny", "Bunny", "bunny.net"},
{"hostinger", "Hostinger", "hostinger.com"},
{"siteground", "SiteGround", "siteground.com"},
{"godaddy", "GoDaddy", "godaddy.com"},
{"automattic", "WordPress.com", "wordpress.com"},
{"squarespace", "Squarespace", "squarespace.com"},
{"wix", "Wix", "wix.com"},
{"infomaniak", "Infomaniak", "infomaniak.com"},
{"dreamhost", "DreamHost", "dreamhost.com"},
{"liquid web", "Liquid Web", "liquidweb.com"},
{"liquidweb", "Liquid Web", "liquidweb.com"},
{"inmotion", "InMotion", "inmotionhosting.com"},
{"unified layer", "Bluehost", "bluehost.com"},
{"hostgator", "HostGator", "hostgator.com"},
{"glesys", "GleSYS", "glesys.se"},
{"exoscale", "Exoscale", "exoscale.com"},
{"incapsula", "Imperva", "imperva.com"},
{"sucuri", "Sucuri", "sucuri.net"},
{"ddos-guard", "DDoS-Guard", "ddos-guard.net"},
{"huawei", "Huawei Cloud", "huaweicloud.com"},
{"leaseweb", "Leaseweb", "leaseweb.com"},
{"aruba", "Aruba", "aruba.it"},
{"krystal", "Krystal", "krystal.uk"},
{"namecheap", "Namecheap", "namecheap.com"},
{"web-hosting.com", "Namecheap", "namecheap.com"},
{"zendesk", "Zendesk", "zendesk.com"},
{"kinsta", "Kinsta", "kinsta.com"},
{"flywheel", "Flywheel", "getflywheel.com"},
{"wp engine", "WP Engine", "wpengine.com"},
{"pantheon", "Pantheon", "pantheon.io"},
{"meta", "Meta", "meta.com"},
{"gitlab", "GitLab", "gitlab.com"},
{"stackpath", "StackPath", "stackpath.com"},
{"cdn77", "CDN77", "cdn77.com"},
{"imperva", "Imperva", "imperva.com"},
{"framer", "Framer", "framer.com"},
{"webflow", "Webflow", "webflow.com"},
{"readthedocs", "Read the Docs", "readthedocs.io"},
{"transip", "TransIP", "transip.nl"},
{"strato", "STRATO", "strato.de"},
{"all-inkl", "ALL-INKL.COM", "all-inkl.com"},
{"one.com", "One.com", "one.com"},
{"netlify", "Netlify", "netlify.com"},
{"neocities", "Neocities", "neocities.org"},
}
var registrarProviders = []provMatch{
{"markmonitor", "MarkMonitor", "markmonitor.com"},
{"namecheap", "Namecheap", "namecheap.com"},
{"cloudflare", "Cloudflare", "cloudflare.com"},
{"godaddy", "GoDaddy", "godaddy.com"},
{"gandi", "Gandi", "gandi.net"},
{"porkbun", "Porkbun", "porkbun.com"},
{"ovh", "OVHcloud", "ovhcloud.com"},
{"name.com", "Name.com", "name.com"},
{"ionos", "IONOS", "ionos.com"},
{"1&1", "IONOS", "ionos.com"},
{"1und1", "IONOS", "ionos.com"},
{"tucows", "Tucows", "tucows.com"},
{"squarespace", "Squarespace", "squarespace.com"},
{"google", "Google Domains", "domains.google"},
{"dynadot", "Dynadot", "dynadot.com"},
{"namesilo", "NameSilo", "namesilo.com"},
{"epik", "Epik", "epik.com"},
{"hexonet", "HEXONET", "hexonet.net"},
{"1api", "HEXONET", "hexonet.net"},
{"openprovider", "Openprovider", "openprovider.com"},
{"internetx", "InterNetX", "internetx.com"},
{"nic.cz", "CZ.NIC", "nic.cz"},
{"hostinger", "Hostinger", "hostinger.com"},
{"com laude", "Com Laude", "comlaude.com"},
{"csc", "CSC", "cscdbs.com"},
{"safenames", "Safenames", "safenames.net"},
{"xinnet", "Xin Net", "xinnet.com"},
{"alibaba", "Alibaba Cloud", "alibabacloud.com"},
{"dnspod", "DNSPod", "dnspod.com"},
{"tencent", "Tencent Cloud", "cloud.tencent.com"},
{"gabia", "Gabia", "gabia.com"},
{"reg.ru", "REG.RU", "reg.ru"},
{"nic.ru", "RU-CENTER", "nic.ru"},
{"nordname", "NordName", "nordname.com"},
{"internet.bs", "Internet.bs", "internetbs.net"},
{"key-systems", "Key-Systems", "key-systems.net"},
{"united domains", "United Domains", "uniteddomains.com"},
{"united-domains", "United Domains", "uniteddomains.com"},
{"scaleway", "Scaleway", "scaleway.com"},
{"hetzner", "Hetzner", "hetzner.com"},
{"infomaniak", "Infomaniak", "infomaniak.com"},
{"one.com", "One.com", "one.com"},
{"netim", "Netim", "netim.com"},
{"inwx", "INWX", "inwx.com"},
{"internetworx", "INWX", "inwx.com"},
{"checkdomain", "checkdomain", "checkdomain.de"},
{"domaindiscount24", "domaindiscount24", "domaindiscount24.com"},
{"easyname", "easyname", "easyname.com"},
{"world4you", "World4You", "world4you.com"},
{"all-inkl", "ALL-INKL.COM", "all-inkl.com"},
{"transip", "TransIP", "transip.nl"},
{"strato", "STRATO", "strato.de"},
{"hover", "Hover", "hover.com"},
{"enom", "eNom", "enom.com"},
{"namebright", "NameBright", "namebright.com"},
{"sav.com", "Sav.com", "sav.com"},
{"turncommerce", "DropCatch", "dropcatch.com"},
{"eurodns", "EuroDNS", "eurodns.com"},
{"gransy", "Gransy", "regtons.com"},
{"101domain", "101domain", "101domain.com"},
{"iwantmyname", "iwantmyname", "iwantmyname.com"},
{"rebel", "Rebel", "rebel.ca"},
{"blacknight", "Blacknight", "blacknight.com"},
{"gname", "GNAME", "gname.com"},
{"west.cn", "West.cn", "west.cn"},
{"west263", "West.cn", "west.cn"},
{"imena.ua", "Imena.ua", "imena.ua"},
{"ukraine.com.ua", "Hosting Ukraine", "ukraine.com.ua"},
{"beget", "Beget", "beget.com"},
{"selectel", "Selectel", "selectel.ru"},
{"timeweb", "Timeweb", "timeweb.ru"},
{"papaki", "Papaki", "papaki.com"},
{"dotname", "Dotname", "dotname.co.kr"},
{"gabia", "Gabia", "gabia.com"},
{"registar-se", "Namecheap", "namecheap.com"},
{"registrar-servers", "Namecheap", "namecheap.com"},
{"namebay", "Namebay", "namebay.com"},
{"domainpeople", "DomainPeople", "domainpeople.com"},
{"networksolutions", "Network Solutions", "networksolutions.com"},
{"network solutions", "Network Solutions", "networksolutions.com"},
{"register.com", "Register.com", "register.com"},
{"webcentral", "Webcentral", "webcentral.com.au"},
{"melbourne it", "Melbourne IT", "melbourneit.com.au"},
{"corporatedomains", "CSC", "cscdbs.com"},
{"amazon registrar", "Amazon Registrar", "aws.amazon.com"},
{"nominet", "Nominet", "nominet.uk"},
{"123-reg", "123 Reg", "123-reg.co.uk"},
{"domain.com", "Domain.com", "domain.com"},
{"dotster", "Dotster", "dotster.com"},
{"joker", "Joker.com", "joker.com"},
{"variomedia", "Variomedia", "variomedia.de"},
{"goneo", "goneo", "goneo.de"},
{"manitu", "manitu", "manitu.de"},
{"hosteurope", "Host Europe", "hosteurope.de"},
{"keyweb", "Keyweb", "keyweb.de"},
{"wedos", "WEDOS", "wedos.com"},
{"active24", "Active 24", "active24.com"},
{"forpsi", "FORPSI", "forpsi.com"},
{"ignum", "IGNUM", "ignum.cz"},
{"oneprovider", "OneProvider", "oneprovider.com"},
{"reg.cz", "REG.CZ", "reg.cz"},
{"subreg", "Websupport", "websupport.sk"},
{"websupport", "Websupport", "websupport.sk"},
{"loopia", "Loopia", "loopia.se"},
{"binero", "Binero", "binero.se"},
{"one.se", "One.com", "one.com"},
{"simply.com", "Simply.com", "simply.com"},
{"domeneshop", "Domeneshop", "domeneshop.no"},
{"syse", "Syse", "syse.no"},
{"registrar.eu", "Openprovider", "openprovider.com"},
{"mondo", "Mondo International", "mondo.com"},
{"dansk.net", "Dansk.net", "dansk.net"},
{"internet invest", "Imena.ua", "imena.ua"},
{"netearth", "NetEarth One", "netearthone.com"},
{"publicdomainregistry", "PublicDomainRegistry", "publicdomainregistry.com"},
{"pdr ltd", "PublicDomainRegistry", "publicdomainregistry.com"},
{"webnames", "Webnames", "webnames.ca"},
{"rebel.ca", "Rebel", "rebel.ca"},
{"easydns", "easyDNS", "easydns.com"},
{"namespro", "Namespro", "namespro.ca"},
{"sibername", "Sibername", "sibername.com"},
{"netfirms", "Netfirms", "netfirms.com"},
{"names.co.uk", "names.co.uk", "names.co.uk"},
{"namesco", "names.co.uk", "names.co.uk"},
{"fasthosts", "Fasthosts", "fasthosts.co.uk"},
{"heart internet", "Heart Internet", "heartinternet.uk"},
{"dreamhost", "DreamHost", "dreamhost.com"},
{"pair domains", "pair Domains", "pairdomains.com"},
{"domainthenet", "Domain The Net", "domainthenet.com"},
{"domainregistry", "DomainRegistry", "domainregistry.com"},
{"moniker", "Moniker", "moniker.com"},
{"fabulous", "Fabulous", "fabulous.com"},
{"above.com", "Above.com", "above.com"},
{"united-internet", "United Internet", "united-internet.de"},
{"united internet", "United Internet", "united-internet.de"},
{"arsys", "Arsys", "arsys.es"},
{"piensa solutions", "Piensa Solutions", "piensasolutions.com"},
{"dinahosting", "Dinahosting", "dinahosting.com"},
{"cdmon", "CDmon", "cdmon.com"},
{"nominalia", "Nominalia", "nominalia.com"},
{"register.it", "Register.it", "register.it"},
{"ovh sas", "OVHcloud", "ovhcloud.com"},
{"gandi sas", "Gandi", "gandi.net"},
{"blue razor", "GoDaddy", "godaddy.com"},
{"wild west domains", "GoDaddy", "godaddy.com"},
{"starfield", "GoDaddy", "godaddy.com"},
{"nameking", "Namecheap", "namecheap.com"},
{"enom", "eNom", "enom.com"},
{"realtime register", "Realtime Register", "realtimeregister.com"},
{"rrpproxy", "RRPproxy", "rrpproxy.net"},
{"key-systems gmbh", "Key-Systems", "key-systems.net"},
{"internet domains", "Internet Domains", "internetdomains.ch"},
{"corehub", "CORE", "corehub.net"},
{"don dominio", "DonDominio", "dondominio.com"},
{"arsys internet", "Arsys", "arsys.es"},
{"key-systems", "Key-Systems", "key-systems.net"},
{"centralnic", "CentralNic", "centralnic.com"},
{"internet.bs corp", "Internet.bs", "internetbs.net"},
{"gkg.net", "GKG.NET", "gkg.net"},
{"namecheap inc", "Namecheap", "namecheap.com"},
{"namecheap, inc", "Namecheap", "namecheap.com"},
{"google llc", "Google Domains", "domains.google"},
{"squarespace domains", "Squarespace", "squarespace.com"},
{"turncommerce, inc.", "DropCatch", "dropcatch.com"},
{"amazon", "Amazon Registrar", "aws.amazon.com"},
{"name.com, inc", "Name.com", "name.com"},
{"godaddy.com, llc", "GoDaddy", "godaddy.com"},
{"tucows domains", "Tucows", "tucows.com"},
{"porkbun llc", "Porkbun", "porkbun.com"},
{"cloudflare, inc", "Cloudflare", "cloudflare.com"},
}
// caProviders: issuer CN/org → certificate authority vendor.
var caProviders = []provMatch{
{"let's encrypt", "Let's Encrypt", "letsencrypt.org"},
{"letsencrypt", "Let's Encrypt", "letsencrypt.org"},
{"digicert", "DigiCert", "digicert.com"},
{"sectigo", "Sectigo", "sectigo.com"},
{"comodoca", "Sectigo", "sectigo.com"},
{"comodo", "Sectigo", "sectigo.com"},
{"globalsign", "GlobalSign", "globalsign.com"},
{"godaddy", "GoDaddy", "godaddy.com"},
{"starfield", "GoDaddy", "godaddy.com"},
{"entrust", "Entrust", "entrust.com"},
{"google trust", "Google Trust Services", "pki.goog"},
{"pki.goog", "Google Trust Services", "pki.goog"},
{"amazon", "Amazon Trust Services", "amazontrust.com"},
{"zerossl", "ZeroSSL", "zerossl.com"},
{"buypass", "Buypass", "buypass.com"},
{"ssl.com", "SSL.com", "ssl.com"},
{"actalis", "Actalis", "actalis.com"},
{"certum", "Certum", "certum.pl"},
{"harica", "HARICA", "harica.gr"},
{"swisssign", "SwissSign", "swisssign.com"},
{"cloudflare", "Cloudflare", "cloudflare.com"},
{"microsoft", "Microsoft", "microsoft.com"},
{"geotrust", "DigiCert", "digicert.com"},
{"thawte", "DigiCert", "digicert.com"},
{"rapidssl", "DigiCert", "digicert.com"},
{"usertrust", "Sectigo", "sectigo.com"},
{"certainly", "Certainly", "certainly.io"},
{"isrg", "Let's Encrypt", "letsencrypt.org"},
{"e1", "Let's Encrypt", "letsencrypt.org"},
{"r3", "Let's Encrypt", "letsencrypt.org"},
{"r10", "Let's Encrypt", "letsencrypt.org"},
{"r11", "Let's Encrypt", "letsencrypt.org"},
{"wotrus", "WoTrus", "wotrus.com"},
{"cameo", "Cameo", "cameo.com"},
}
// detectHostProvider picks the hosting vendor: CNAME target first (most
// precise), then response headers, then the ASN/org of the primary IPv4.
func detectHostProvider(r *Result) *Provider {
if r.CNAME != "" {
if p := matchProvider(r.CNAME, hostTargets); p != nil {
return p
}
}
h := r.Headers
server := strings.ToLower(h["server"] + " " + h["x-powered-by"] + " " + h["via"])
if _, ok := h["cf-ray"]; ok || strings.Contains(server, "cloudflare") {
return "Cloudflare"
switch {
case h["cf-ray"] != "" || strings.Contains(server, "cloudflare"):
return prov("Cloudflare", "cloudflare.com")
case h["x-vercel-id"] != "" || strings.Contains(server, "vercel"):
return prov("Vercel", "vercel.com")
case h["x-nf-request-id"] != "" || strings.Contains(server, "netlify"):
return prov("Netlify", "netlify.com")
case h["x-amz-cf-id"] != "" || strings.Contains(server, "cloudfront"):
return prov("AWS CloudFront", "aws.amazon.com")
case h["x-github-request-id"] != "" || strings.Contains(server, "github"):
return prov("GitHub Pages", "github.com")
case strings.Contains(server, "fly.io") || h["fly-request-id"] != "":
return prov("Fly.io", "fly.io")
case strings.Contains(server, "render"):
return prov("Render", "render.com")
case strings.Contains(server, "shopify"):
return prov("Shopify", "shopify.com")
case strings.Contains(server, "squarespace"):
return prov("Squarespace", "squarespace.com")
case strings.Contains(server, "wix"):
return prov("Wix", "wix.com")
}
if _, ok := h["x-vercel-id"]; ok || strings.Contains(server, "vercel") {
return "Vercel"
webServers := []provMatch{
{"awselb", "AWS ELB", "aws.amazon.com"},
{"gws", "Google", "google.com"},
{"kestrel", "Kestrel/.NET", "microsoft.com"},
{"cowboy", "Cowboy/Erlang", "heroku.com"},
}
if _, ok := h["x-nf-request-id"]; ok || strings.Contains(server, "netlify") {
return "Netlify"
if p := matchProvider(server, webServers); p != nil {
return p
}
if _, ok := h["x-amz-cf-id"]; ok || strings.Contains(server, "cloudfront") {
return "AWS CloudFront"
if p := matchProvider(r.HostOrg+" "+r.HostAS+" "+r.HostISP, asnProviders); p != nil {
return p
}
if _, ok := h["x-github-request-id"]; ok || strings.Contains(server, "github") {
return "GitHub Pages"
if p := matchProvider(server, []provMatch{
{"nginx", "nginx", "nginx.org"},
{"apache", "Apache", "apache.org"},
{"caddy", "Caddy", "caddyserver.com"},
{"litespeed", "LiteSpeed", "litespeedtech.com"},
{"openresty", "OpenResty", "openresty.org"},
{"envoy", "Envoy", "envoyproxy.io"},
}); p != nil {
return p
}
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 ""
return nil
}
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"},
// txtProviders: SPF includes and domain-verification strings → vendor.
var txtProviders = []provMatch{
{"v=spf1 include:_spf.google.com", "Google Workspace", "google.com"},
{"include:spf.protonmail.ch", "Proton Mail", "proton.me"},
{"include:mail.protonmail.ch", "Proton Mail", "proton.me"},
{"include:_spf.mx.cloudflare.net", "Cloudflare", "cloudflare.com"},
{"include:servers.mcsv.net", "Mailchimp", "mailchimp.com"},
{"include:amazonses.com", "Amazon SES", "aws.amazon.com"},
{"include:mailgun.org", "Mailgun", "mailgun.com"},
{"include:sendgrid.net", "SendGrid", "sendgrid.com"},
{"include:spf.protection.outlook.com", "Microsoft 365", "microsoft.com"},
{"include:spf.purelymail.com", "Purelymail", "purelymail.com"},
{"_spf.purelymail.com", "Purelymail", "purelymail.com"},
{"purelymail_ownership_proof", "Purelymail", "purelymail.com"},
{"openai-domain-verification", "OpenAI", "openai.com"},
{"include:_spf.migadu.com", "Migadu", "migadu.com"},
{"include:mx.zohomail.com", "Zoho Mail", "zoho.com"},
{"include:_spf.fastmail.com", "Fastmail", "fastmail.com"},
{"include:spf.mtasv.net", "Postmark", "postmarkapp.com"},
{"include:_spf.salesforce.com", "Salesforce", "salesforce.com"},
{"include:spf.improvmx.com", "ImprovMX", "improvmx.com"},
{"include:mail.zendesk.com", "Zendesk", "zendesk.com"},
{"include:spf.mandrillapp.com", "Mailchimp", "mailchimp.com"},
{"include:mailspike.net", "Mailspike", "mailspike.net"},
{"include:spf.messagelabs.com", "Broadcom", "broadcom.com"},
{"include:_spf.mx.yandex", "Yandex", "yandex.com"},
{"include:spf.tuta.io", "Tuta", "tuta.com"},
{"google-site-verification", "Google", "google.com"},
{"ms=", "Microsoft 365", "microsoft.com"},
{"facebook-domain-verification", "Meta", "meta.com"},
{"apple-domain-verification", "Apple", "apple.com"},
{"keybase-site-verification", "Keybase", "keybase.io"},
{"keybase", "Keybase", "keybase.io"},
{"protonmail-verification", "Proton", "proton.me"},
{"have-i-been-pwned-verification", "Have I Been Pwned", "haveibeenpwned.com"},
{"atlassian-domain-verification", "Atlassian", "atlassian.com"},
{"docker-verification", "Docker", "docker.com"},
{"docusign", "DocuSign", "docusign.com"},
{"yandex-verification", "Yandex", "yandex.com"},
{"stripe-verification", "Stripe", "stripe.com"},
{"dropbox-domain-verification", "Dropbox", "dropbox.com"},
{"brave-ledger-verification", "Brave", "brave.com"},
{"detectify-verification", "Detectify", "detectify.com"},
{"adobe-idp-site-verification", "Adobe", "adobe.com"},
{"webexdomainverification", "Cisco Webex", "webex.com"},
{"zoho-verification", "Zoho", "zoho.com"},
{"mailru-verification", "Mail.ru", "mail.ru"},
{"baidu-site-verification", "Baidu", "baidu.com"},
{"google-gws-recovery-domain-verification", "Google", "google.com"},
{"status-page-domain-verification", "Atlassian", "atlassian.com"},
{"citrix-verification", "Citrix", "citrix.com"},
{"tiktok-domain-verification", "TikTok", "tiktok.com"},
{"netlify-verification", "Netlify", "netlify.com"},
{"vercel-domain-verification", "Vercel", "vercel.com"},
{"workplace-domain-verification", "Meta", "meta.com"},
{"v=dmarc1", "DMARC", "dmarc.org"},
{"v=dkim1", "DKIM", "dkim.org"},
}
for _, t := range table {
if strings.Contains(i, t.sub) {
return t.name
}
}
return ""
func matchTXTProvider(txt string) *Provider {
return matchProvider(txt, txtProviders)
}
+128
View File
@@ -0,0 +1,128 @@
package domain
// Subdomain discovery: certificate-transparency names from crt.sh, filtered
// to the owned suffix, then resolved via A/AAAA. Both network steps are
// injected seams so tests never leave the process.
import (
"context"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"strings"
"sync"
"time"
)
// Subdomain is one discovered hostname and its resolved addresses.
type Subdomain struct {
Name string `json:"name"`
IPs []string `json:"ips"`
}
// CTNames returns candidate hostnames for domain from crt.sh.
// Seam: tests substitute a stub.
var CTNames = func(ctx context.Context, domain string) ([]string, error) {
url := "https://crt.sh/?q=%25." + domain + "&output=json"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
resp, err := (&http.Client{Timeout: 20 * time.Second}).Do(req)
if err != nil {
return nil, fmt.Errorf("crt.sh unreachable")
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("crt.sh returned %d", resp.StatusCode)
}
// Bound the response — popular domains can return megabytes.
var rows []struct {
NameValue string `json:"name_value"`
CommonName string `json:"common_name"`
}
if err := json.NewDecoder(io.LimitReader(resp.Body, 8<<20)).Decode(&rows); err != nil {
return nil, fmt.Errorf("crt.sh decode failed")
}
var out []string
for _, r := range rows {
out = append(out, r.NameValue, r.CommonName)
}
return out, nil
}
// ResolveIPs returns A/AAAA addresses for a hostname. Seam: tests stub it.
var ResolveIPs = func(ctx context.Context, name string) ([]string, error) {
addrs, err := net.DefaultResolver.LookupIP(ctx, "ip", name)
if err != nil {
return nil, err
}
out := make([]string, 0, len(addrs))
for _, a := range addrs {
out = append(out, a.String())
}
return out, nil
}
// FilterCTNames reduces raw cert names to owned, canonical hostnames:
// lowercase, wildcard prefix stripped, must equal domain or end in .domain.
// CT names are untrusted text — stored and rendered as text only.
func FilterCTNames(raw []string, domain string) []string {
suffix := "." + domain
seen := map[string]bool{}
out := make([]string, 0, len(raw))
for _, r := range raw {
for _, name := range strings.Split(r, "\n") {
name = strings.ToLower(strings.TrimSpace(name))
name = strings.TrimPrefix(name, "*.")
if name == "" || (!strings.HasSuffix(name, suffix) && name != domain) {
continue
}
if strings.ContainsAny(name, " \t/\\\"'<>@") {
continue // cert names are untrusted; drop malformed entries
}
if !seen[name] {
seen[name] = true
out = append(out, name)
}
}
}
return out
}
// DiscoverSubdomains queries CT and resolves each surviving name, bounded:
// at most 500 candidates considered, 100 resolved, 8 concurrent lookups.
func DiscoverSubdomains(ctx context.Context, domain string) ([]Subdomain, error) {
raw, err := CTNames(ctx, domain)
if err != nil {
return nil, err
}
names := FilterCTNames(raw, domain)
if len(names) > 500 {
names = names[:500]
}
if len(names) > 100 {
names = names[:100]
}
out := make([]Subdomain, len(names))
var wg sync.WaitGroup
sem := make(chan struct{}, 8)
for i, name := range names {
out[i].Name = name
wg.Add(1)
sem <- struct{}{}
go func(i int, name string) {
defer wg.Done()
defer func() { <-sem }()
rctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
if ips, err := ResolveIPs(rctx, name); err == nil {
out[i].IPs = ips
}
}(i, name)
}
wg.Wait()
return out, nil
}
@@ -0,0 +1,29 @@
package domain
import "testing"
func TestFilterCTNames(t *testing.T) {
raw := []string{
"api.example.com\nwww.example.com",
"*.example.com",
"example.com",
"other.com",
"api.example.com", // dup
"sub.api.example.com",
"evil name.example.com",
"example.com.evil.com",
}
got := FilterCTNames(raw, "example.com")
want := map[string]bool{
"api.example.com": true, "www.example.com": true,
"example.com": true, "sub.api.example.com": true,
}
if len(got) != len(want) {
t.Fatalf("got %v", got)
}
for _, n := range got {
if !want[n] {
t.Fatalf("unexpected name %q", n)
}
}
}
+1
View File
@@ -42,6 +42,7 @@ type Monitor struct {
PushToken string `json:"pushToken,omitempty"`
Tags []string `json:"tags"`
Notes string `json:"notes"`
Alerts json.RawMessage `json:"alerts"`
Position float64 `json:"position"`
LastCheck *string `json:"lastCheck"`
CreatedAt string `json:"createdAt"`
+30
View File
@@ -0,0 +1,30 @@
package widget
import (
"context"
"encoding/json"
"fmt"
)
// Embed renders a sandboxed iframe client-side — registered only so
// /api/widgets/types lists it. The browser fetches the framed page, not the
// server (remote content may be LAN-only or need the user's cookies).
type embed struct{}
func init() { Register(embed{}) }
func (embed) Meta() Type {
return Type{
Type: "embed",
Name: "Embed",
Description: "Embed any page in a tile",
Local: true,
Fields: []Field{
{Key: "url", Label: "URL", Placeholder: "https://…", Required: true},
},
}
}
func (embed) Fetch(context.Context, json.RawMessage) (any, error) {
return nil, fmt.Errorf("embed renders client-side")
}
+89
View File
@@ -0,0 +1,89 @@
package widget
import (
"context"
"encoding/json"
"fmt"
"strconv"
"strings"
)
// JSON status: GET a URL, extract one value by dot/bracket path
// ("data.status", "items[0].name"), render it in a tile. Covers the
// "is API X alive / what does it report" case without per-service widgets.
type jsonpath struct{}
func init() { Register(jsonpath{}) }
func (jsonpath) Meta() Type {
return Type{
Type: "jsonpath",
Name: "JSON value",
Description: "Extract one value from a JSON endpoint",
Fields: []Field{
{Key: "url", Label: "URL", Placeholder: "https://api…/status", Required: true},
{Key: "path", Label: "Path", Placeholder: "data.status", Required: true},
{Key: "header", Label: "Header", Placeholder: "Authorization: Bearer …"},
{Key: "label", Label: "Label", Placeholder: "Status"},
},
}
}
func (jsonpath) Fetch(ctx context.Context, cfg json.RawMessage) (any, error) {
url := cfgStr(cfg, "url")
path := cfgStr(cfg, "path")
if url == "" || path == "" {
return nil, fmt.Errorf("url and path required")
}
if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
return nil, fmt.Errorf("url must be http(s)")
}
var headers map[string]string
if h := cfgStr(cfg, "header"); h != "" {
if k, v, ok := strings.Cut(h, ":"); ok {
headers = map[string]string{strings.TrimSpace(k): strings.TrimSpace(v)}
}
}
var doc any
if err := getJSON(ctx, url, headers, &doc); err != nil {
return nil, err
}
v, ok := jsonPath(doc, path)
if !ok {
return nil, fmt.Errorf("path %q not found", path)
}
return map[string]any{"value": v, "label": cfgStr(cfg, "label")}, nil
}
// jsonPath walks doc along a path like "a.b[0].c". Segments split on dots;
// a trailing [n] indexes into arrays. Returns false on any miss.
func jsonPath(doc any, path string) (any, bool) {
cur := doc
for _, seg := range strings.Split(path, ".") {
name, rest, _ := strings.Cut(seg, "[")
if name != "" {
m, ok := cur.(map[string]any)
if !ok {
return nil, false
}
cur, ok = m[name]
if !ok {
return nil, false
}
}
for rest != "" {
idxStr, tail, _ := strings.Cut(rest, "]")
i, err := strconv.Atoi(idxStr)
if err != nil {
return nil, false
}
arr, ok := cur.([]any)
if !ok || i < 0 || i >= len(arr) {
return nil, false
}
cur = arr[i]
rest = strings.TrimPrefix(tail, "[")
}
}
return cur, true
}
@@ -0,0 +1,80 @@
package widget
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestJSONPathWalk(t *testing.T) {
doc := map[string]any{
"data": map[string]any{
"status": "ok",
"items": []any{map[string]any{"name": "first"}, "second"},
"nested": []any{[]any{float64(42)}},
},
}
cases := []struct {
path string
want any
ok bool
}{
{"data.status", "ok", true},
{"data.items[0].name", "first", true},
{"data.items[1]", "second", true},
{"data.nested[0][0]", float64(42), true},
{"data.missing", nil, false},
{"data.items[9]", nil, false},
{"data.items[-1]", nil, false},
{"data.status.x", nil, false},
{"data.items[x]", nil, false},
{"data.status[0]", nil, false},
}
for _, tc := range cases {
got, ok := jsonPath(doc, tc.path)
if ok != tc.ok || (ok && got != tc.want) {
t.Errorf("path %q: got %v,%v want %v,%v", tc.path, got, ok, tc.want, tc.ok)
}
}
}
func TestJSONPathFetch(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer tok" {
w.WriteHeader(http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"data":{"status":"degraded"}}`))
}))
defer srv.Close()
cfg, _ := json.Marshal(map[string]any{
"url": srv.URL,
"path": "data.status",
"header": "Authorization: Bearer tok",
"label": "State",
})
got, err := (jsonpath{}).Fetch(context.Background(), cfg)
if err != nil {
t.Fatal(err)
}
m := got.(map[string]any)
if m["value"] != "degraded" || m["label"] != "State" {
t.Fatalf("unexpected payload: %v", m)
}
}
func TestJSONPathFetchValidation(t *testing.T) {
for _, cfg := range []string{
`{"url":"","path":"a"}`,
`{"url":"file:///etc/passwd","path":"a"}`,
`{"url":"ftp://x","path":"a"}`,
} {
if _, err := (jsonpath{}).Fetch(context.Background(), json.RawMessage(cfg)); err == nil {
t.Errorf("expected error for %s", cfg)
}
}
}
+4
View File
@@ -3,6 +3,10 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#0a0a0a" />
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="/icon-192.png" />
<title>Dash</title>
<script>
// Pre-paint theme: localStorage mirrors the server setting, applied
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" rx="104" fill="#0a0a0a"/>
<g fill="#3b82f6">
<rect x="120" y="120" width="124" height="124" rx="28"/>
<rect x="268" y="120" width="124" height="124" rx="28" fill="#6b9ef8"/>
<rect x="120" y="268" width="124" height="124" rx="28" fill="#1d4fd7"/>
<rect x="268" y="268" width="124" height="124" rx="28"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 431 B

+16
View File
@@ -0,0 +1,16 @@
{
"name": "Dash",
"short_name": "Dash",
"description": "Self-hosted homelab dashboard",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#0a0a0a",
"theme_color": "#0a0a0a",
"icons": [
{ "src": "/icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any" },
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
{ "src": "/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
]
}
+57
View File
@@ -0,0 +1,57 @@
// Dash service worker: cache-first for static assets, network-first for /api.
// Bump CACHE on each deploy shape change; clients drop stale caches on activate.
const CACHE = "dash-v1";
const ASSET = /\.(?:js|css|woff2?|png|svg|webmanifest|ico)$/;
self.addEventListener("install", (e) => {
self.skipWaiting();
e.waitUntil(caches.open(CACHE).then((c) => c.add("/")));
});
self.addEventListener("activate", (e) => {
e.waitUntil(
caches.keys()
.then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))))
.then(() => self.clients.claim()),
);
});
self.addEventListener("fetch", (e) => {
const url = new URL(e.request.url);
if (e.request.method !== "GET" || url.origin !== self.location.origin) return;
// API: network-first so widgets stay live; fall back to cache offline.
if (url.pathname.startsWith("/api/")) {
e.respondWith(
fetch(e.request)
.then((res) => {
const clone = res.clone();
if (res.ok) caches.open(CACHE).then((c) => c.put(e.request, clone));
return res;
})
.catch(() => caches.match(e.request)),
);
return;
}
// Navigations: network-first, fall back to cached "/" (read-only offline).
if (e.request.mode === "navigate") {
e.respondWith(fetch(e.request).catch(() => caches.match("/")));
return;
}
// Static assets: cache-first, populate on miss.
if (ASSET.test(url.pathname)) {
e.respondWith(
caches.match(e.request).then(
(hit) =>
hit ||
fetch(e.request).then((res) => {
const clone = res.clone();
if (res.ok) caches.open(CACHE).then((c) => c.put(e.request, clone));
return res;
}),
),
);
}
});
+33 -2
View File
@@ -2,6 +2,7 @@ import { useEffect, useState } from "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 { BoardSwitcher } from "@/components/BoardSwitcher";
import { Board } from "@/board/Board";
import { ServiceDialog } from "@/components/ServiceDialog";
import { WidgetDialog } from "@/components/WidgetDialog";
@@ -16,6 +17,8 @@ import { SystemDetailPage } from "@/pages/SystemDetailPage";
import { IncidentsPage } from "@/pages/IncidentsPage";
import { StatusPagesPage } from "@/pages/StatusPagesPage";
import { StatusPublicPage } from "@/pages/StatusPublicPage";
import { LoginPage } from "@/pages/LoginPage";
import { api } from "@/api";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { t, type Key } from "@/i18n";
@@ -41,6 +44,8 @@ const NAV: { href: string; labelKey: Key; icon: typeof LayoutGrid }[] = [
function Shell() {
const board = useBoard();
const [loc] = useLocation();
// null = still checking; gate blocks everything except public status pages.
const [auth, setAuth] = useState<{ enabled?: boolean; setup?: boolean; authed?: boolean } | null>(null);
const [settingsOpen, setSettingsOpen] = useState(false);
const [paletteOpen, setPaletteOpen] = useState(false);
const [svcOpen, setSvcOpen] = useState(false);
@@ -64,12 +69,34 @@ function Shell() {
return () => window.removeEventListener("keydown", onKey);
}, []);
// Auth gate: check the session once, and again whenever the client sees a
// 401 (middleware in api.ts dispatches dash:unauthorized).
useEffect(() => {
const load = () => api.GET("/api/auth/session").then(({ data }) => setAuth(data ?? {}));
load();
window.addEventListener("dash:unauthorized", load);
return () => window.removeEventListener("dash:unauthorized", load);
}, []);
// URL is the board source of truth: / -> default, /b/<slug> -> that board.
const setActiveSlug = board.setActiveSlug;
useEffect(() => {
setActiveSlug(loc.startsWith("/b/") ? decodeURIComponent(loc.slice(3)) : "");
}, [loc, setActiveSlug]);
function openEditor(it: Item) {
setEditing(it);
if (it.kind === "widget") setWdgOpen(true);
else setSvcOpen(true);
}
// Public status pages stay reachable when auth is on — the backend serves
// their API unauthenticated, so the SPA must let the route through too.
const publicPage = loc.startsWith("/status/");
if (auth?.enabled && !auth.authed && !publicPage) {
return <LoginPage setup={!!auth.setup} onDone={() => location.reload()} />;
}
return (
<div className="font-sans">
<header className="sticky top-0 z-10 flex items-center gap-3.5 border-b border-border bg-bg/80 px-7 py-3.5 backdrop-blur-md">
@@ -82,9 +109,10 @@ function Shell() {
</svg>
Dash
</div>
<nav className="ml-6 flex items-center gap-1" aria-label="Primary">
<BoardSwitcher />
<nav className="ml-4 flex items-center gap-1" aria-label="Primary">
{NAV.map((n) => {
const active = n.href === "/" ? loc === "/" : loc.startsWith(n.href);
const active = n.href === "/" ? loc === "/" || loc.startsWith("/b/") : loc.startsWith(n.href);
return (
<Link
key={n.href}
@@ -138,6 +166,9 @@ function Shell() {
<Route path="/">
<Board onEditItem={openEditor} />
</Route>
<Route path="/b/:slug">
<Board onEditItem={openEditor} />
</Route>
<Route path="/monitors">
<MonitorsPage />
</Route>
+12
View File
@@ -2,3 +2,15 @@ import { createClient } from "@dash/api-client";
/** Same-origin typed client; vite proxies /api in dev. */
export const api = createClient();
api.use({
onResponse({ request, response }) {
// A 401 from a real endpoint means auth is enabled and the session is
// gone — tell the shell to drop back to the login gate. Auth endpoints
// handle their own 401s (bad password shows inline).
if (response.status === 401 && !request.url.includes("/api/auth/")) {
window.dispatchEvent(new Event("dash:unauthorized"));
}
return response;
},
});
+6 -5
View File
@@ -6,6 +6,7 @@ import { renderers } from "@/renderers";
import type { Item } from "@/types";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { t } from "@/i18n";
// Board is the renderer-agnostic glue: state + dnd + sort contexts wired once,
// the active renderer supplies the chrome.
@@ -31,7 +32,7 @@ export function Board({ onEditItem }: { onEditItem: (item: Item) => void }) {
if (board.sections.length === 0) {
return (
<main className="mx-auto flex max-w-[1140px] flex-col items-center gap-4 px-7 pb-24 pt-24">
<p className="text-[13px] text-text-faint">No sections yet.</p>
<p className="text-[13px] text-text-faint">{t("board.empty")}</p>
<AddSectionRow
open={addingSection} setOpen={setAddingSection}
name={sectionName} setName={setSectionName} onSubmit={submitSection}
@@ -52,7 +53,7 @@ export function Board({ onEditItem }: { onEditItem: (item: Item) => void }) {
index={si}
onToggle={() => void board.patchSection(sec.id, { collapsed: !sec.collapsed })}
onDelete={() => {
if (window.confirm(`Delete section "${sec.name}" and its ${sec.items.length} items?`)) {
if (window.confirm(t("board.deleteSectionConfirm", { name: sec.name ?? "", count: sec.items.length }))) {
void board.deleteSection(sec.id);
}
}}
@@ -100,7 +101,7 @@ function AddSectionRow({
if (!open) {
return (
<Button type="button" variant="outline" size="sm" onClick={() => setOpen(true)}>
<Plus size={12} /> Add section
<Plus size={12} /> {t("board.addSection")}
</Button>
);
}
@@ -110,12 +111,12 @@ function AddSectionRow({
autoFocus
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Section name"
placeholder={t("board.sectionNamePlaceholder")}
className="w-[200px]"
onBlur={() => !name.trim() && setOpen(false)}
onKeyDown={(e) => e.key === "Escape" && setOpen(false)}
/>
<Button type="submit" size="sm">Add</Button>
<Button type="submit" size="sm">{t("common.add")}</Button>
</form>
);
}
+92 -15
View File
@@ -9,20 +9,33 @@ import {
} from "react";
import { api } from "@/api";
import type {
Item, ItemInput, ItemPatch, Prefs, RendererName, Section, StatusMap, Theme,
Board, Item, ItemInput, ItemPatch, Prefs, RendererName, Section, StatusMap, Theme,
} from "@/types";
import { parsePrefs } from "@/types";
import { setLocale } from "@/i18n";
// Board store — single renderer-agnostic source of truth for the board tree,
// reachability statuses, and persisted settings. Renderers consume via useBoard.
interface BoardState {
sections: Section[];
boards: Board[];
boardId: string;
boardSlug: string;
statuses: StatusMap;
theme: Theme;
renderer: RendererName;
accent: string;
wallpaper: string;
customCss: string;
locale: string;
loaded: boolean;
refresh(): Promise<void>;
// slug is the URL form; "" means the default (first) board.
setActiveSlug(slug: string): void;
addBoard(name: string): Promise<Board | null>;
renameBoard(id: string, name: string): Promise<void>;
deleteBoard(id: string): Promise<void>;
addSection(name: string): Promise<Section | null>;
patchSection(id: string, patch: { name?: string; collapsed?: boolean }): Promise<void>;
deleteSection(id: string): Promise<void>;
@@ -41,14 +54,22 @@ const Ctx = createContext<BoardState | null>(null);
export function BoardProvider({ children }: { children: ReactNode }) {
const [sections, setSections] = useState<Section[]>([]);
const [boards, setBoards] = useState<Board[]>([]);
const [activeSlug, setActiveSlug] = useState("");
const [statuses, setStatuses] = useState<StatusMap>({});
const [prefs, setPrefs] = useState<Prefs>({ theme: "dark", renderer: "bento" });
const [prefs, setPrefs] = useState<Prefs>({ theme: "dark", renderer: "bento", accent: "", wallpaper: "", customCss: "", locale: "" });
const [loaded, setLoaded] = useState(false);
// Resolve slug → id; unknown/empty slugs fall back to the first board.
const board = boards.find((b) => b.slug === activeSlug) ?? boards[0];
const boardId = board?.id ?? "";
const boardSlug = board?.slug ?? "";
const refresh = useCallback(async () => {
const { data } = await api.GET("/api/sections");
if (!boardId) return;
const { data } = await api.GET("/api/sections", { params: { query: { board: boardId } } });
if (data) setSections(data);
}, []);
}, [boardId]);
const refreshStatus = useCallback(async () => {
const { data } = await api.GET("/api/status");
@@ -57,11 +78,11 @@ export function BoardProvider({ children }: { children: ReactNode }) {
useEffect(() => {
void (async () => {
const [{ data: secs }, { data: cfg }] = await Promise.all([
api.GET("/api/sections"),
const [{ data: bs }, { data: cfg }] = await Promise.all([
api.GET("/api/boards"),
api.GET("/api/settings"),
]);
if (secs) setSections(secs);
if (bs) setBoards(bs);
if (cfg) setPrefs(parsePrefs(cfg));
setLoaded(true);
void refreshStatus();
@@ -70,7 +91,34 @@ export function BoardProvider({ children }: { children: ReactNode }) {
return () => clearInterval(t);
}, [refreshStatus]);
const { theme, renderer } = prefs;
// Sections reload whenever the active board changes.
useEffect(() => {
void refresh();
}, [refresh]);
const addBoard = useCallback(async (name: string) => {
const { data } = await api.POST("/api/boards", { body: { name } });
if (data) setBoards((bs) => [...bs, data]);
return data ?? null;
}, []);
const renameBoard = useCallback(async (id: string, name: string) => {
const { data } = await api.PATCH("/api/boards/{id}", { params: { path: { id } }, body: { name } });
if (data) setBoards((bs) => bs.map((b) => (b.id === id ? data : b)));
}, []);
const deleteBoard = useCallback(async (id: string) => {
const { error } = await api.DELETE("/api/boards/{id}", { params: { path: { id } } });
if (!error) {
setBoards((bs) => bs.filter((b) => b.id !== id));
setActiveSlug("");
}
}, []);
const { theme, renderer, accent, wallpaper, customCss, locale } = prefs;
// t() reads a module-level dict; this effect swaps it when the pref lands.
useEffect(() => setLocale(locale), [locale]);
// Apply theme: <html data-theme> drives all CSS vars. localStorage mirrors
// the server setting so index.html can pre-paint without a flash.
@@ -80,11 +128,36 @@ export function BoardProvider({ children }: { children: ReactNode }) {
localStorage.setItem("dash.theme", theme);
}, [theme, loaded]);
// Appearance overrides: accent is a CSS var the whole theme references,
// wallpaper hangs off <body>, customCss lands in a managed <style> tag.
useEffect(() => {
if (!loaded) return;
const root = document.documentElement;
if (accent) root.style.setProperty("--accent", accent);
else root.style.removeProperty("--accent");
// Quotes/backslashes stripped so the URL can't break out of url("…").
const safeWallpaper = wallpaper.replace(/["\\()]/g, "");
document.body.style.backgroundImage = safeWallpaper ? `url("${safeWallpaper}")` : "";
document.body.style.backgroundSize = wallpaper ? "cover" : "";
document.body.style.backgroundAttachment = wallpaper ? "fixed" : "";
let tag = document.getElementById("dash-custom-css");
if (customCss) {
if (!tag) {
tag = document.createElement("style");
tag.id = "dash-custom-css";
document.head.appendChild(tag);
}
tag.textContent = customCss;
} else {
tag?.remove();
}
}, [accent, wallpaper, customCss, loaded]);
const addSection = useCallback(async (name: string) => {
const { data } = await api.POST("/api/sections", { body: { name } });
const { data } = await api.POST("/api/sections", { body: { name, boardId } });
if (data) setSections((s) => [...s, data]);
return data ?? null;
}, []);
}, [boardId]);
const patchSection = useCallback(async (id: string, patch: { name?: string; collapsed?: boolean }) => {
setSections((ss) => ss.map((s) => (s.id === id ? { ...s, ...patch } : s)));
@@ -195,22 +268,26 @@ export function BoardProvider({ children }: { children: ReactNode }) {
const setSetting = useCallback(async (key: keyof Prefs, value: string) => {
setPrefs((p) => ({ ...p, [key]: value }));
// Prefs uses camelCase; wire keys keep the snake_case convention.
const wireKey = key === "customCss" ? "custom_css" : key;
const { data, error } = await api.PUT("/api/settings", {
body: { [key]: value },
body: { [wireKey]: value },
});
if (error) await refresh();
else if (data) setPrefs(parsePrefs(data));
}, [refresh]);
const value = useMemo<BoardState>(() => ({
sections, statuses, theme, renderer, loaded,
refresh, addSection, patchSection, deleteSection,
sections, boards, boardId, boardSlug, statuses, theme, renderer, accent, wallpaper, customCss, locale, loaded,
refresh, setActiveSlug, addBoard, renameBoard, deleteBoard,
addSection, patchSection, deleteSection,
addItem, patchItem, deleteItem, uploadIcon,
moveItemLocal, moveSectionLocal, persistItemMove, persistSectionMove,
setSetting,
}), [
sections, statuses, theme, renderer, loaded,
refresh, addSection, patchSection, deleteSection,
sections, boards, boardId, boardSlug, statuses, theme, renderer, accent, wallpaper, customCss, locale, loaded,
refresh, addBoard, renameBoard, deleteBoard,
addSection, patchSection, deleteSection,
addItem, patchItem, deleteItem, uploadIcon,
moveItemLocal, moveSectionLocal, persistItemMove, persistSectionMove,
setSetting,
@@ -0,0 +1,127 @@
import { useState } from "react";
import { useLocation } from "wouter";
import { Check, ChevronsUpDown, Pencil, Trash2 } from "lucide-react";
import { useBoard } from "@/board/store";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { t } from "@/i18n";
// BoardSwitcher — header control for navigating boards (/b/<slug>) and
// managing them. The store owns the data; this only routes and calls API.
export function BoardSwitcher() {
const board = useBoard();
const [, nav] = useLocation();
const [manage, setManage] = useState(false);
if (board.boards.length === 0) return null;
return (
<>
<Select value={board.boardSlug} onValueChange={(slug) => nav(slug === board.boards[0]?.slug ? "/" : `/b/${slug}`)}>
<SelectTrigger className="h-8 w-auto min-w-[110px] gap-1 border-none bg-transparent px-2 text-[13px] font-semibold shadow-none">
<SelectValue />
<ChevronsUpDown size={12} className="text-text-faint" />
</SelectTrigger>
<SelectContent>
{board.boards.map((b) => (
<SelectItem key={b.id} value={b.slug}>{b.name}</SelectItem>
))}
<button
type="button"
className="mt-1 w-full rounded-[6px] border-t border-border px-2 py-1.5 text-left text-[12px] text-text-faint hover:text-text"
onClick={() => setManage(true)}
>
{t("boards.manage")}
</button>
</SelectContent>
</Select>
<BoardManageDialog open={manage} onOpenChange={setManage} />
</>
);
}
function BoardManageDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (o: boolean) => void }) {
const board = useBoard();
const [, nav] = useLocation();
const [name, setName] = useState("");
const [renaming, setRenaming] = useState<string | null>(null);
const [renameTo, setRenameTo] = useState("");
async function create() {
const n = name.trim();
if (!n) return;
const b = await board.addBoard(n);
setName("");
if (b) {
onOpenChange(false);
nav(`/b/${b.slug}`);
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>{t("boards.title")}</DialogTitle>
</DialogHeader>
<div className="grid gap-2">
{board.boards.map((b) => (
<div key={b.id} className="flex items-center gap-2 rounded-md border border-border px-2.5 py-1.5">
{renaming === b.id ? (
<>
<Input
value={renameTo} autoFocus
onChange={(e) => setRenameTo(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void board.renameBoard(b.id, renameTo.trim()).then(() => setRenaming(null));
if (e.key === "Escape") setRenaming(null);
}}
className="h-7 text-[13px]"
/>
<Button
variant="ghost" size="icon" className="h-7 w-7" aria-label={t("common.save")}
onClick={() => void board.renameBoard(b.id, renameTo.trim()).then(() => setRenaming(null))}
>
<Check size={13} />
</Button>
</>
) : (
<>
<span className="flex-1 text-[13px]">{b.name}</span>
<span className="font-mono text-[10px] text-text-faint">/b/{b.slug}</span>
<Button
variant="ghost" size="icon" className="h-7 w-7" aria-label={t("common.edit")}
onClick={() => { setRenaming(b.id); setRenameTo(b.name); }}
>
<Pencil size={12} />
</Button>
{board.boards.length > 1 && (
<Button
variant="ghost" size="icon" className="h-7 w-7 text-danger" aria-label={t("common.delete")}
onClick={() => void board.deleteBoard(b.id).then(() => {
if (b.slug === board.boardSlug) nav("/");
})}
>
<Trash2 size={12} />
</Button>
)}
</>
)}
</div>
))}
<div className="mt-1 flex gap-2">
<Input
value={name} placeholder={t("boards.newPlaceholder")}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") void create(); }}
className="h-8 text-[13px]"
/>
<Button size="sm" disabled={!name.trim()} onClick={() => void create()}>{t("common.add")}</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}
+23 -22
View File
@@ -7,13 +7,14 @@ import { rendererLabels, rendererList } from "@/renderers";
import { cn } from "@/lib/utils";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
import type { Item } from "@/types";
import { t } from "@/i18n";
// ⌘K palette — flat filtered list over actions + service endpoints.
// No cmdk dep: an input, a list, and an active-index is the whole mechanic.
interface Command {
id: string;
group: "Actions" | "Services";
group: "actions" | "services";
icon?: Item;
glyph?: typeof LayoutGrid;
label: string;
@@ -42,28 +43,28 @@ export function CommandPalette({
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 },
{ id: "nav-board", group: "actions", glyph: LayoutGrid, label: t("palette.goTo", { page: t("nav.board") }), run: () => navigate("/") },
{ id: "nav-monitors", group: "actions", glyph: Activity, label: t("palette.goTo", { page: t("nav.monitors") }), run: () => navigate("/monitors") },
{ id: "nav-domains", group: "actions", glyph: Globe, label: t("palette.goTo", { page: t("nav.domains") }), run: () => navigate("/domains") },
{ id: "nav-systems", group: "actions", glyph: Server, label: t("palette.goTo", { page: t("nav.systems") }), run: () => navigate("/systems") },
{ id: "nav-incidents", group: "actions", glyph: AlertTriangle, label: t("palette.goTo", { page: t("nav.incidents") }), run: () => navigate("/incidents") },
{ id: "nav-status", group: "actions", glyph: Megaphone, label: t("palette.goTo", { page: t("statusPages.title") }), run: () => navigate("/status") },
{ id: "add-service", group: "actions", glyph: Plus, label: t("header.addService"), hint: t("palette.hintNew"), run: onAddService },
{ id: "add-widget", group: "actions", glyph: Plus, label: t("header.addWidget"), hint: t("palette.hintNew"), run: onAddWidget },
{ id: "settings", group: "actions", glyph: Settings, label: t("palette.openSettings"), run: onSettings },
{
id: "theme", group: "Actions", glyph: board.theme === "dark" ? Sun : Moon,
label: `Switch to ${board.theme === "dark" ? "light" : "dark"} theme`,
id: "theme", group: "actions", glyph: board.theme === "dark" ? Sun : Moon,
label: t("palette.themeSwitch", { theme: board.theme === "dark" ? t("settings.light") : t("settings.dark") }),
run: () => void board.setSetting("theme", board.theme === "dark" ? "light" : "dark"),
},
...rendererList.map((r): Command => ({
id: `renderer-${r}`, group: "Actions", glyph: LayoutGrid,
label: `Renderer: ${rendererLabels[r]}`,
hint: board.renderer === r ? "active" : undefined,
id: `renderer-${r}`, group: "actions", glyph: LayoutGrid,
label: t("palette.renderer", { name: rendererLabels[r] }),
hint: board.renderer === r ? t("palette.hintActive") : undefined,
run: () => void board.setSetting("renderer", r),
})),
{
id: "export", group: "Actions", glyph: Download, label: "Export board JSON",
id: "export", group: "actions", glyph: Download, label: t("palette.exportJson"),
run: () => { window.open("/api/export", "_blank", "noopener"); },
},
];
@@ -72,7 +73,7 @@ export function CommandPalette({
if (it.kind === "widget") continue;
for (const u of it.urls) {
cmds.push({
id: `svc-${u.id}`, group: "Services", icon: it,
id: `svc-${u.id}`, group: "services", icon: it,
label: u.label ? `${it.name} — ${u.label}` : it.name,
hint: hostOf(u.url),
run: () => window.open(u.url, "_blank", "noopener"),
@@ -105,7 +106,7 @@ export function CommandPalette({
c.run();
}
const groups: { name: string; items: { c: Command; i: number }[] }[] = [];
const groups: { name: Command["group"]; items: { c: Command; i: number }[] }[] = [];
filtered.forEach((c, i) => {
const g = groups.find((g) => g.name === c.group);
if (g) g.items.push({ c, i });
@@ -115,7 +116,7 @@ export function CommandPalette({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="top-[18%] max-w-[520px] translate-y-0 gap-0 overflow-hidden p-0" showCloseButton={false}>
<DialogTitle className="sr-only">Command palette</DialogTitle>
<DialogTitle className="sr-only">{t("palette.title")}</DialogTitle>
<div className="flex items-center gap-2.5 border-b border-border px-4">
<input
autoFocus
@@ -126,18 +127,18 @@ export function CommandPalette({
else if (e.key === "ArrowUp") { e.preventDefault(); setActive(clamped - 1); }
else if (e.key === "Enter") { e.preventDefault(); pick(clamped); }
}}
placeholder="Search services and actions…"
placeholder={t("palette.placeholder")}
className="h-12 w-full bg-transparent text-[14px] outline-none placeholder:text-text-faint focus-visible:outline-none"
/>
<kbd className="shrink-0 rounded border border-border px-1.5 py-0.5 font-mono text-[10px] text-text-faint">esc</kbd>
</div>
<div ref={listRef} className="max-h-[340px] overflow-y-auto p-1.5">
{filtered.length === 0 ? (
<p className="px-3 py-6 text-center text-[12.5px] text-text-faint">No matches.</p>
<p className="px-3 py-6 text-center text-[12.5px] text-text-faint">{t("palette.noResults")}</p>
) : groups.map((g) => (
<div key={g.name}>
<div className="px-2.5 pb-1 pt-2 font-mono text-[10px] uppercase tracking-[0.1em] text-text-faint">
{g.name}
{t(`palette.${g.name}`)}
</div>
{g.items.map(({ c, i }) => (
<button
+35 -12
View File
@@ -7,6 +7,7 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { t } from "@/i18n";
// Add/edit a tracked domain. The first WHOIS/DNS/TLS lookup runs on create —
// expect a few seconds of spinner.
@@ -26,6 +27,8 @@ export function DomainDialog({
const [intervalH, setIntervalH] = useState("24");
const [alertDays, setAlertDays] = useState("30");
const [autoRenew, setAutoRenew] = useState(false);
const [certDays, setCertDays] = useState("");
const [mute, setMute] = useState(false);
const [notes, setNotes] = useState("");
const [busy, setBusy] = useState(false);
const [err, setErr] = useState("");
@@ -37,11 +40,13 @@ export function DomainDialog({
setIntervalH(String(domain?.intervalH ?? 24));
setAlertDays(String(domain?.alertDaysBefore ?? 30));
setAutoRenew(domain?.autoRenew ?? false);
setCertDays(domain?.alerts?.certDays ? String(domain.alerts.certDays) : "");
setMute(domain?.alerts?.mute ?? false);
setNotes(domain?.notes ?? "");
}, [open, domain]);
async function submit() {
if (!name.trim()) return setErr("Domain is required"), undefined;
if (!name.trim()) return setErr(t("domains.nameRequired")), undefined;
setBusy(true);
const body: DomainInput = {
name: name.trim(),
@@ -49,6 +54,10 @@ export function DomainDialog({
alertDaysBefore: Math.max(0, parseInt(alertDays) || 30),
autoRenew,
notes,
alerts: {
certDays: Math.max(0, parseInt(certDays) || 0),
mute,
},
};
try {
if (editing && domain) {
@@ -60,7 +69,7 @@ export function DomainDialog({
onSaved();
onOpenChange(false);
} catch (e) {
setErr(e instanceof Error ? e.message : "save failed");
setErr(e instanceof Error ? e.message : t("domains.saveFailed"));
} finally {
setBusy(false);
}
@@ -70,37 +79,51 @@ export function DomainDialog({
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{editing ? "Edit domain" : "Track a domain"}</DialogTitle>
<DialogTitle>{editing ? t("domains.editTitle") : t("domains.addTitle")}</DialogTitle>
<DialogDescription>
WHOIS, DNS, TLS certificate, and host info — refreshed on your interval.
{t("domains.addDesc")}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<Label htmlFor="dom-name">Domain</Label>
<Label htmlFor="dom-name">{t("domains.domain")}</Label>
<Input id="dom-name" value={name} onChange={(e) => setName(e.target.value)}
placeholder="example.com" className="font-mono text-[12px]" autoFocus />
placeholder={t("domains.namePlaceholder")} 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>
<Label htmlFor="dom-int">{t("domains.refreshEvery")}</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>
<Label htmlFor="dom-alert">{t("domains.alertDays")}</Label>
<Input id="dom-alert" value={alertDays} onChange={(e) => setAlertDays(e.target.value)} className="font-mono text-[12px]" />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor="dom-cert">{t("domains.certDaysLbl")}</Label>
<Input id="dom-cert" value={certDays} onChange={(e) => setCertDays(e.target.value)}
placeholder={t("domains.certDaysPlaceholder")} className="font-mono text-[12px]" />
</div>
<div className="flex items-end pb-2">
<label className="flex items-center gap-2 text-[12px] text-text-dim">
<input type="checkbox" checked={mute} onChange={(e) => setMute(e.target.checked)} />
{t("monitors.mute")}
</label>
</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
{t("domains.autoRenewLbl")}
</label>
<div className="flex flex-col gap-1.5">
<Label htmlFor="dom-notes">Notes (optional)</Label>
<Label htmlFor="dom-notes">{t("domains.notesOptional")}</Label>
<Input id="dom-notes" value={notes} onChange={(e) => setNotes(e.target.value)} />
</div>
@@ -109,10 +132,10 @@ export function DomainDialog({
<DialogFooter className="items-center">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={busy}>
Cancel
{t("common.cancel")}
</Button>
<Button type="button" onClick={() => void submit()} disabled={busy}>
{busy ? "Looking up…" : editing ? "Save" : "Track domain"}
{busy ? t("domains.lookingUp") : editing ? t("common.save") : t("domains.new")}
</Button>
</DialogFooter>
</DialogContent>
+52 -26
View File
@@ -8,6 +8,7 @@ import {
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { t } from "@/i18n";
const TYPES = ["http", "tcp", "ping", "dns", "keyword", "json", "push"] as const;
type MonType = (typeof TYPES)[number];
@@ -48,6 +49,9 @@ export function MonitorDialog({
intervalS: String(monitor?.intervalS ?? 60),
timeoutS: String(monitor?.timeoutS ?? 10),
retries: String(monitor?.retries ?? 0),
consecFails: String(monitor?.alerts?.consecutiveFailures ?? ""),
latencyWarn: String(monitor?.alerts?.latencyWarnMs ?? ""),
mute: monitor?.alerts?.mute ? "1" : "",
notes: monitor?.notes ?? "",
});
}, [open, monitor]);
@@ -59,11 +63,11 @@ export function MonitorDialog({
const type = (f.type || "http") as MonType;
async function submit() {
if (!f.name?.trim()) return setErr("Name is required"), undefined;
if (!f.name?.trim()) return setErr(t("monitors.nameRequired")), undefined;
if (type !== "push" && type !== "tcp" && type !== "ping" && type !== "dns" && !f.url?.trim())
return setErr("URL is required"), undefined;
return setErr(t("monitors.urlRequired")), undefined;
if ((type === "tcp" || type === "ping" || type === "dns") && !f.hostname?.trim() && !f.url?.trim())
return setErr("Hostname is required"), undefined;
return setErr(t("monitors.hostnameRequired")), undefined;
setBusy(true);
const body: MonitorInput = {
name: f.name.trim(),
@@ -81,6 +85,11 @@ export function MonitorDialog({
timeoutS: Math.max(1, parseInt(f.timeoutS ?? "10") || 10),
retries: Math.max(0, parseInt(f.retries ?? "0") || 0),
notes: f.notes ?? "",
alerts: {
consecutiveFailures: Math.max(1, parseInt(f.consecFails ?? "") || 1),
latencyWarnMs: Math.max(0, parseInt(f.latencyWarn ?? "") || 0),
mute: f.mute === "1",
},
};
try {
if (editing && monitor) {
@@ -91,7 +100,7 @@ export function MonitorDialog({
onSaved();
onOpenChange(false);
} catch {
setErr("save failed");
setErr(t("monitors.saveFailed"));
} finally {
setBusy(false);
}
@@ -101,18 +110,18 @@ export function MonitorDialog({
<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>
<DialogTitle>{editing ? t("monitors.editTitle") : t("monitors.newTitle")}</DialogTitle>
<DialogDescription>{t("monitors.desc")}</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>
<Label htmlFor="mon-name">{t("common.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>
<Label>{t("common.type")}</Label>
<Select value={type} onValueChange={(v) => setF((s) => ({ ...s, type: v }))}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
@@ -126,11 +135,11 @@ export function MonitorDialog({
<>
<div className="grid grid-cols-[1fr_110px] gap-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor="mon-url">URL</Label>
<Label htmlFor="mon-url">{t("common.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>
<Label>{t("monitors.method")}</Label>
<Select value={f.method || "GET"} onValueChange={(v) => setF((s) => ({ ...s, method: v }))}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
@@ -141,23 +150,23 @@ export function MonitorDialog({
</div>
{type === "keyword" && (
<div className="flex flex-col gap-1.5">
<Label htmlFor="mon-kw">Keyword</Label>
<Label htmlFor="mon-kw">{t("monitors.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)
{t("monitors.keywordInvert")}
</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>
<Label htmlFor="mon-jq">{t("monitors.jsonPath")}</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>
<Label htmlFor="mon-exp">{t("monitors.expected")}</Label>
<Input id="mon-exp" value={f.expected ?? ""} onChange={set("expected")} placeholder="ok" className="font-mono text-[12px]" />
</div>
</div>
@@ -168,17 +177,17 @@ export function MonitorDialog({
{(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>
<Label htmlFor="mon-host">{t("common.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>
<Label htmlFor="mon-port">{t("common.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>
<Label>{t("monitors.record")}</Label>
<Select value={f.dnsType || "A"} onValueChange={(v) => setF((s) => ({ ...s, dnsType: v }))}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
@@ -191,36 +200,53 @@ export function MonitorDialog({
)}
{type === "dns" && (
<div className="flex flex-col gap-1.5">
<Label htmlFor="mon-dnsexp">Expected record contains (optional)</Label>
<Label htmlFor="mon-dnsexp">{t("monitors.dnsExpected")}</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.
{t("monitors.pushHint")}
</p>
)}
<div className="grid grid-cols-3 gap-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor="mon-int">Interval (s)</Label>
<Label htmlFor="mon-int">{t("monitors.interval")}</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>
<Label htmlFor="mon-to">{t("monitors.timeout")}</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>
<Label htmlFor="mon-re">{t("monitors.retries")}</Label>
<Input id="mon-re" value={f.retries ?? "0"} onChange={set("retries")} className="font-mono text-[12px]" />
</div>
</div>
<div className="grid grid-cols-3 gap-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor="mon-cf">{t("monitors.failsBeforeDown")}</Label>
<Input id="mon-cf" value={f.consecFails ?? ""} onChange={set("consecFails")} placeholder="1" className="font-mono text-[12px]" />
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="mon-lw">{t("monitors.latencyWarn")}</Label>
<Input id="mon-lw" value={f.latencyWarn ?? ""} onChange={set("latencyWarn")} placeholder="off" className="font-mono text-[12px]" />
</div>
<div className="flex items-end pb-2">
<label className="flex items-center gap-2 text-[12px] text-text-dim">
<input type="checkbox" checked={f.mute === "1"}
onChange={(e) => setF((s) => ({ ...s, mute: e.target.checked ? "1" : "" }))} />
{t("monitors.mute")}
</label>
</div>
</div>
{monitor?.pushToken ? (
<div className="flex flex-col gap-1.5">
<Label>Push URL</Label>
<Label>{t("monitors.pushUrl")}</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>
@@ -232,10 +258,10 @@ export function MonitorDialog({
<DialogFooter className="items-center">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={busy}>
Cancel
{t("common.cancel")}
</Button>
<Button type="button" onClick={() => void submit()} disabled={busy}>
{busy ? "Saving…" : editing ? "Save" : "Add monitor"}
{busy ? t("common.saving") : editing ? t("common.save") : t("monitors.addMonitor")}
</Button>
</DialogFooter>
</DialogContent>
+21 -20
View File
@@ -10,6 +10,7 @@ import {
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { t } from "@/i18n";
// dashboard-icons CDN serves png/<slug>.png — slugify the service name for
// the one-click suggestion under the icon field.
@@ -58,9 +59,9 @@ export function ServiceDialog({
async function submit() {
const cleanUrls = urls.filter((u) => u.url.trim() !== "");
if (!name.trim()) return setErr("Name is required"), undefined;
if (!name.trim()) return setErr(t("service.nameRequired")), undefined;
if (cleanUrls.some((u) => !/^https?:\/\/.+/.test(u.url))) {
return setErr("URLs must start with http:// or https://"), undefined;
return setErr(t("service.urlRequired")), undefined;
}
setBusy(true);
try {
@@ -72,7 +73,7 @@ export function ServiceDialog({
let sid = sectionId;
if (!sid) {
// First service on an empty board — create the home section.
const sec = await board.addSection("Services");
const sec = await board.addSection(t("statusPages.services"));
if (!sec) throw new Error("could not create section");
sid = sec.id;
}
@@ -93,7 +94,7 @@ export function ServiceDialog({
}
onOpenChange(false);
} catch (e) {
setErr(e instanceof Error ? e.message : "save failed");
setErr(e instanceof Error ? e.message : t("service.saveFailed"));
} finally {
setBusy(false);
}
@@ -111,21 +112,21 @@ export function ServiceDialog({
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{editing ? "Edit service" : "Add service"}</DialogTitle>
<DialogTitle>{editing ? t("service.editTitle") : t("service.newTitle")}</DialogTitle>
<DialogDescription>
{editing ? "Update name, icon, or launch URLs." : "Name it, give it an icon, add one or more URLs."}
{editing ? t("service.editDesc") : t("service.newDesc")}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4">
<div className="grid grid-cols-[1fr_150px] gap-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor="svc-name">Name</Label>
<Label htmlFor="svc-name">{t("common.name")}</Label>
<Input id="svc-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Pi-hole" autoFocus />
</div>
{!editing && board.sections.length > 0 && (
<div className="flex flex-col gap-1.5">
<Label>Section</Label>
<Label>{t("widget.section")}</Label>
<Select value={sectionId} onValueChange={setSectionId}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
@@ -139,7 +140,7 @@ export function ServiceDialog({
</div>
<div className="flex flex-col gap-1.5">
<Label>URLs</Label>
<Label>{t("service.urls")}</Label>
{urls.map((u, i) => (
<div key={i} className="flex gap-2">
<Input
@@ -151,13 +152,13 @@ export function ServiceDialog({
<Input
value={u.label ?? ""}
onChange={(e) => setUrl(i, { label: e.target.value })}
placeholder="label"
placeholder={t("service.labelPlaceholder")}
className="w-[110px] shrink-0"
list="url-labels"
/>
<Button
type="button" variant="ghost" size="icon"
aria-label="Remove URL"
aria-label={t("service.removeUrl")}
disabled={urls.length <= 1}
onClick={() => setUrls((us) => us.filter((_, j) => j !== i))}
>
@@ -173,16 +174,16 @@ export function ServiceDialog({
className="mt-1 w-fit"
onClick={() => setUrls((us) => [...us, { url: "", label: "" }])}
>
<Plus size={12} /> Add URL
<Plus size={12} /> {t("service.addUrl")}
</Button>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="svc-icon">Icon URL</Label>
<Label htmlFor="svc-icon">{t("service.icon")}</Label>
<div className="flex items-center gap-2">
<IconPreview url={icon} />
<Input id="svc-icon" value={icon} onChange={(e) => setIcon(e.target.value)} placeholder="https://…/icon.png" className="font-mono text-[12px]" />
<Button type="button" variant="outline" size="icon" aria-label="Upload icon file" onClick={() => fileRef.current?.click()}>
<Button type="button" variant="outline" size="icon" aria-label={t("service.upload")} onClick={() => fileRef.current?.click()}>
<Upload size={13} />
</Button>
<input
@@ -196,10 +197,10 @@ export function ServiceDialog({
onClick={() => setIcon(`${DI_BASE}/${diSlug(name)}.png`)}
className="w-fit rounded border border-border px-1.5 py-0.5 font-mono text-[10.5px] text-text-faint transition-colors hover:border-border-strong hover:text-text"
>
suggest: {diSlug(name)}.png
{t("service.suggest", { slug: diSlug(name) })}
</button>
) : null}
<p className="text-[11px] text-text-faint">URL or uploaded file; blank shows a letter tile.</p>
<p className="text-[11px] text-text-faint">{t("service.iconHint")}</p>
</div>
{!editing && (
@@ -209,7 +210,7 @@ export function ServiceDialog({
checked={alsoMonitor}
onChange={(e) => setAlsoMonitor(e.target.checked)}
/>
Also create an uptime monitor for the first URL
{t("service.alsoMonitor")}
</label>
)}
@@ -219,14 +220,14 @@ export function ServiceDialog({
<DialogFooter className="items-center">
{editing ? (
<Button type="button" variant="destructive" size="sm" className="mr-auto" onClick={() => void remove()} disabled={busy}>
<Trash2 size={12} /> Delete
<Trash2 size={12} /> {t("common.delete")}
</Button>
) : null}
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={busy}>
Cancel
{t("common.cancel")}
</Button>
<Button type="button" onClick={() => void submit()} disabled={busy}>
{busy ? "Saving…" : editing ? "Save" : "Add service"}
{busy ? t("common.saving") : editing ? t("common.save") : t("service.addService")}
</Button>
</DialogFooter>
</DialogContent>
+252 -58
View File
@@ -1,9 +1,10 @@
import { useEffect, useRef, useState } from "react";
import { Bell, Download, Upload } from "lucide-react";
import { Bell, Download, HardDrive, Image, LayoutGrid, Lock, Moon, Palette, Sun, Upload, type LucideIcon } from "lucide-react";
import { useBoard } from "@/board/store";
import { api } from "@/api";
import { rendererDescriptions, rendererLabels, rendererList } from "@/renderers";
import { rendererDescriptions, rendererIcons, rendererLabels, rendererList } from "@/renderers";
import { cn } from "@/lib/utils";
import { LOCALES, t } from "@/i18n";
import { Button } from "@/components/ui/button";
import {
Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle,
@@ -12,26 +13,113 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
// Notification transports — mirrors the backend registry. Config is stored
// under notify_<name> as a JSON object; legacy notify_webhook strings map to
// {url}.
const TRANSPORTS = {
webhook: { label: "Webhook", fields: [{ k: "url", l: "URL", ph: "https://…" }] },
slack: { label: "Slack", fields: [{ k: "url", l: "Webhook URL", ph: "https://hooks.slack.com/…" }] },
discord: { label: "Discord", fields: [{ k: "url", l: "Webhook URL", ph: "https://discord.com/api/webhooks/…" }] },
telegram: { label: "Telegram", fields: [
{ k: "token", l: "Bot token", ph: "123456:ABC…", secret: true },
{ k: "chat_id", l: "Chat ID", ph: "-100…" },
{ k: "server", l: "API server (optional)", ph: "https://api.telegram.org" },
] },
gotify: { label: "Gotify", fields: [
{ k: "server", l: "Server", ph: "https://gotify.local" },
{ k: "token", l: "App token", secret: true },
] },
ntfy: { label: "ntfy", fields: [
{ k: "server", l: "Server", ph: "https://ntfy.sh" },
{ k: "topic", l: "Topic", ph: "dash-alerts" },
] },
smtp: { label: "SMTP", fields: [
{ k: "host", l: "Host", ph: "smtp.local" },
{ k: "port", l: "Port", ph: "587" },
{ k: "user", l: "User (optional)" },
{ k: "pass", l: "Password", secret: true },
{ k: "from", l: "From", ph: "dash@local" },
{ k: "to", l: "To", ph: "me@local, oncall@local" },
] },
} as const;
type TransportName = keyof typeof TRANSPORTS;
// Group — a labelled settings section. Icon + mono header keeps the dialog
// scannable now that it holds five distinct concerns.
function Group({ icon: Icon, title, children }: { icon: LucideIcon; title: string; children: React.ReactNode }) {
return (
<section className="flex flex-col gap-2.5 border-t border-border/60 pt-4 first:border-0 first:pt-0">
<h3 className="flex items-center gap-1.5 font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">
<Icon size={11} /> {title}
</h3>
{children}
</section>
);
}
export function SettingsDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (o: boolean) => void }) {
const board = useBoard();
const fileRef = useRef<HTMLInputElement>(null);
const [msg, setMsg] = useState("");
const [webhook, setWebhook] = useState("");
const [webhookMsg, setWebhookMsg] = useState("");
const [transport, setTransport] = useState<TransportName>("webhook");
const [notifyCfg, setNotifyCfg] = useState<Record<string, Record<string, string>>>({});
const [notifyMsg, setNotifyMsg] = useState("");
const [auth, setAuth] = useState<{ enabled?: boolean; authed?: boolean; username?: string }>({});
useEffect(() => {
if (!open) return;
void api.GET("/api/auth/session").then(({ data }) => setAuth(data ?? {}));
void api.GET("/api/settings").then(({ data }) => {
const v = data?.notify_webhook;
setWebhook(typeof v === "string" ? v : "");
setWebhookMsg("");
const cfg: Record<string, Record<string, string>> = {};
for (const name of Object.keys(TRANSPORTS)) {
const v = data?.["notify_" + name];
if (typeof v === "string") cfg[name] = { url: v }; // legacy webhook
else if (v !== null && typeof v === "object" && !Array.isArray(v)) {
const obj: Record<string, string> = {};
for (const [k, val] of Object.entries(v)) if (typeof val === "string") obj[k] = val;
cfg[name] = obj;
}
}
setNotifyCfg(cfg);
setNotifyMsg("");
});
}, [open]);
async function saveWebhook() {
setWebhookMsg("");
await api.PUT("/api/settings", { body: { notify_webhook: webhook.trim() } });
setWebhookMsg("saved");
const fields = TRANSPORTS[transport].fields;
const cfg = notifyCfg[transport] ?? {};
const setField = (k: string, v: string) => {
setNotifyCfg((s) => ({ ...s, [transport]: { ...s[transport], [k]: v } }));
setNotifyMsg("");
};
async function saveNotify() {
setNotifyMsg("");
await api.PUT("/api/settings", { body: { ["notify_" + transport]: cfg } });
setNotifyMsg(t("settings.saved"));
}
async function testNotify() {
setNotifyMsg("");
const { data, error } = await api.POST("/api/notify/test", { body: { transport } });
const r = data?.results?.[transport];
setNotifyMsg(error ? t("settings.failed") : r === "ok" ? t("settings.sent") : (r ?? t("settings.sent")));
}
// Enabling auth flips the whole app to the setup screen — reload so the
// gate picks it up. Same on sign-out/disable.
async function enableAuth() {
await api.PUT("/api/settings", { body: { auth_enabled: true } });
location.reload();
}
async function signOut() {
await api.POST("/api/auth/logout");
location.reload();
}
async function disableAuth() {
await api.POST("/api/auth/disable");
location.reload();
}
async function exportJson() {
@@ -62,94 +150,199 @@ export function SettingsDialog({ open, onOpenChange }: { open: boolean; onOpenCh
} else {
// 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");
setMsg(body?.error ?? t("settings.importFailed"));
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-sm">
<DialogContent className="max-w-xl">
<DialogHeader>
<DialogTitle>Settings</DialogTitle>
<DialogDescription>Board layout, theme, and backup.</DialogDescription>
<DialogTitle>{t("settings.title")}</DialogTitle>
<DialogDescription>{t("settings.desc")}</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<Label>Renderer</Label>
<div className="grid grid-cols-2 gap-1.5" role="radiogroup" aria-label="Renderer">
{rendererList.map((r) => (
<div className="flex max-h-[72vh] flex-col gap-5 overflow-y-auto pr-1">
<Group icon={LayoutGrid} title={t("settings.renderer")}>
<div className="grid grid-cols-2 gap-2" role="radiogroup" aria-label={t("settings.renderer")}>
{rendererList.map((r) => {
const Icon = rendererIcons[r];
const active = board.renderer === r;
return (
<button
key={r}
type="button"
role="radio"
aria-checked={board.renderer === r}
aria-checked={active}
onClick={() => void board.setSetting("renderer", r)}
className={cn(
"rounded-[8px] border px-3 py-2.5 text-left transition-colors",
board.renderer === r
? "border-border-strong bg-surface-hover"
"flex items-center gap-3 rounded-[9px] border px-3 py-2.5 text-left transition-colors",
active
? "border-accent bg-accent/10"
: "border-border hover:border-border-strong hover:bg-surface-hover",
)}
>
<span className={cn(
"flex size-8 shrink-0 items-center justify-center rounded-[7px] border",
active ? "border-accent/40 bg-accent/15 text-accent" : "border-border bg-bg text-text-faint",
)}>
<Icon size={15} />
</span>
<span className="min-w-0">
<span className="block text-[12.5px] font-medium">{rendererLabels[r]}</span>
<span className="block font-mono text-[10px] text-text-faint">{rendererDescriptions[r]}</span>
<span className="block truncate font-mono text-[10px] text-text-faint">{rendererDescriptions[r]}</span>
</span>
</button>
);
})}
</div>
</Group>
<Group icon={Palette} title={t("settings.appearance")}>
<div className="grid grid-cols-2 gap-3 max-[560px]:grid-cols-1">
<div className="flex flex-col gap-1.5">
<Label>{t("settings.theme")}</Label>
<div className="flex overflow-hidden rounded-[7px] border border-border">
{(["dark", "light"] as const).map((th) => (
<button
key={th}
type="button"
aria-pressed={board.theme === th}
onClick={() => void board.setSetting("theme", th)}
className={cn(
"flex flex-1 items-center justify-center gap-1.5 px-3 py-1.5 text-[12px] transition-colors",
board.theme === th ? "bg-surface-hover text-text" : "text-text-faint hover:text-text",
)}
>
{th === "dark" ? <Moon size={12} /> : <Sun size={12} />}
{th === "dark" ? t("settings.dark") : t("settings.light")}
</button>
))}
</div>
</div>
<div className="flex flex-col gap-1.5">
<Label>Theme</Label>
<Select value={board.theme} onValueChange={(v) => {
if (v === "dark" || v === "light") void board.setSetting("theme", v);
}}>
<Label>{t("settings.language")}</Label>
<Select
value={board.locale || "en"}
onValueChange={(v) => void board.setSetting("locale", v)}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="dark">Dark</SelectItem>
<SelectItem value="light">Light</SelectItem>
{Object.entries(LOCALES).map(([k, name]) => (
<SelectItem key={k} value={k}>{name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="notify-webhook">Notification webhook</Label>
<div className="flex gap-2">
<Label htmlFor="accent">{t("settings.accent")}</Label>
<div className="flex items-center gap-2">
<input
id="accent" type="color" aria-label={t("settings.accent")}
value={/^#[0-9a-fA-F]{6}$/.test(board.accent) ? board.accent : "#3b82f6"}
onChange={(e) => void board.setSetting("accent", e.target.value)}
className="h-8 w-12 cursor-pointer rounded-[6px] border border-border bg-surface p-0.5"
/>
<span className="font-mono text-[11px] text-text-faint">{board.accent || "—"}</span>
<Button type="button" variant="outline" size="sm" onClick={() => void board.setSetting("accent", "")}>
{t("common.reset")}
</Button>
</div>
</div>
</Group>
<Group icon={Image} title={t("settings.wallpaper")}>
<Input
id="notify-webhook"
value={webhook}
onChange={(e) => { setWebhook(e.target.value); setWebhookMsg(""); }}
placeholder="https://ntfy.sh/dash or Slack/Discord hook"
id="wallpaper"
value={board.wallpaper}
onChange={(e) => void board.setSetting("wallpaper", e.target.value)}
placeholder={t("settings.wallpaperPh")}
className="font-mono text-[12px]"
/>
<Button type="button" variant="outline" size="sm" onClick={() => void saveWebhook()}>
Save
</Button>
<textarea
id="custom-css"
aria-label={t("settings.customCss")}
value={board.customCss}
onChange={(e) => void board.setSetting("customCss", e.target.value)}
placeholder=".item-tile { border-radius: 0; }"
rows={3}
className="w-full resize-y rounded-[7px] border border-border bg-bg px-2.5 py-2 font-mono text-[11.5px] outline-none focus:border-border-strong"
/>
<p className="text-[11px] text-text-faint">{t("settings.customCssHint")}</p>
</Group>
<Group icon={Bell} title={t("settings.notifications")}>
<div className="flex gap-2">
<Select value={transport} onValueChange={(v) => {
// SAFETY: Select only yields TRANSPORTS keys.
setTransport(v as TransportName); setNotifyMsg("");
}}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{Object.entries(TRANSPORTS).map(([k, v]) => (
<SelectItem key={k} value={k}>
{v.label}{notifyCfg[k] ? " ·" : ""}
</SelectItem>
))}
</SelectContent>
</Select>
<Button type="button" variant="outline" size="sm" onClick={() => void saveNotify()}>{t("common.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"));
}}
type="button" variant="outline" size="icon" aria-label={t("settings.testNotify")}
onClick={() => void testNotify()}
>
<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}
{fields.map((f) => (
<div key={f.k} className="flex flex-col gap-1">
<Label htmlFor={`ntf-${f.k}`} className="text-[11px] text-text-faint">{f.l}</Label>
<Input
id={`ntf-${f.k}`}
type={"secret" in f && f.secret ? "password" : "text"}
value={cfg[f.k] ?? ""}
onChange={(e) => setField(f.k, e.target.value)}
placeholder={"ph" in f ? f.ph : ""}
className="font-mono text-[12px]"
/>
</div>
))}
<p className="text-[11px] text-text-faint">
{t("settings.notifyFanout")}
</p>
{notifyMsg ? <p className="text-[11px] text-text-faint">{notifyMsg}</p> : null}
</Group>
<div className="flex gap-2 pt-1">
<Group icon={Lock} title={t("auth.section")}>
<div className="flex items-center justify-between rounded-lg border border-border px-3 py-2.5">
<p className="text-[11px] text-text-faint">
{auth.enabled ? `${t("auth.onHint")} ${auth.username ?? ""}` : t("auth.offHint")}
</p>
{auth.enabled ? (
<div className="flex gap-2">
<Button type="button" variant="outline" size="sm" onClick={() => void signOut()}>
{t("auth.signOut")}
</Button>
<Button type="button" variant="outline" size="sm" onClick={() => void disableAuth()}>
{t("auth.disable")}
</Button>
</div>
) : (
<Button type="button" variant="outline" size="sm" onClick={() => void enableAuth()}>
{t("auth.enable")}
</Button>
)}
</div>
</Group>
<Group icon={HardDrive} title={t("settings.backup")}>
<div className="flex gap-2">
<Button type="button" variant="outline" size="sm" onClick={() => void exportJson()}>
<Download size={12} /> Export JSON
<Download size={12} /> {t("settings.exportJson")}
</Button>
<Button type="button" variant="outline" size="sm" onClick={() => fileRef.current?.click()}>
<Upload size={12} /> Import
<Upload size={12} /> {t("settings.import")}
</Button>
<input
ref={fileRef} type="file" accept=".json,.yml,.yaml" className="hidden"
@@ -160,8 +353,9 @@ 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>
<p className="text-[11px] text-text-faint">{t("settings.importHint")}</p>
{msg ? <p className="text-[12px] text-destructive">{msg}</p> : null}
</Group>
</div>
</DialogContent>
</Dialog>
+16 -15
View File
@@ -10,6 +10,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import type { components } from "@dash/api-client";
import { t } from "@/i18n";
type WidgetType = components["schemas"]["WidgetType"];
@@ -66,10 +67,10 @@ export function WidgetDialog({
const meta = types.find((t) => t.type === type);
async function submit() {
if (!type) return setErr("Pick a widget type"), undefined;
if (!name.trim()) return setErr("Name is required"), undefined;
if (!type) return setErr(t("widget.pickType")), undefined;
if (!name.trim()) return setErr(t("service.nameRequired")), undefined;
if (meta?.fields.some((f) => f.required && !values[f.key]?.trim())) {
return setErr("Fill in the required fields"), undefined;
return setErr(t("widget.requiredFields")), undefined;
}
setBusy(true);
try {
@@ -83,7 +84,7 @@ export function WidgetDialog({
} else {
let sid = sectionId;
if (!sid) {
const sec = await board.addSection("Widgets");
const sec = await board.addSection(t("widget.defaultSection"));
if (!sec) throw new Error("could not create section");
sid = sec.id;
}
@@ -91,7 +92,7 @@ export function WidgetDialog({
}
onOpenChange(false);
} catch (e) {
setErr(e instanceof Error ? e.message : "save failed");
setErr(e instanceof Error ? e.message : t("service.saveFailed"));
} finally {
setBusy(false);
}
@@ -101,21 +102,21 @@ export function WidgetDialog({
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{editing ? "Edit widget" : "Add widget"}</DialogTitle>
<DialogTitle>{editing ? t("widget.editTitle") : t("widget.newTitle")}</DialogTitle>
<DialogDescription>
{editing ? "Update widget settings." : "Pick a type and point it at your service."}
{editing ? t("widget.editDesc") : t("widget.newDesc")}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4">
<div className="grid grid-cols-[1fr_150px] gap-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor="wdg-name">Name</Label>
<Label htmlFor="wdg-name">{t("common.name")}</Label>
<Input id="wdg-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Pi-hole" autoFocus />
</div>
{!editing && board.sections.length > 0 && (
<div className="flex flex-col gap-1.5">
<Label>Section</Label>
<Label>{t("widget.section")}</Label>
<Select value={sectionId} onValueChange={setSectionId}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
@@ -129,9 +130,9 @@ export function WidgetDialog({
</div>
<div className="flex flex-col gap-1.5">
<Label>Type</Label>
<Label>{t("common.type")}</Label>
<Select value={type} onValueChange={setType}>
<SelectTrigger><SelectValue placeholder="Pick one…" /></SelectTrigger>
<SelectTrigger><SelectValue placeholder={t("widget.pickOne")} /></SelectTrigger>
<SelectContent>
{types.map((t) => (
<SelectItem key={t.type} value={t.type}>{t.name}</SelectItem>
@@ -146,14 +147,14 @@ export function WidgetDialog({
{meta?.fields.map((f) => (
<div key={f.key} className="flex flex-col gap-1.5">
<Label htmlFor={`wdg-${f.key}`}>
{f.label}{f.required ? "" : " (optional)"}
{f.label}{f.required ? "" : ` (${t("common.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>
<SelectTrigger><SelectValue placeholder={t("widget.pickOne")} /></SelectTrigger>
<SelectContent>
{(f.key === "monitorId" ? monitors : f.key === "domainId" ? domains : systems).map((m) => (
<SelectItem key={m.id} value={m.id ?? ""}>{m.name}</SelectItem>
@@ -178,10 +179,10 @@ export function WidgetDialog({
<DialogFooter className="items-center">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={busy}>
Cancel
{t("common.cancel")}
</Button>
<Button type="button" onClick={() => void submit()} disabled={busy}>
{busy ? "Saving…" : editing ? "Save" : "Add widget"}
{busy ? t("common.saving") : editing ? t("common.save") : t("header.addWidget")}
</Button>
</DialogFooter>
</DialogContent>
+235
View File
@@ -0,0 +1,235 @@
// Shared time-series chart primitives — the Beszel-grade look on our tokens:
// titled cards with a subtitle, soft area fills over a real time axis,
// unit-aware Y ticks, a dotted tooltip sorted by value, and legend chips.
import type { ReactNode } from "react";
import {
Area,
CartesianGrid,
ComposedChart,
Line,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { t } from "@/i18n";
/** One chart row: epoch ms plus named series values. */
export type ChartRow = { t: number } & Record<string, number | undefined>;
export interface Series {
key: string;
label: string;
color: string;
/** Shared value → stacked areas (container charts). */
stack?: string;
/** Render as a bare line, no fill (temperatures). */
line?: boolean;
}
export const CHART_COLORS = [
"var(--chart-1)",
"var(--chart-2)",
"var(--chart-3)",
"var(--chart-4)",
"var(--chart-5)",
"var(--chart-6)",
"var(--chart-7)",
"var(--chart-8)",
];
export const chartColor = (i: number) => CHART_COLORS[i % CHART_COLORS.length] ?? "var(--accent)";
const axisTick = { fontSize: 10, fill: "var(--text-faint)" };
/** Auto Y-domain with headroom, rounding to a readable ceiling. */
export function niceMax(dataMax: number): number {
if (!Number.isFinite(dataMax) || dataMax <= 0) return 1;
if (dataMax > 10) return Math.ceil(dataMax * 1.05);
if (dataMax > 1) return Math.ceil(dataMax * 10.5) / 10;
return Math.ceil(dataMax * 105) / 100;
}
/** Tick format by range: HH:mm up to 2 days, day+time to 2 weeks, then dates. */
export function xTick(spanMs: number): (v: number) => string {
if (spanMs <= 48 * 3600_000)
return (v) => new Date(v).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
if (spanMs <= 14 * 86400_000)
return (v) =>
new Date(v).toLocaleString([], { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" });
return (v) => new Date(v).toLocaleDateString([], { month: "short", day: "numeric" });
}
const fullTime = (ms: number) =>
new Date(ms).toLocaleString([], {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
interface TipItem {
name?: string;
value?: number | string;
color?: string;
stroke?: string;
dataKey?: string | number;
payload?: ChartRow;
}
/** Dotted tooltip: timestamp header, per-series rows sorted by value desc. */
function ChartTip({
active,
payload,
fmt,
}: {
active?: boolean;
payload?: TipItem[];
fmt: (v: number) => string;
}) {
if (!active || !payload?.length) return null;
const t0 = payload[0]?.payload?.t;
const rows = payload
.filter((p): p is TipItem & { value: number } => typeof p.value === "number")
.sort((a, b) => b.value - a.value);
if (!rows.length) return null;
return (
<div className="min-w-28 rounded-lg border border-border-strong bg-popover px-2.5 py-1.5 text-[11px] shadow-xl">
{t0 !== undefined && <div className="mb-1 font-medium text-text-dim">{fullTime(t0)}</div>}
<div className="grid gap-1">
{rows.map((p) => (
<div key={String(p.dataKey ?? p.name)} className="flex items-center gap-2">
<span
className="size-2 shrink-0 rounded-[2px]"
style={{ background: p.color ?? p.stroke ?? "var(--text-faint)" }}
/>
<span className="flex-1 truncate text-text-faint">{p.name}</span>
<span className="font-mono font-medium">{fmt(p.value)}</span>
</div>
))}
</div>
</div>
);
}
/** Area/line chart on a time-scaled X axis. Empty data renders a hint, not a blank box. */
export function MetricChart({
rows,
series,
fmt,
domain,
spanMs,
}: {
rows: ChartRow[];
series: Series[];
fmt: (v: number) => string;
domain?: [number, number] | undefined;
spanMs: number;
}) {
if (!rows.length) {
return (
<div className="flex h-full items-center justify-center text-[11px] text-text-faint">
{t("chart.noData")}
</div>
);
}
return (
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={rows} margin={{ top: 4, right: 8, bottom: 0, left: 0 }}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" vertical={false} />
<XAxis
dataKey="t"
type="number"
scale="time"
domain={["dataMin", "dataMax"]}
tick={axisTick}
tickLine={false}
axisLine={false}
tickFormatter={xTick(spanMs)}
minTickGap={28}
/>
<YAxis
tick={axisTick}
tickLine={false}
axisLine={false}
width="auto"
domain={domain ?? [0, niceMax]}
tickFormatter={(v) => fmt(Number(v))}
/>
<Tooltip content={<ChartTip fmt={fmt} />} cursor={{ stroke: "var(--border-strong)" }} />
{series.map((s) =>
s.line ? (
<Line
key={s.key}
dataKey={s.key}
name={s.label}
type="monotone"
stroke={s.color}
strokeWidth={1.5}
dot={false}
connectNulls
isAnimationActive={false}
/>
) : (
<Area
key={s.key}
dataKey={s.key}
name={s.label}
type="monotone"
stroke={s.color}
fill={s.color}
fillOpacity={s.stack ? 0.65 : 0.14}
strokeWidth={1.5}
dot={false}
{...(s.stack !== undefined ? { stackId: s.stack } : {})}
isAnimationActive={false}
/>
),
)}
</ComposedChart>
</ResponsiveContainer>
);
}
/** Titled chart card in the Beszel layout: title + subtitle on top, legend chips below. */
export function ChartCard({
title,
hint,
actions,
series,
height = 160,
children,
}: {
title: string;
hint?: string;
actions?: ReactNode;
series?: Series[];
height?: number;
children: ReactNode;
}) {
return (
<div className="rounded-[10px] border border-border bg-surface p-3 pt-3.5">
<div className="mb-2 flex items-start justify-between gap-2 px-1">
<div>
<h3 className="text-[13px] font-medium tracking-tight">{title}</h3>
{hint ? <p className="text-[10.5px] text-text-faint">{hint}</p> : null}
</div>
{actions}
</div>
<div style={{ height }}>{children}</div>
{series && series.length > 1 && (
<div className="mt-2 flex flex-wrap gap-x-3.5 gap-y-1 px-1">
{series.map((s) => (
<span
key={s.key}
className="flex items-center gap-1.5 font-mono text-[10px] text-text-faint"
>
<span className="size-2 rounded-[2px]" style={{ background: s.color }} />
{s.label}
</span>
))}
</div>
)}
</div>
);
}
+2 -1
View File
@@ -3,6 +3,7 @@ import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
import { t } from "@/i18n";
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
@@ -27,7 +28,7 @@ function DialogContent({
{showCloseButton ? (
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-md p-1 text-text-faint transition-colors hover:bg-surface-hover hover:text-text focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring">
<X size={14} />
<span className="sr-only">Close</span>
<span className="sr-only">{t("common.close")}</span>
</DialogPrimitive.Close>
) : null}
</DialogPrimitive.Content>
+436
View File
@@ -0,0 +1,436 @@
import type { Key } from "@/i18n/en";
// Czech strings. Partial — missing keys fall back to English in t().
export const cs: Partial<Record<Key, string>> = {
"nav.board": "Nástěnka",
"nav.monitors": "Monitory",
"nav.domains": "Domény",
"nav.systems": "Systémy",
"nav.incidents": "Incidenty",
"nav.status": "Stav",
"header.search": "Hledat (⌘K)",
"header.settings": "Nastavení",
"header.theme": "Přepnout motiv",
"header.addWidget": "Přidat widget",
"header.addService": "Přidat službu",
"header.widget": "Widget",
"common.add": "Přidat",
"common.edit": "Upravit",
"common.delete": "Smazat",
"common.cancel": "Zrušit",
"common.save": "Uložit",
"common.create": "Vytvořit",
"common.creating": "Vytvářím…",
"common.saving": "Ukládám…",
"common.test": "Test",
"common.loading": "Načítám…",
"common.name": "Název",
"common.title": "Titulek",
"common.description": "Popis",
"common.slug": "Slug",
"common.url": "URL",
"common.type": "Typ",
"common.hostname": "Hostname",
"common.port": "Port",
"common.optional": "volitelné",
"common.enabled": "Zapnuto",
"common.refresh": "Obnovit",
"common.never": "nikdy",
"common.all": "Vše",
"common.reset": "Resetovat",
"common.close": "Zavřít",
"auth.setupTitle": "Vytvořte si účet správce",
"auth.setupHint": "První uživatel se stane správcem. Pouze lokální účet — žádná externí identita.",
"auth.signInTitle": "Přihlásit se",
"auth.signInHint": "Tato instance Dash vyžaduje přihlášení.",
"auth.username": "Uživatelské jméno nebo e-mail",
"auth.password": "Heslo",
"auth.createAccount": "Vytvořit účet",
"auth.signIn": "Přihlásit se",
"auth.invalid": "Neplatné jméno nebo heslo.",
"auth.rateLimited": "Příliš mnoho pokusů — počkejte minutu.",
"auth.section": "Ověřování",
"auth.offHint": "Kdokoli, kdo dosáhne na tuto instanci, má plný přístup. Zapněte pro vyžadování přihlášení.",
"auth.enable": "Zapnout ověřování",
"auth.onHint": "Přihlášen jako",
"auth.signOut": "Odhlásit se",
"auth.disable": "Vypnout ověřování",
"auth.disabledNote": "Ověřování je vypnuté — nástěnka je otevřená vaší síti.",
"boards.title": "Nástěnky",
"boards.manage": "Spravovat nástěnky…",
"boards.newPlaceholder": "Název nové nástěnky",
"board.empty": "Zatím žádné sekce.",
"board.emptyHint": "Přidejte službu nebo widget.",
"board.addSection": "Přidat sekci",
"board.sectionNamePlaceholder": "Název sekce",
"board.deleteSectionConfirm": "Smazat sekci \"{name}\" a jejích {count} položek?",
"palette.title": "Příkazová paleta",
"palette.placeholder": "Hledat služby a akce…",
"palette.noResults": "Nic nenalezeno.",
"palette.pages": "Stránky",
"palette.services": "Služby",
"palette.actions": "Akce",
"palette.goTo": "Přejít na {page}",
"palette.openSettings": "Otevřít nastavení",
"palette.themeSwitch": "Přepnout na motiv {theme}",
"palette.renderer": "Vzhled: {name}",
"palette.exportJson": "Exportovat nástěnku do JSON",
"palette.hintNew": "nové",
"palette.hintActive": "aktivní",
"settings.title": "Nastavení",
"settings.desc": "Rozvržení nástěnky, motiv a záloha.",
"settings.theme": "Motiv",
"settings.language": "Jazyk",
"settings.dark": "Tmavý",
"settings.light": "Světlý",
"settings.renderer": "Vzhled nástěnky",
"settings.appearance": "Vzhled",
"settings.accent": "Akcentní barva",
"settings.accentHint": "Libovolná CSS barva — hex, hsl, pojmenovaná.",
"settings.wallpaper": "URL tapety",
"settings.wallpaperHint": "Pozadí přes celou nástěnku.",
"settings.customCss": "Vlastní CSS",
"settings.customCssHint": "Aplikuje se naposled — přebije proměnné motivu.",
"settings.notifications": "Notifikace",
"settings.notifyFanout": "Přepnutí monitorů a expirace domén/certifikátů se rozešlou všem nastaveným transportům.",
"settings.exportJson": "Export JSON",
"settings.import": "Import",
"settings.importHint": "Import umí Dash exporty plus konfigurace Homepage, Homarr a Dashy.",
"settings.saved": "uloženo",
"settings.sent": "odesláno",
"settings.failed": "selhalo",
"settings.testNotify": "Odeslat testovací notifikaci",
"settings.wallpaperPh": "https://… — prázdné = žádná",
"settings.importFailed": "Import selhal",
"settings.backup": "Záloha a import",
"service.editTitle": "Upravit službu",
"service.newTitle": "Přidat službu",
"service.desc": "Záložka na nástěnce — název, ikona, jedna nebo více URL.",
"service.newDesc": "Pojmenujte ji, dejte ikonu, přidejte jednu nebo více URL.",
"service.editDesc": "Upravte název, ikonu nebo URL.",
"service.icon": "URL ikony",
"service.iconHint": "URL nebo nahraný soubor; prázdné zobrazí dlaždici s písmenem.",
"service.upload": "Nahrát",
"service.urls": "URL",
"service.addUrl": "Přidat URL",
"service.labelPlaceholder": "štítek",
"service.removeUrl": "Odebrat URL",
"service.suggest": "návrh: {slug}.png",
"service.alsoMonitor": "Vytvořit i uptime monitor pro první URL",
"service.nameRequired": "Název je povinný",
"service.urlRequired": "Je potřeba alespoň jedna platná http(s) URL",
"service.saveFailed": "uložení selhalo",
"service.addService": "Přidat službu",
"widget.editTitle": "Upravit widget",
"widget.newTitle": "Přidat widget",
"widget.desc": "Živá data na nástěnce.",
"widget.newDesc": "Vyberte typ a nasměrujte na svou službu.",
"widget.editDesc": "Upravte nastavení widgetu.",
"widget.type": "Typ widgetu",
"widget.section": "Sekce",
"widget.pickOne": "Vyberte…",
"widget.pickType": "Vyberte typ widgetu",
"widget.requiredFields": "Vyplňte povinná pole",
"widget.defaultSection": "Widgety",
"widget.err": "chyba",
"widget.offline": "offline",
"widget.fetchFailed": "načtení selhalo",
"widget.queries": "{n} dotazů",
"widget.blocked": "{n} blokováno",
"widget.msAvg": "{n} ms prům.",
"widget.videos": "{n} videí",
"widget.blockedLbl": "blokováno",
"widget.photosLbl": "fotek",
"widget.embedHint": "Nastavte URL v konfiguraci widgetu.",
"renderer.localTime": "Místní čas",
"renderer.down": "Nedostupné",
"renderer.allGreen": "vše v pořádku",
"renderer.offline": "offline",
"renderer.needsAttention": "vyžaduje pozornost",
"renderer.downCount": "{n} nedostupných",
"renderer.entry": "položka",
"renderer.entries": "položek",
"renderer.dragSection": "Přesunout sekci",
"renderer.deleteSection": "Smazat sekci",
"renderer.live": "živě",
"monitors.title": "Monitory",
"monitors.subtitle": "HTTP, TCP, ping, DNS, klíčové slovo, JSON a push kontroly.",
"monitors.new": "Nový monitor",
"monitors.empty": "Zatím žádné monitory.",
"monitors.emptyHint": "Přidejte jeden pro sledování dostupnosti.",
"monitors.deleteConfirm": "Smazat monitor \"{name}\" a jeho historii?",
"monitors.checkNow": "Zkontrolovat nyní",
"monitors.pause": "Pozastavit",
"monitors.resume": "Obnovit",
"monitors.editTitle": "Upravit monitor",
"monitors.newTitle": "Nový monitor",
"monitors.desc": "Kontrola dostupnosti v intervalu.",
"monitors.method": "Metoda",
"monitors.keyword": "Klíčové slovo",
"monitors.keywordInvert": "Invertovat (down když nalezeno)",
"monitors.jsonPath": "JSON cesta",
"monitors.expected": "Očekávaná hodnota",
"monitors.record": "Záznam",
"monitors.dnsExpected": "Záznam obsahuje (volitelné)",
"monitors.pushHint": "Po uložení volají vaše úlohy zobrazenou push URL. Ztichne-li se na 2× interval, monitor přejde do down.",
"monitors.interval": "Interval (s)",
"monitors.timeout": "Timeout (s)",
"monitors.retries": "Opakování",
"monitors.failsBeforeDown": "Selhání před down",
"monitors.latencyWarn": "Varování latence (ms)",
"monitors.mute": "Ztišit notifikace",
"monitors.pushUrl": "Push URL",
"monitors.addMonitor": "Přidat monitor",
"monitors.nameRequired": "Název je povinný",
"monitors.urlRequired": "URL je povinná",
"monitors.hostnameRequired": "Hostname je povinný",
"monitors.saveFailed": "uložení selhalo",
"monitors.uptime24h": "24h dostupnost",
"monitors.avgLatency": "Prům. latence",
"monitors.lastCheck": "Poslední kontrola",
"monitors.latencyHistory": "Latence",
"monitors.uptimeHistory": "Historie dostupnosti",
"monitors.noHeartbeats": "Zatím žádné odezvy.",
"monitors.everyInterval": "každých {interval}s",
"monitors.status": "Stav",
"monitors.uptime30d": "Dostupnost 30d",
"monitors.ping": "Ping",
"monitors.every": "Každých",
"monitors.checksHint": "HTTP, TCP, ping, DNS, klíčové slovo, JSON cesta nebo push — kontroly běží na serveru ve vašem intervalu.",
"monitors.uptime": "Dostupnost",
"monitors.checks": "Kontroly",
"monitors.responseTime": "Doba odezvy",
"monitors.recentChecks": "Nedávné kontroly",
"monitors.noChecks": "Žádné kontroly v rozsahu.",
"monitors.noChecksBucket": "žádné kontroly",
"monitors.bucketChecks": "{status} ({n} kontrol)",
"domains.title": "Domény",
"domains.subtitle": "WHOIS, DNS a hlídání certifikátů.",
"domains.new": "Sledovat doménu",
"domains.empty": "Zatím žádné sledované domény.",
"domains.emptyHint": "Expirace, registrátor, DNS záznamy, SSL certifikáty a detekce poskytovatelů.",
"domains.domain": "Doména",
"domains.ssl": "SSL",
"domains.providers": "Poskytovatelé",
"domains.lookupError": "chyba dotazu",
"domains.refreshNow": "Obnovit nyní",
"domains.deleteConfirm": "Přestat sledovat \"{name}\"?",
"domains.refresh": "Překontrolovat",
"domains.expiresIn": "expiruje za {days}d",
"domains.expired": "expirováno",
"domains.certDays": "cert {days}d",
"domains.registrar": "Registrátor",
"domains.expires": "Expirace",
"domains.cert": "Certifikát",
"domains.nameservers": "Nameservery",
"domains.hosting": "Hosting",
"domains.subdomains": "Subdomény",
"domains.subdomainsHint": "Objevené přes Certificate Transparency logy; resolvováno přes DNS.",
"domains.scan": "Skenovat",
"domains.scanning": "Skenuji…",
"domains.noSubdomains": "Zatím žádné subdomény — spusťte sken.",
"domains.lastSeen": "naposledy viděna",
"domains.namePlaceholder": "example.com",
"domains.addTitle": "Sledovat doménu",
"domains.editTitle": "Upravit doménu",
"domains.addDesc": "WHOIS, DNS, TLS certifikát a host — obnoveno ve vašem intervalu.",
"domains.refreshEvery": "Obnovovat každých (hodin)",
"domains.alertDays": "Dní před expirací pro alert",
"domains.certDaysLbl": "Práh TLS alertu (dny)",
"domains.certDaysPlaceholder": "stejné jako expirace",
"domains.autoRenewLbl": "Auto-renew zapnutý u registrátora",
"domains.notesOptional": "Poznámky (volitelné)",
"domains.lookingUp": "Dotazuji…",
"domains.nameRequired": "Doména je povinná",
"domains.saveFailed": "uložení selhalo",
"domains.add": "Sledovat",
"domains.domainExpires": "Doména expiruje",
"domains.sslExpires": "SSL expiruje",
"domains.registration": "Registrace",
"domains.dns": "DNS",
"domains.tlsCert": "TLS certifikát",
"domains.host": "Host",
"domains.checkHistory": "Historie kontrol",
"domains.noSnapshots": "Zatím žádné snímky.",
"domains.created": "Vytvořeno",
"domains.updated": "Aktualizováno",
"domains.registrant": "Registrant",
"domains.country": "Země",
"domains.abuse": "Abuse",
"domains.statusLbl": "Stav",
"domains.location": "Lokalita",
"domains.isp": "ISP",
"domains.org": "Organizace",
"domains.checked": "Zkontrolováno",
"domains.dnsProvider": "DNS poskytovatel",
"domains.email": "E-mail",
"domains.issuer": "Vydavatel",
"domains.valid": "Platnost",
"domains.subject": "Subjekt",
"domains.key": "Klíč",
"domains.algorithm": "Algoritmus",
"domains.responseHeaders": "Hlavičky odpovědi ({n})",
"domains.transferLock": "transfer lock",
"domains.whoisPrivacy": "whois ochrana",
"domains.autoRenew": "auto-renew",
"domains.scanShort": "Sken",
"domains.noSubsFound": "Žádné subdomény nenalezeny — sken dotazuje certificate transparency.",
"domains.expPrefix": "exp",
"domains.sslPrefix": "ssl",
"systems.title": "Systémy",
"systems.subtitle": "Servery a PC reportující přes dash-agent.",
"systems.new": "Nový systém",
"systems.empty": "Zatím žádné systémy.",
"systems.emptyHintPre": "Přidejte jeden, poté spusťte",
"systems.emptyHintPost": "na hostiteli s vydaným tokenem.",
"systems.waiting": "čeká se na agenta…",
"systems.cores": "{n} jader",
"systems.newTitle": "Nový systém",
"systems.newDesc": "Pojmenujte hostitele, poté na něm spusťte dash-agent s vydaným tokenem.",
"systems.agentCmdFor": "Příkaz agenta pro",
"systems.tokenWarning": "Token identifikuje tento systém — nedávejte ho do veřejných repozitářů.",
"systems.done": "Hotovo",
"systems.copy": "Kopírovat",
"systems.deleteConfirm": "Smazat systém \"{name}\"?",
"systems.agentHint": "Spusťte agenta s tímto tokenem; statistiky dorazí sem.",
"systems.token": "Bearer token",
"systems.containers": "Kontejnery",
"systems.drives": "Disky",
"systems.zfs": "ZFS pooly",
"systems.gpu": "GPU",
"systems.cpu": "CPU",
"systems.mem": "Paměť",
"systems.disk": "Disk",
"systems.net": "Síť",
"systems.temp": "Teplota",
"systems.load": "Zátěž",
"systems.up": "up",
"systems.down": "down",
"systems.namePlaceholder": "nas",
"systems.addTitle": "Přidat systém",
"systems.addDesc": "Vytvoří bearer token — spusťte s ním dash-agent.",
"systems.notFound": "Systém nenalezen.",
"systems.usage": "Využití",
"systems.chartCpu": "Využití CPU",
"systems.chartCpuHint": "Průměrné využití CPU celého systému",
"systems.chartMem": "Využití paměti",
"systems.chartMemHint": "Přesné využití v zaznamenaný čas",
"systems.chartUsed": "obsazeno",
"systems.chartDisk": "Využití disku",
"systems.chartDiskHint": "Využití kořenového oddílu",
"systems.chartNet": "Šířka pásma",
"systems.chartNetHint": "Síťový provoz veřejných rozhraní",
"systems.chartRx": "přijato",
"systems.chartTx": "odesláno",
"systems.chartLoad": "Průměrné zatížení",
"systems.chartLoadHint": "Zátěž za 1, 5 a 15 minut",
"systems.chartTemp": "Teplota",
"systems.chartTempHint": "Systémové senzory",
"systems.chartDockerCpu": "CPU Dockeru",
"systems.chartDockerCpuHint": "CPU na kontejner, procent jednoho jádra",
"systems.chartDockerMem": "Paměť Dockeru",
"systems.chartDockerMemHint": "Paměť na kontejner",
"systems.chartGpu": "Využití GPU",
"systems.chartGpuHint": "Využití na GPU",
"systems.chartVram": "VRAM GPU",
"systems.chartVramHint": "Paměť na GPU",
"systems.temperatures": "Teploty",
"systems.drivesSmart": "Disky (SMART)",
"systems.agentCmd": "Příkaz agenta",
"systems.neverSeen": "nikdy neviděn",
"systems.seenAt": "viděn {time}",
"systems.failing": "selhává",
"systems.uptime": "Doba běhu",
"chart.noData": "Zatím žádná data.",
"incidents.title": "Incidenty",
"incidents.subtitle": "Automaticky otevřené při výpadcích monitorů, nebo vedené ručně.",
"incidents.new": "Nový incident",
"incidents.empty": "Žádné otevřené incidenty.",
"incidents.resolve": "Vyřešit",
"incidents.acknowledge": "Potvrdit",
"incidents.close": "Zavřít",
"incidents.reopen": "Znovu otevřít",
"incidents.deleteConfirm": "Smazat incident \"{title}\"?",
"incidents.openOnly": "Jen otevřené",
"incidents.showAll": "Zobrazit vše",
"incidents.noUpdates": "Zatím žádné aktualizace.",
"incidents.notePlaceholder": "Přidat poznámku…",
"incidents.post": "Odeslat",
"incidents.open": "otevřený",
"incidents.resolved": "vyřešený",
"incidents.editTitle": "Upravit incident",
"incidents.newTitle": "Nový incident",
"incidents.desc": "Ručně evidujte výpadek nebo degradaci.",
"incidents.severity": "Závažnost",
"incidents.sevMinor": "menší",
"incidents.sevMajor": "významný",
"incidents.sevCritical": "kritický",
"incidents.status": "Stav",
"incidents.monitor": "Monitor",
"incidents.bodyLabel": "Podrobnosti",
"incidents.noteOptional": "Poznámka (volitelná)",
"incidents.titlePlaceholder": "NAS offline",
"incidents.noteFirst": "Poprvé pozorováno…",
"incidents.createError": "Incident se nepodařilo vytvořit.",
"statusPages.title": "Stavové stránky",
"statusPages.publicAt": "Veřejné živé náhledy na",
"statusPages.new": "Nová stránka",
"statusPages.empty": "Zatím žádné stavové stránky.",
"statusPages.open": "Otevřít veřejnou stránku",
"statusPages.deleteConfirm": "Smazat stavovou stránku \"{title}\"?",
"statusPages.createError": "Nepodařilo se vytvořit — slug může být obsazený.",
"statusPages.newTitle": "Nová stavová stránka",
"statusPages.newDesc": "Veřejný živý náhled nad vybranými monitory.",
"statusPages.monitors": "Monitory",
"statusPages.services": "Služby",
"statusPages.noMonitors": "Zatím žádné monitory — prázdný výběr zobrazí vše.",
"statusPages.allHint": "Nechte vše nezaškrtnuté pro zahrnutí všech aktivních monitorů.",
"statusPages.slugPlaceholder": "auto",
"statusPages.titlePlaceholder": "Stav homelab",
"maintenance.title": "Okna údržby",
"maintenance.subtitle": "Aktivní okna ztišují alerty + auto-incidenty a označují pokryté monitory.",
"maintenance.schedule": "Naplánovat",
"maintenance.empty": "Žádná naplánovaná údržba.",
"maintenance.deleteConfirm": "Smazat okno \"{title}\"?",
"maintenance.active": "aktivní",
"maintenance.scheduled": "naplánované",
"maintenance.dialogTitle": "Naplánovat údržbu",
"maintenance.dialogDesc": "Potlačí alerty a auto-incidenty po dobu okna.",
"maintenance.starts": "Začátek",
"maintenance.ends": "Konec",
"maintenance.titlePlaceholder": "Upgrade sítě",
"maintenance.createError": "Neplatné okno — zkontrolujte časy.",
"statusPublic.operational": "Všechny systémy v provozu",
"statusPublic.degraded": "Částečný výpadek",
"statusPublic.outage": "Velký výpadek",
"statusPublic.underMaintenance": "Probíhá údržba",
"statusPublic.poweredBy": "Běží na Dash",
"statusPublic.updated": "Aktualizováno {time}",
"statusPublic.noMonitors": "Tato stránka zatím nemá monitory.",
"statusPublic.uptime90": "90denní dostupnost",
"statusPublic.services": "Služby",
"statusPublic.systems": "Systémy",
"statusPublic.maintenance": "Údržba",
"statusPublic.incidents": "Incidenty",
"statusPublic.checking": "Kontroluji…",
"statusPublic.notFound": "Stavová stránka nenalezena.",
"statusPublic.footer": "běží na dash",
};
+410
View File
@@ -20,8 +20,418 @@ export const en = {
"common.delete": "Delete",
"common.cancel": "Cancel",
"common.save": "Save",
"common.create": "Create",
"common.creating": "Creating…",
"common.saving": "Saving…",
"common.test": "Test",
"common.loading": "Loading…",
"common.name": "Name",
"common.title": "Title",
"common.description": "Description",
"common.slug": "Slug",
"common.url": "URL",
"common.type": "Type",
"common.hostname": "Hostname",
"common.port": "Port",
"common.optional": "optional",
"common.enabled": "Enabled",
"common.refresh": "Refresh",
"common.never": "never",
"common.all": "All",
"common.reset": "Reset",
"common.close": "Close",
"auth.setupTitle": "Create your admin account",
"auth.setupHint": "First user becomes the admin. Local account only — no external identity.",
"auth.signInTitle": "Sign in",
"auth.signInHint": "This Dash instance requires authentication.",
"auth.username": "Username or email",
"auth.password": "Password",
"auth.createAccount": "Create account",
"auth.signIn": "Sign in",
"auth.invalid": "Invalid username or password.",
"auth.rateLimited": "Too many attempts — wait a minute.",
"auth.section": "Authentication",
"auth.offHint": "Anyone who can reach this instance has full access. Enable to require sign-in.",
"auth.enable": "Enable authentication",
"auth.onHint": "Signed in as",
"auth.signOut": "Sign out",
"auth.disable": "Disable authentication",
"auth.disabledNote": "Auth is off — the dashboard is open to your network.",
"boards.title": "Boards",
"boards.manage": "Manage boards…",
"boards.newPlaceholder": "New board name",
"board.empty": "No sections yet.",
"board.emptyHint": "Add a service or a widget to get started.",
"board.addSection": "Add section",
"board.sectionNamePlaceholder": "Section name",
"board.deleteSectionConfirm": "Delete section \"{name}\" and its {count} items?",
"palette.title": "Command palette",
"palette.placeholder": "Search services and actions…",
"palette.noResults": "No matches.",
"palette.pages": "Pages",
"palette.services": "Services",
"palette.actions": "Actions",
"palette.goTo": "Go to {page}",
"palette.openSettings": "Open settings",
"palette.themeSwitch": "Switch to {theme} theme",
"palette.renderer": "Renderer: {name}",
"palette.exportJson": "Export board JSON",
"palette.hintNew": "new",
"palette.hintActive": "active",
"settings.title": "Settings",
"settings.desc": "Board layout, theme, and backup.",
"settings.theme": "Theme",
"settings.language": "Language",
"settings.dark": "Dark",
"settings.light": "Light",
"settings.renderer": "Board renderer",
"settings.appearance": "Appearance",
"settings.accent": "Accent color",
"settings.accentHint": "Any CSS color — hex, hsl, named.",
"settings.wallpaper": "Wallpaper URL",
"settings.wallpaperHint": "Full-bleed background image behind the board.",
"settings.customCss": "Custom CSS",
"settings.customCssHint": "Injected last — wins over theme variables.",
"settings.notifications": "Notifications",
"settings.notifyFanout": "Monitor flips and domain/certificate expiry fan out to every configured transport.",
"settings.exportJson": "Export JSON",
"settings.import": "Import",
"settings.importHint": "Import accepts Dash exports plus Homepage, Homarr, and Dashy configs.",
"settings.saved": "saved",
"settings.sent": "sent",
"settings.failed": "failed",
"settings.testNotify": "Send test notification",
"settings.wallpaperPh": "https://… — blank for none",
"settings.importFailed": "Import failed",
"settings.backup": "Backup & import",
"service.editTitle": "Edit service",
"service.newTitle": "Add service",
"service.desc": "A bookmark on the board — name, icon, one or more URLs.",
"service.newDesc": "Name it, give it an icon, add one or more URLs.",
"service.editDesc": "Update name, icon, or launch URLs.",
"service.icon": "Icon URL",
"service.iconHint": "URL or uploaded file; blank shows a letter tile.",
"service.upload": "Upload",
"service.urls": "URLs",
"service.addUrl": "Add URL",
"service.labelPlaceholder": "label",
"service.removeUrl": "Remove URL",
"service.suggest": "suggest: {slug}.png",
"service.alsoMonitor": "Also create an uptime monitor for the first URL",
"service.nameRequired": "Name is required",
"service.urlRequired": "At least one valid http(s) URL is required",
"service.saveFailed": "save failed",
"service.addService": "Add service",
"widget.editTitle": "Edit widget",
"widget.newTitle": "Add widget",
"widget.desc": "Live data on the board.",
"widget.newDesc": "Pick a type and point it at your service.",
"widget.editDesc": "Update widget settings.",
"widget.type": "Widget type",
"widget.section": "Section",
"widget.pickOne": "Pick one…",
"widget.pickType": "Pick a widget type",
"widget.requiredFields": "Fill in the required fields",
"widget.defaultSection": "Widgets",
"widget.err": "err",
"widget.offline": "offline",
"widget.fetchFailed": "fetch failed",
"widget.queries": "{n} queries",
"widget.blocked": "{n} blocked",
"widget.msAvg": "{n} ms avg",
"widget.videos": "{n} videos",
"widget.blockedLbl": "blocked",
"widget.photosLbl": "photos",
"widget.embedHint": "Set a URL in the widget config.",
"renderer.localTime": "Local time",
"renderer.down": "Down",
"renderer.allGreen": "all green",
"renderer.offline": "offline",
"renderer.needsAttention": "needs attention",
"renderer.downCount": "{n} down",
"renderer.entry": "entry",
"renderer.entries": "entries",
"renderer.dragSection": "Drag section",
"renderer.deleteSection": "Delete section",
"renderer.live": "live",
"monitors.title": "Monitors",
"monitors.subtitle": "HTTP, TCP, ping, DNS, keyword, JSON, and push checks.",
"monitors.new": "New monitor",
"monitors.empty": "No monitors yet.",
"monitors.emptyHint": "Add one to start checking uptime.",
"monitors.deleteConfirm": "Delete monitor \"{name}\" and its history?",
"monitors.checkNow": "Check now",
"monitors.pause": "Pause",
"monitors.resume": "Resume",
"monitors.editTitle": "Edit monitor",
"monitors.newTitle": "New monitor",
"monitors.desc": "Uptime check on a schedule.",
"monitors.method": "Method",
"monitors.keyword": "Keyword",
"monitors.keywordInvert": "Invert (down when found)",
"monitors.jsonPath": "JSON path",
"monitors.expected": "Expected",
"monitors.record": "Record",
"monitors.dnsExpected": "Expected record contains (optional)",
"monitors.pushHint": "After saving, your jobs call the shown push URL on a schedule. If it goes quiet for 2× the interval, the monitor flips down.",
"monitors.interval": "Interval (s)",
"monitors.timeout": "Timeout (s)",
"monitors.retries": "Retries",
"monitors.failsBeforeDown": "Fails before down",
"monitors.latencyWarn": "Latency warn (ms)",
"monitors.mute": "Mute alerts",
"monitors.pushUrl": "Push URL",
"monitors.addMonitor": "Add monitor",
"monitors.nameRequired": "Name is required",
"monitors.urlRequired": "URL is required",
"monitors.hostnameRequired": "Hostname is required",
"monitors.saveFailed": "save failed",
"monitors.uptime24h": "24h uptime",
"monitors.avgLatency": "Avg latency",
"monitors.lastCheck": "Last check",
"monitors.latencyHistory": "Latency",
"monitors.uptimeHistory": "Uptime history",
"monitors.noHeartbeats": "No heartbeats yet.",
"monitors.everyInterval": "every {interval}s",
"monitors.status": "Status",
"monitors.uptime30d": "Uptime 30d",
"monitors.ping": "Ping",
"monitors.every": "Every",
"monitors.checksHint": "HTTP, TCP, ping, DNS, keyword, JSON path, or push — checks run server-side on your interval.",
"monitors.uptime": "Uptime",
"monitors.checks": "Checks",
"monitors.responseTime": "Response time",
"monitors.recentChecks": "Recent checks",
"monitors.noChecks": "No checks in range.",
"monitors.noChecksBucket": "no checks",
"monitors.bucketChecks": "{status} ({n} checks)",
"domains.title": "Domains",
"domains.subtitle": "WHOIS, DNS, and certificate watch.",
"domains.new": "Track domain",
"domains.empty": "No domains tracked yet.",
"domains.emptyHint": "Expiry, registrar, DNS records, SSL certificates, and provider detection.",
"domains.domain": "Domain",
"domains.ssl": "SSL",
"domains.providers": "Providers",
"domains.lookupError": "lookup error",
"domains.refreshNow": "Refresh now",
"domains.deleteConfirm": "Stop tracking \"{name}\"?",
"domains.refresh": "Re-check",
"domains.expiresIn": "expires in {days}d",
"domains.expired": "expired",
"domains.certDays": "cert {days}d",
"domains.registrar": "Registrar",
"domains.expires": "Expires",
"domains.cert": "Certificate",
"domains.nameservers": "Nameservers",
"domains.hosting": "Hosting",
"domains.subdomains": "Subdomains",
"domains.subdomainsHint": "Discovered via Certificate Transparency logs; resolved over DNS.",
"domains.scan": "Scan now",
"domains.scanning": "Scanning…",
"domains.noSubdomains": "No subdomains discovered yet — run a scan.",
"domains.lastSeen": "last seen",
"domains.namePlaceholder": "example.com",
"domains.addTitle": "Track a domain",
"domains.editTitle": "Edit domain",
"domains.addDesc": "WHOIS, DNS, TLS certificate, and host info — refreshed on your interval.",
"domains.refreshEvery": "Refresh every (hours)",
"domains.alertDays": "Alert days before expiry",
"domains.certDaysLbl": "TLS alert threshold (days)",
"domains.certDaysPlaceholder": "same as expiry",
"domains.autoRenewLbl": "Auto-renew enabled at the registrar",
"domains.notesOptional": "Notes (optional)",
"domains.lookingUp": "Looking up…",
"domains.nameRequired": "Domain is required",
"domains.saveFailed": "save failed",
"domains.add": "Track",
"domains.domainExpires": "Domain expires",
"domains.sslExpires": "SSL expires",
"domains.registration": "Registration",
"domains.dns": "DNS",
"domains.tlsCert": "TLS certificate",
"domains.host": "Host",
"domains.checkHistory": "Check history",
"domains.noSnapshots": "No snapshots yet.",
"domains.created": "Created",
"domains.updated": "Updated",
"domains.registrant": "Registrant",
"domains.country": "Country",
"domains.abuse": "Abuse",
"domains.statusLbl": "Status",
"domains.location": "Location",
"domains.isp": "ISP",
"domains.org": "Org",
"domains.checked": "Checked",
"domains.dnsProvider": "DNS provider",
"domains.email": "Email",
"domains.issuer": "Issuer",
"domains.valid": "Valid",
"domains.subject": "Subject",
"domains.key": "Key",
"domains.algorithm": "Algorithm",
"domains.responseHeaders": "Response headers ({n})",
"domains.transferLock": "transfer-locked",
"domains.whoisPrivacy": "whois privacy",
"domains.autoRenew": "auto-renew",
"domains.scanShort": "Scan",
"domains.noSubsFound": "No subdomains discovered — scan queries certificate transparency.",
"domains.expPrefix": "exp",
"domains.sslPrefix": "ssl",
"systems.title": "Systems",
"systems.subtitle": "Servers and PCs reporting via dash-agent.",
"systems.new": "New system",
"systems.empty": "No systems yet.",
"systems.emptyHintPre": "Add one, then run",
"systems.emptyHintPost": "on the host with the issued token.",
"systems.waiting": "waiting for agent…",
"systems.cores": "{n} cores",
"systems.newTitle": "New system",
"systems.newDesc": "Name the host, then run dash-agent on it with the issued token.",
"systems.agentCmdFor": "Agent command for",
"systems.tokenWarning": "The token identifies this system — keep it out of public repos.",
"systems.done": "Done",
"systems.copy": "Copy",
"systems.deleteConfirm": "Delete system \"{name}\"?",
"systems.agentHint": "Run the agent with this token; stats land here.",
"systems.token": "Bearer token",
"systems.containers": "Containers",
"systems.drives": "Drives",
"systems.zfs": "ZFS pools",
"systems.gpu": "GPU",
"systems.cpu": "CPU",
"systems.mem": "Memory",
"systems.disk": "Disk",
"systems.net": "Network",
"systems.temp": "Temp",
"systems.load": "Load",
"systems.up": "up",
"systems.down": "down",
"systems.namePlaceholder": "nas",
"systems.addTitle": "Add a system",
"systems.addDesc": "Creates a bearer token — run dash-agent with it.",
"systems.notFound": "System not found.",
"systems.usage": "Usage",
"systems.chartCpu": "CPU Usage",
"systems.chartCpuHint": "Average system-wide CPU utilization",
"systems.chartMem": "Memory Usage",
"systems.chartMemHint": "Precise utilization at the recorded time",
"systems.chartUsed": "used",
"systems.chartDisk": "Disk Usage",
"systems.chartDiskHint": "Usage of the root partition",
"systems.chartNet": "Bandwidth",
"systems.chartNetHint": "Network traffic of public interfaces",
"systems.chartRx": "received",
"systems.chartTx": "sent",
"systems.chartLoad": "Load Average",
"systems.chartLoadHint": "System load over 1, 5, and 15 minutes",
"systems.chartTemp": "Temperature",
"systems.chartTempHint": "System sensors",
"systems.chartDockerCpu": "Docker CPU Usage",
"systems.chartDockerCpuHint": "Per-container CPU, percent of one core",
"systems.chartDockerMem": "Docker Memory",
"systems.chartDockerMemHint": "Per-container memory usage",
"systems.chartGpu": "GPU Usage",
"systems.chartGpuHint": "Per-GPU utilization",
"systems.chartVram": "GPU VRAM",
"systems.chartVramHint": "Per-GPU memory usage",
"systems.temperatures": "Temperatures",
"systems.drivesSmart": "Drives (SMART)",
"systems.agentCmd": "Agent command",
"systems.neverSeen": "never seen",
"systems.seenAt": "seen {time}",
"systems.failing": "failing",
"systems.uptime": "Uptime",
"chart.noData": "No data yet.",
"incidents.title": "Incidents",
"incidents.subtitle": "Auto-opened on monitor outages, or tracked manually.",
"incidents.new": "New incident",
"incidents.empty": "No open incidents.",
"incidents.resolve": "Resolve",
"incidents.acknowledge": "Acknowledge",
"incidents.close": "Close",
"incidents.reopen": "Reopen",
"incidents.deleteConfirm": "Delete incident \"{title}\"?",
"incidents.openOnly": "Open only",
"incidents.showAll": "Show all",
"incidents.noUpdates": "No updates yet.",
"incidents.notePlaceholder": "Add a note…",
"incidents.post": "Post",
"incidents.open": "open",
"incidents.resolved": "resolved",
"incidents.editTitle": "Edit incident",
"incidents.newTitle": "New incident",
"incidents.desc": "Track an outage or degradation manually.",
"incidents.severity": "Severity",
"incidents.sevMinor": "minor",
"incidents.sevMajor": "major",
"incidents.sevCritical": "critical",
"incidents.status": "Status",
"incidents.monitor": "Monitor",
"incidents.bodyLabel": "Details",
"incidents.noteOptional": "Note (optional)",
"incidents.titlePlaceholder": "NAS offline",
"incidents.noteFirst": "First observed…",
"incidents.createError": "Could not create incident.",
"statusPages.title": "Status pages",
"statusPages.publicAt": "Public live views at",
"statusPages.new": "New page",
"statusPages.empty": "No status pages yet.",
"statusPages.open": "Open public page",
"statusPages.deleteConfirm": "Delete status page \"{title}\"?",
"statusPages.createError": "Could not create — slug may be taken.",
"statusPages.newTitle": "New status page",
"statusPages.newDesc": "Public live view over selected monitors.",
"statusPages.monitors": "Monitors",
"statusPages.services": "Services",
"statusPages.noMonitors": "No monitors yet — empty selection shows all.",
"statusPages.allHint": "Leave all unchecked to include every active monitor.",
"statusPages.slugPlaceholder": "auto",
"statusPages.titlePlaceholder": "Homelab status",
"maintenance.title": "Maintenance windows",
"maintenance.subtitle": "Active windows mute alerts + auto-incidents and mark covered monitors.",
"maintenance.schedule": "Schedule",
"maintenance.empty": "No maintenance scheduled.",
"maintenance.deleteConfirm": "Delete window \"{title}\"?",
"maintenance.active": "active",
"maintenance.scheduled": "scheduled",
"maintenance.dialogTitle": "Schedule maintenance",
"maintenance.dialogDesc": "Suppresses alerts and auto-incidents for the window.",
"maintenance.starts": "Starts",
"maintenance.ends": "Ends",
"maintenance.titlePlaceholder": "Network upgrade",
"maintenance.createError": "Invalid window — check times.",
"statusPublic.operational": "All systems operational",
"statusPublic.degraded": "Partial outage",
"statusPublic.outage": "Major outage",
"statusPublic.underMaintenance": "Under maintenance",
"statusPublic.poweredBy": "Powered by Dash",
"statusPublic.updated": "Updated {time}",
"statusPublic.noMonitors": "No monitors on this page yet.",
"statusPublic.uptime90": "90-day uptime",
"statusPublic.services": "Services",
"statusPublic.systems": "Systems",
"statusPublic.maintenance": "Maintenance",
"statusPublic.incidents": "Incidents",
"statusPublic.checking": "Checking…",
"statusPublic.notFound": "Status page not found.",
"statusPublic.footer": "powered by dash",
} as const;
export type Key = keyof typeof en;
+21 -7
View File
@@ -1,13 +1,27 @@
import { en, type Key } from "@/i18n/en";
import { cs } from "@/i18n/cs";
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 const LOCALES = { en: "English", cs: "Čeština" } as const;
export type Locale = keyof typeof LOCALES;
export function t(key: Key): string {
return dict[key] ?? key;
// i18n seam: dictionaries are plain objects keyed by dotted names. `t` is a
// typed lookup that falls back to English, then to the key itself — a missing
// translation is loud in dev, never a crash. The store calls setLocale when
// the `locale` setting loads/changes; components re-render on prefs updates.
const dicts: Record<Locale, Partial<Record<Key, string>>> = { en, cs };
let active: Locale = "en";
export function setLocale(l: string) {
active = l === "cs" ? "cs" : "en";
document.documentElement.lang = active;
}
export function t(key: Key, params?: Record<string, string | number>): string {
let s: string = dicts[active][key] ?? en[key] ?? key;
if (params) {
for (const [k, v] of Object.entries(params)) s = s.replaceAll(`{${k}}`, String(v));
}
return s;
}
+20
View File
@@ -25,6 +25,16 @@
--icon-bg: #1c1c1e;
--popover: #141414;
/* chart series palette (monitoring graphs) */
--chart-1: #3b82f6;
--chart-2: #10b981;
--chart-3: #8b5cf6;
--chart-4: #f59e0b;
--chart-5: #22d3ee;
--chart-6: #fb7185;
--chart-7: #a3e635;
--chart-8: #c084fc;
--background: var(--bg);
--foreground: var(--text);
--card: var(--surface);
@@ -57,6 +67,16 @@
--icon-bg: #f4f4f5;
--popover: #ffffff;
/* chart series palette (monitoring graphs) — darker for light bg */
--chart-1: #2563eb;
--chart-2: #059669;
--chart-3: #7c3aed;
--chart-4: #d97706;
--chart-5: #0891b2;
--chart-6: #e11d48;
--chart-7: #65a30d;
--chart-8: #9333ea;
--background: var(--bg);
--foreground: var(--text);
--card: var(--surface);
+6
View File
@@ -16,3 +16,9 @@ createRoot(root).render(
<App />
</StrictMode>,
);
// PWA: register the service worker only in production builds — in dev it would
// fight Vite's HMR by caching modules.
if (import.meta.env.PROD && "serviceWorker" in navigator) {
window.addEventListener("load", () => void navigator.serviceWorker.register("/sw.js"));
}
+165 -57
View File
@@ -1,11 +1,12 @@
import { useCallback, useEffect, useState } from "react";
import { Link } from "wouter";
import { ArrowLeft, Pause, Play, RefreshCw, Shield, Trash2 } from "lucide-react";
import { ArrowLeft, Globe, Pause, Play, RefreshCw, Shield, Trash2 } from "lucide-react";
import { api } from "@/api";
import type { DomainCheck, DomainView } from "@/types";
import type { DNSRecord, DomainCheck, DomainView, Provider, Subdomain } from "@/types";
import { DomainDialog } from "@/components/DomainDialog";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { t } from "@/i18n";
function Row({ k, v, mono }: { k: string; v: React.ReactNode; mono?: boolean }) {
if (v === undefined || v === null || v === "") return null;
@@ -31,31 +32,90 @@ function fmtDate(iso: string | null | undefined) {
return new Date(iso).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
}
// Vendor chip shown next to records and inside the provider strip.
function ProviderBadge({ p }: { p: Provider | null | undefined }) {
if (!p?.name) return null;
return (
<span className="inline-flex shrink-0 items-center gap-1.5 rounded-full border border-border/60 bg-bg px-2 py-0.5">
{p.icon
? <img src={p.icon} alt="" className="size-3 rounded-[3px]" loading="lazy" />
: <Globe size={11} className="text-text-faint" />}
<span className="whitespace-nowrap text-[10.5px] text-text-dim">{p.name}</span>
</span>
);
}
function ProvTile({ label, p }: { label: string; p: Provider | undefined }) {
return (
<div className="flex min-w-0 items-center gap-2.5 rounded-[9px] border border-border/60 bg-bg px-3 py-2.5">
{p?.icon
? <img src={p.icon} alt="" className="size-5 rounded-[5px]" loading="lazy" />
: <Globe size={17} className="shrink-0 text-text-faint" />}
<div className="min-w-0">
<div className="font-mono text-[9px] uppercase tracking-[0.1em] text-text-faint">{label}</div>
<div className="truncate text-[12px] font-medium text-text">{p?.name || "—"}</div>
</div>
</div>
);
}
const recTypeColor = {
A: "text-[var(--chart-1)]",
AAAA: "text-[var(--chart-1)]",
CNAME: "text-[var(--chart-4)]",
NS: "text-[var(--chart-2)]",
MX: "text-[var(--chart-3)]",
TXT: "text-[var(--chart-5)]",
} satisfies Record<NonNullable<DNSRecord["type"]>, string>;
const recOrder = ["A", "AAAA", "CNAME", "NS", "MX", "TXT"];
function RecordRow({ r }: { r: DNSRecord }) {
return (
<div className="flex items-center gap-3 border-b border-border/50 px-4 py-2 last:border-0">
<span className={cn("w-11 shrink-0 font-mono text-[9.5px] font-semibold uppercase tracking-[0.1em]",
(r.type ? recTypeColor[r.type] : undefined) ?? "text-text-faint")}>
{r.type}
</span>
<span className="min-w-0 flex-1 truncate font-mono text-[11px] text-text" title={r.value ?? ""}>
{r.value}
{r.type === "MX" && r.priority !== null && r.priority !== undefined
? <span className="text-text-faint"> (pri {r.priority})</span>
: null}
</span>
<ProviderBadge p={r.provider} />
</div>
);
}
export function DomainDetailPage({ id }: { id: string }) {
const [d, setD] = useState<DomainView | null>(null);
const [checks, setChecks] = useState<DomainCheck[]>([]);
const [subs, setSubs] = useState<Subdomain[]>([]);
const [dlgOpen, setDlgOpen] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const [subScanning, setSubScanning] = useState(false);
const load = useCallback(async () => {
const [{ data: dv }, { data: ck }] = await Promise.all([
const [{ data: dv }, { data: ck }, { data: sb }] = await Promise.all([
api.GET("/api/domains/{id}", { params: { path: { id } } }),
api.GET("/api/domains/{id}/checks", { params: { path: { id } } }),
api.GET("/api/domains/{id}/subdomains", { params: { path: { id } } }),
]);
if (dv) setD(dv);
if (ck) setChecks(ck);
if (sb) setSubs(sb);
}, [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>;
if (!d) return <main className="mx-auto max-w-5xl px-7 py-8 text-[13px] text-text-faint">{t("common.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
<ArrowLeft size={12} /> {t("nav.domains")}
</Link>
<div className="mb-6 flex items-start justify-between">
@@ -66,9 +126,9 @@ export function DomainDetailPage({ id }: { id: string }) {
<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}
{d.transferLock ? <span>{t("domains.transferLock")}</span> : null}
{d.privacyEnabled ? <span>{t("domains.whoisPrivacy")}</span> : null}
{d.autoRenew ? <span>{t("domains.autoRenew")}</span> : null}
</p>
</div>
</div>
@@ -79,16 +139,16 @@ export function DomainDetailPage({ id }: { id: string }) {
void api.POST("/api/domains/{id}/refresh", { params: { path: { id } } })
.then(load).finally(() => setRefreshing(false));
}}>
<RefreshCw size={12} className={cn(refreshing && "animate-spin")} /> Refresh
<RefreshCw size={12} className={cn(refreshing && "animate-spin")} /> {t("common.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"}
{d.active ? <Pause size={12} /> : <Play size={12} />} {d.active ? t("monitors.pause") : t("monitors.resume")}
</Button>
<Button variant="outline" size="sm" onClick={() => setDlgOpen(true)}>Edit</Button>
<Button variant="outline" size="sm" aria-label="Delete"
<Button variant="outline" size="sm" onClick={() => setDlgOpen(true)}>{t("common.edit")}</Button>
<Button variant="outline" size="sm" aria-label={t("common.delete")}
onClick={() => {
if (confirm(`Stop tracking "${d.name}"?`)) {
if (confirm(t("domains.deleteConfirm", { name: d.name ?? "" }))) {
void api.DELETE("/api/domains/{id}", { params: { path: { id } } }).then(() => { location.href = "/domains"; });
}
}}>
@@ -105,86 +165,97 @@ export function DomainDetailPage({ id }: { id: string }) {
<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="font-mono text-[10px] uppercase tracking-[0.1em] text-text-faint">{t("domains.domainExpires")}</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`)
? (d.daysUntilExpiry < 0 ? t("domains.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="font-mono text-[10px] uppercase tracking-[0.1em] text-text-faint">{t("domains.sslExpires")}</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`)
? (d.sslDaysUntilExpiry < 0 ? t("domains.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="font-mono text-[10px] uppercase tracking-[0.1em] text-text-faint">{t("domains.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 className="font-mono text-[10px] uppercase tracking-[0.1em] text-text-faint">{t("domains.checked")}</div>
<div className="mt-1 truncate text-[13px] font-medium">
{d.lastChecked ? new Date(d.lastChecked).toLocaleDateString() : "—"}
</div>
<div className="font-mono text-[10px] text-text-faint">every {d.intervalH}h</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} />
{/* Vendor strip — registrar, DNS, hosting, email, CA */}
<Section title={t("domains.providers")}>
<div className="grid grid-cols-5 gap-2 p-2.5 max-[900px]:grid-cols-3 max-[560px]:grid-cols-2">
<ProvTile label={t("domains.registrar")} p={d.providers?.registrar} />
<ProvTile label={t("domains.dns")} p={d.providers?.dns} />
<ProvTile label={t("domains.hosting")} p={d.providers?.hosting} />
<ProvTile label={t("domains.email")} p={d.providers?.email} />
<ProvTile label={t("domains.tlsCert")} p={d.providers?.ca} />
</div>
</Section>
<div className="mt-3 grid grid-cols-2 gap-3 max-[820px]:grid-cols-1">
<Section title={t("domains.registration")}>
<Row k={t("domains.created")} v={fmtDate(d.creationDate)} />
<Row k={t("domains.updated")} v={fmtDate(d.updatedDate)} />
<Row k={t("domains.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}
<Row k={t("domains.registrant")} v={[d.registrantName, d.registrantOrg].filter(Boolean).join(" / ")} />
<Row k={t("domains.country")} v={d.registrantCountry} />
<Row k={t("domains.abuse")} v={d.abuseEmail} mono />
{d.statuses && d.statuses.length > 0 ? <Row k={t("domains.statusLbl")} 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 title={t("domains.dns")}>
{[...(d.records ?? [])]
.sort((a, b) => recOrder.indexOf(a.type ?? "") - recOrder.indexOf(b.type ?? ""))
.map((r, i) => <RecordRow key={i} r={r} />)}
{(d.records ?? []).length === 0 ? (
<p className="px-4 py-4 text-center text-[12px] text-text-faint">—</p>
) : 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 />
<Section title={t("domains.tlsCert")}>
<Row k={t("domains.issuer")} v={d.sslIssuer} />
<Row k="CA" v={d.providers?.ca ? <ProviderBadge p={d.providers.ca} /> : d.caProvider} />
<Row k={t("domains.valid")} v={`${fmtDate(d.sslValidFrom)} → ${fmtDate(d.sslValidTo)}`} mono />
<Row k={t("domains.subject")} v={d.sslSubject} mono />
<Row k={t("domains.key")} v={d.sslKeySize ? `${d.sslKeySize} bit` : ""} mono />
<Row k={t("domains.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} />
<Section title={t("domains.host")}>
<Row k={t("domains.location")} v={[d.hostCity, d.hostRegion, d.hostCountry].filter(Boolean).join(", ")} />
<Row k={t("domains.isp")} v={d.hostIsp} />
<Row k={t("domains.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 />
<Row k={t("domains.hosting")} v={d.providers?.hosting ? <ProviderBadge p={d.providers.hosting} /> : d.hostingProvider} />
<Row k={t("domains.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})
{t("domains.responseHeaders", { n: Object.keys(d.headers ?? {}).length })}
</summary>
<div className="border-t border-border px-4 py-2">
{Object.entries(d.headers ?? {}).map(([k, v]) => (
@@ -197,20 +268,57 @@ export function DomainDetailPage({ id }: { id: string }) {
</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="mb-2 mt-6 flex items-center justify-between">
<h2 className="font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">
{t("domains.subdomains")}{subs.length > 0 ? ` (${subs.length})` : ""}
</h2>
<Button variant="ghost" size="sm" disabled={subScanning}
onClick={() => {
setSubScanning(true);
void api.POST("/api/domains/{id}/subdomains/refresh", { params: { path: { id } } })
.then(({ data }) => { if (data) setSubs(data); })
.finally(() => setSubScanning(false));
}}>
<RefreshCw size={11} className={cn(subScanning && "animate-spin")} /> {t("domains.scanShort")}
</Button>
</div>
<div className="overflow-hidden rounded-[10px] border border-border">
<table className="w-full text-[12px]">
<tbody>
{subs.map((s) => (
<tr key={s.name} className="border-b border-border/60 last:border-0">
<td className="px-4 py-2 font-mono text-[11px]">{s.name}</td>
<td className="px-4 py-2 font-mono text-[11px] text-text-dim">
{(s.ips ?? []).join(", ") || "—"}
</td>
<td className="px-4 py-2 text-right font-mono text-[10.5px] text-text-faint">
{fmtDate(s.lastSeen)}
</td>
</tr>
))}
{subs.length === 0 && (
<tr><td className="px-4 py-6 text-center text-text-faint">
{t("domains.noSubsFound")}
</td></tr>
)}
</tbody>
</table>
</div>
<h2 className="mb-2 mt-6 font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">{t("domains.checkHistory")}</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-dim">{t("domains.expPrefix")} {fmtDate(c.expiryDate)}</td>
<td className="px-4 py-2 font-mono text-[11px] text-text-dim">{t("domains.sslPrefix")} {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>
<tr><td className="px-4 py-6 text-center text-text-faint">{t("domains.noSnapshots")}</td></tr>
)}
</tbody>
</table>
+18 -18
View File
@@ -6,11 +6,12 @@ import type { DomainView } from "@/types";
import { DomainDialog } from "@/components/DomainDialog";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { t } from "@/i18n";
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>;
return <span className={cn("font-mono text-[11px]", cls)}>{days < 0 ? t("domains.expired") : `${days}d`}</span>;
}
export function DomainsPage() {
@@ -38,19 +39,19 @@ export function DomainsPage() {
<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>
<h1 className="text-[15px] font-semibold tracking-tight">{t("domains.title")}</h1>
<p className="text-[12px] text-text-faint">{t("domains.subtitle")}</p>
</div>
<Button onClick={() => { setEditing(undefined); setDlgOpen(true); }}>
<Plus size={13} strokeWidth={2.2} /> Track domain
<Plus size={13} strokeWidth={2.2} /> {t("domains.new")}
</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="text-[13px] text-text-dim">{t("domains.empty")}</p>
<p className="mt-1 text-[11.5px] text-text-faint">
Expiry, registrar, DNS records, SSL certificates, and provider detection.
{t("domains.emptyHint")}
</p>
</div>
) : (
@@ -58,11 +59,11 @@ export function DomainsPage() {
<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 font-medium">{t("domains.domain")}</th>
<th className="px-4 py-2.5 font-medium">{t("domains.registrar")}</th>
<th className="px-4 py-2.5 font-medium">{t("domains.expires")}</th>
<th className="px-4 py-2.5 font-medium">{t("domains.ssl")}</th>
<th className="px-4 py-2.5 font-medium">{t("domains.providers")}</th>
<th className="px-4 py-2.5" />
</tr>
</thead>
@@ -76,7 +77,7 @@ export function DomainsPage() {
) : 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>
<span className="font-mono text-[10px] text-down" title={d.lookupError}>{t("domains.lookupError")}</span>
) : null}
</span>
</td>
@@ -88,7 +89,7 @@ export function DomainsPage() {
</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}
<Button variant="outline" size="icon" aria-label={t("domains.refreshNow")} disabled={refreshing === d.id}
onClick={() => {
if (!d.id) return;
setRefreshing(d.id);
@@ -97,20 +98,19 @@ export function DomainsPage() {
}}>
<RefreshCw size={12} className={cn(refreshing === d.id && "animate-spin")} />
</Button>
<Button variant="outline" size="icon" aria-label={d.active ? "Pause" : "Resume"}
<Button variant="outline" size="icon" aria-label={d.active ? t("monitors.pause") : t("monitors.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"
<Button variant="outline" size="icon" aria-label={t("common.delete")}
onClick={() => {
if (confirm(`Stop tracking "${d.name}"?`)) {
if (confirm(t("domains.deleteConfirm", { name: 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 variant="outline" size="sm" onClick={() => { setEditing(d); setDlgOpen(true); }}>{t("common.edit")}
</Button>
</div>
</td>
+26 -25
View File
@@ -10,6 +10,7 @@ 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";
import { t } from "@/i18n";
const SEV_COLOR = {
critical: "text-down",
@@ -19,9 +20,9 @@ const SEV_COLOR = {
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" }],
open: [{ label: t("incidents.acknowledge"), to: "ack" }, { label: t("incidents.resolve"), to: "resolved" }],
ack: [{ label: t("incidents.resolve"), to: "resolved" }],
resolved: [{ label: t("incidents.close"), to: "closed" }],
};
export function IncidentsPage() {
@@ -83,22 +84,22 @@ export function IncidentsPage() {
<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>
<h1 className="text-[15px] font-semibold tracking-tight">{t("incidents.title")}</h1>
<p className="text-[12px] text-text-faint">{t("incidents.subtitle")}</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => setShowAll((v) => !v)}>
{showAll ? "Open only" : "Show all"}
{showAll ? t("incidents.openOnly") : t("incidents.showAll")}
</Button>
<Button onClick={() => setDlgOpen(true)}>
<Plus size={13} strokeWidth={2.2} /> New incident
<Plus size={13} strokeWidth={2.2} /> {t("incidents.new")}
</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>
<p className="text-[13px] text-text-dim">{t("incidents.empty")}</p>
</div>
) : (
<div className="flex flex-col gap-2.5">
@@ -122,9 +123,9 @@ export function IncidentsPage() {
{n.label}
</Button>
))}
<Button variant="outline" size="icon" aria-label="Delete"
<Button variant="outline" size="icon" aria-label={t("common.delete")}
onClick={() => {
if (inc.id && confirm(`Delete incident "${inc.title}"?`)) {
if (inc.id && confirm(t("incidents.deleteConfirm", { title: inc.title ?? "" }))) {
void api.DELETE("/api/incidents/{id}", { params: { path: { id: inc.id } } }).then(load);
}
}}>
@@ -144,14 +145,14 @@ export function IncidentsPage() {
</div>
))}
{(inc.updates ?? []).length === 0 && (
<p className="text-[11.5px] text-text-faint">No updates yet.</p>
<p className="text-[11.5px] text-text-faint">{t("incidents.noUpdates")}</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]"
placeholder={t("incidents.notePlaceholder")} 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>
<Button variant="outline" size="sm" onClick={() => void addNote(inc.id)}>{t("incidents.post")}</Button>
</div>
</div>
)}
@@ -163,37 +164,37 @@ export function IncidentsPage() {
<Dialog open={dlgOpen} onOpenChange={setDlgOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>New incident</DialogTitle>
<DialogDescription>Track an outage or degradation manually.</DialogDescription>
<DialogTitle>{t("incidents.newTitle")}</DialogTitle>
<DialogDescription>{t("incidents.desc")}</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<Label htmlFor="inc-title">Title</Label>
<Label htmlFor="inc-title">{t("common.title")}</Label>
<Input id="inc-title" value={title} onChange={(e) => setTitle(e.target.value)}
placeholder="NAS offline" autoFocus />
placeholder={t("incidents.titlePlaceholder")} autoFocus />
</div>
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5">
<Label>Severity</Label>
<Label>{t("incidents.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>
<SelectItem value="minor">{t("incidents.sevMinor")}</SelectItem>
<SelectItem value="major">{t("incidents.sevMajor")}</SelectItem>
<SelectItem value="critical">{t("incidents.sevCritical")}</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…" />
<Label htmlFor="inc-msg">{t("incidents.noteOptional")}</Label>
<Input id="inc-msg" value={msg} onChange={(e) => setMsg(e.target.value)} placeholder={t("incidents.noteFirst")} />
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDlgOpen(false)} disabled={busy}>Cancel</Button>
<Button variant="outline" onClick={() => setDlgOpen(false)} disabled={busy}>{t("common.cancel")}</Button>
<Button onClick={() => void create()} disabled={busy || !title.trim()}>
{busy ? "Creating…" : "Create"}
{busy ? t("common.creating") : t("common.create")}
</Button>
</DialogFooter>
</DialogContent>
+72
View File
@@ -0,0 +1,72 @@
import { useState, type FormEvent } from "react";
import { api } from "@/api";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { t } from "@/i18n";
/** Login/setup gate shown when auth is enabled and no session exists. */
export function LoginPage({ setup, onDone }: { setup: boolean; onDone: () => void }) {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [err, setErr] = useState("");
const [busy, setBusy] = useState(false);
async function submit(e: FormEvent) {
e.preventDefault();
setErr("");
setBusy(true);
const body = { username: username.trim(), password };
const { error, response } = setup
? await api.POST("/api/auth/setup", { body })
: await api.POST("/api/auth/login", { body });
setBusy(false);
if (error) {
setErr(response.status === 429 ? t("auth.rateLimited") : t("auth.invalid"));
return;
}
onDone();
}
return (
<div className="flex min-h-screen items-center justify-center bg-bg p-6">
<form
onSubmit={(e) => void submit(e)}
className="w-full max-w-[320px] rounded-xl border border-border bg-surface p-6 shadow-lg"
>
<div className="mb-5 flex items-center gap-2.5 text-[15px] font-semibold tracking-tight">
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden>
<rect x="1" y="1" width="7" height="7" rx="1.5" fill="currentColor" />
<rect x="10" y="1" width="7" height="7" rx="1.5" fill="currentColor" opacity=".45" />
<rect x="1" y="10" width="7" height="7" rx="1.5" fill="currentColor" opacity=".45" />
<rect x="10" y="10" width="7" height="7" rx="1.5" fill="currentColor" />
</svg>
Dash
</div>
<h1 className="mb-1 text-[14px] font-medium">{setup ? t("auth.setupTitle") : t("auth.signInTitle")}</h1>
<p className="mb-4 text-[12px] text-text-faint">{setup ? t("auth.setupHint") : t("auth.signInHint")}</p>
<div className="grid gap-3">
<div className="grid gap-1.5">
<Label htmlFor="auth-user">{t("auth.username")}</Label>
<Input
id="auth-user" autoComplete="username" autoFocus
value={username} onChange={(e) => setUsername(e.target.value)}
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="auth-pass">{t("auth.password")}</Label>
<Input
id="auth-pass" type="password"
autoComplete={setup ? "new-password" : "current-password"}
value={password} onChange={(e) => setPassword(e.target.value)}
/>
</div>
{err && <p className="text-[12px] text-danger">{err}</p>}
<Button type="submit" disabled={busy || !username.trim() || password.length < 8} className="mt-1">
{setup ? t("auth.createAccount") : t("auth.signIn")}
</Button>
</div>
</form>
</div>
);
}
+76 -33
View File
@@ -1,12 +1,13 @@
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 { ChartCard, MetricChart, chartColor, type ChartRow } from "@/components/chart";
import type { Heartbeat, MonitorView } from "@/types";
import { MonitorDialog } from "@/components/MonitorDialog";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { t } from "@/i18n";
export function MonitorDetailPage({ id }: { id: string }) {
const [m, setM] = useState<MonitorView | null>(null);
@@ -29,18 +30,17 @@ export function MonitorDetailPage({ id }: { id: string }) {
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>;
if (!m) return <main className="mx-auto max-w-5xl px-7 py-8 text-[13px] text-text-faint">{t("common.loading")}</main>;
const chart = hbs.map((h) => ({
t: new Date(h.checkedAt ?? "").toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }),
const rows: ChartRow[] = hbs.map((h) => ({
t: new Date(h.checkedAt ?? "").getTime(),
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
<ArrowLeft size={12} /> {t("nav.monitors")}
</Link>
<div className="mb-6 flex items-start justify-between">
@@ -51,21 +51,21 @@ export function MonitorDetailPage({ id }: { id: string }) {
<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
{m.type} · {m.url || m.hostname}{m.port ? `:${m.port}` : ""} · {t("monitors.everyInterval", { interval: m.intervalS ?? 0 })}
</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
<RefreshCw size={12} /> {t("monitors.checkNow")}
</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"}
{m.active ? <Pause size={12} /> : <Play size={12} />} {m.active ? t("monitors.pause") : t("monitors.resume")}
</Button>
<Button variant="outline" size="sm" onClick={() => setDlgOpen(true)}>Edit</Button>
<Button variant="outline" size="sm" aria-label="Delete"
<Button variant="outline" size="sm" onClick={() => setDlgOpen(true)}>{t("common.edit")}</Button>
<Button variant="outline" size="sm" aria-label={t("common.delete")}
onClick={() => {
if (confirm(`Delete monitor "${m.name}"?`)) {
if (confirm(t("monitors.deleteConfirm", { name: m.name ?? "" }))) {
void api.DELETE("/api/monitors/{id}", { params: { path: { id } } }).then(() => { location.href = "/monitors"; });
}
}}>
@@ -76,10 +76,10 @@ export function MonitorDetailPage({ id }: { id: string }) {
<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))],
[t("monitors.uptime24h"), `${m.stats?.uptime24h?.toFixed(2)}%`],
[t("monitors.uptime30d"), `${m.stats?.uptime30d?.toFixed(2)}%`],
[t("monitors.avgLatency"), m.stats?.avgPing24h ? `${m.stats.avgPing24h} ms` : "—"],
[t("monitors.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>
@@ -88,8 +88,17 @@ export function MonitorDetailPage({ id }: { id: string }) {
))}
</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>
<h2 className="mb-2 font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">{t("monitors.uptime")}</h2>
<div className="mb-6 rounded-[10px] border border-border bg-surface px-3 py-2.5">
<UptimeBar hbs={hbs} hours={hours} />
</div>
<div className="mb-6">
<ChartCard
title={t("monitors.responseTime")}
height={208}
series={[{ key: "ping", label: "ms", color: chartColor(0) }]}
actions={
<div className="flex gap-1">
{[24, 168, 720].map((h) => (
<button key={h} type="button" onClick={() => setHours(h)}
@@ -99,23 +108,18 @@ export function MonitorDetailPage({ id }: { id: string }) {
</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)" }}
}
>
<MetricChart
rows={rows}
series={[{ key: "ping", label: "ms", color: chartColor(0) }]}
fmt={(v) => `${Math.round(v)} ms`}
spanMs={hours * 3600_000}
/>
<Area type="monotone" dataKey="ping" stroke="var(--up)" fill="var(--up)" fillOpacity={0.12} strokeWidth={1.5} dot={false} isAnimationActive={false} />
</AreaChart>
</ResponsiveContainer>
</ChartCard>
</div>
<h2 className="mb-2 font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">Recent checks</h2>
<h2 className="mb-2 font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">{t("monitors.recentChecks")}</h2>
<div className="overflow-hidden rounded-[10px] border border-border">
<table className="w-full text-[12px]">
<tbody>
@@ -134,7 +138,7 @@ export function MonitorDetailPage({ id }: { id: string }) {
</tr>
))}
{hbs.length === 0 && (
<tr><td className="px-4 py-6 text-center text-text-faint">No checks in range.</td></tr>
<tr><td className="px-4 py-6 text-center text-text-faint">{t("monitors.noChecks")}</td></tr>
)}
</tbody>
</table>
@@ -144,3 +148,42 @@ export function MonitorDetailPage({ id }: { id: string }) {
</main>
);
}
/** UptimeBar renders the check history as a strip of status-colored bars —
* the window is split into fixed buckets; a bucket is "down" if any check in
* it failed, so outages stay visible at long ranges. */
function UptimeBar({ hbs, hours }: { hbs: Heartbeat[]; hours: number }) {
const BUCKETS = 90;
const now = Date.now();
const span = hours * 3600_000;
const start = now - span;
const buckets: { status: "up" | "down" | "empty"; from: number; to: number; n: number }[] = [];
for (let i = 0; i < BUCKETS; i++) {
buckets.push({ status: "empty", from: start + (i * span) / BUCKETS, to: start + ((i + 1) * span) / BUCKETS, n: 0 });
}
for (const h of hbs) {
const t = new Date(h.checkedAt ?? "").getTime();
if (Number.isNaN(t) || t < start) continue;
const i = Math.min(BUCKETS - 1, Math.floor(((t - start) / span) * BUCKETS));
const b = buckets[i];
if (!b) continue;
b.n++;
if (h.status === "down") b.status = "down";
else if (b.status === "empty") b.status = "up";
}
const fmt = (t: number) => new Date(t).toLocaleString([], { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" });
return (
<div className="flex h-7 items-stretch gap-[2px]">
{buckets.map((b, i) => (
<div
key={i}
title={`${fmt(b.from)} – ${fmt(b.to)} · ${b.n === 0 ? t("monitors.noChecksBucket") : t("monitors.bucketChecks", { status: b.status, n: b.n })}`}
className={cn(
"flex-1 rounded-[2px]",
b.status === "down" ? "bg-down" : b.status === "up" ? "bg-up/80 hover:bg-up" : "bg-border/50",
)}
/>
))}
</div>
);
}
+18 -17
View File
@@ -6,6 +6,7 @@ import type { MonitorView } from "@/types";
import { MonitorDialog } from "@/components/MonitorDialog";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { t } from "@/i18n";
function StatusDot({ status }: { status?: string }) {
return (
@@ -46,19 +47,19 @@ export function MonitorsPage() {
<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>
<h1 className="text-[15px] font-semibold tracking-tight">{t("monitors.title")}</h1>
<p className="text-[12px] text-text-faint">{t("monitors.desc")}</p>
</div>
<Button onClick={() => { setEditing(undefined); setDlgOpen(true); }}>
<Plus size={13} strokeWidth={2.2} /> New monitor
<Plus size={13} strokeWidth={2.2} /> {t("monitors.new")}
</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="text-[13px] text-text-dim">{t("monitors.empty")}</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.
{t("monitors.checksHint")}
</p>
</div>
) : (
@@ -66,13 +67,13 @@ export function MonitorsPage() {
<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 font-medium">{t("monitors.status")}</th>
<th className="px-4 py-2.5 font-medium">{t("common.name")}</th>
<th className="px-4 py-2.5 font-medium">{t("common.type")}</th>
<th className="px-4 py-2.5 font-medium">{t("monitors.uptime24h")}</th>
<th className="px-4 py-2.5 font-medium">{t("monitors.uptime30d")}</th>
<th className="px-4 py-2.5 font-medium">{t("monitors.ping")}</th>
<th className="px-4 py-2.5 font-medium">{t("monitors.every")}</th>
<th className="px-4 py-2.5" />
</tr>
</thead>
@@ -101,24 +102,24 @@ export function MonitorsPage() {
<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"
<Button variant="outline" size="icon" aria-label={t("monitors.checkNow")}
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"}
<Button variant="outline" size="icon" aria-label={m.active ? t("monitors.pause") : t("monitors.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"
<Button variant="outline" size="icon" aria-label={t("common.delete")}
onClick={() => {
if (confirm(`Delete monitor "${m.name}"?`)) {
if (confirm(t("monitors.deleteConfirm", { name: 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
{t("common.edit")}
</Button>
</div>
</td>
+38 -37
View File
@@ -10,6 +10,7 @@ import {
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import { t } from "@/i18n";
// Admin for public status pages + maintenance windows. The public view
// itself lives at /status/:slug.
@@ -63,7 +64,7 @@ export function StatusPagesPage() {
},
});
setBusy(false);
if (error) { setErr("Could not create — slug may be taken."); return; }
if (error) { setErr(t("statusPages.createError")); return; }
setPageOpen(false); setPTitle(""); setPSlug(""); setPDesc(""); setPMonitors([]);
void load();
}
@@ -79,7 +80,7 @@ export function StatusPagesPage() {
},
});
setBusy(false);
if (error) { setErr("Invalid window — check times."); return; }
if (error) { setErr(t("maintenance.createError")); return; }
setWinOpen(false); setWTitle(""); setWStart(""); setWEnd("");
void load();
}
@@ -88,17 +89,17 @@ export function StatusPagesPage() {
<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>
<h1 className="text-[15px] font-semibold tracking-tight">{t("statusPages.title")}</h1>
<p className="text-[12px] text-text-faint">{t("statusPages.publicAt")} <code className="font-mono">/status/&lt;slug&gt;</code>.</p>
</div>
<Button onClick={() => setPageOpen(true)}>
<Plus size={13} strokeWidth={2.2} /> New page
<Plus size={13} strokeWidth={2.2} /> {t("statusPages.new")}
</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>
<p className="text-[13px] text-text-dim">{t("statusPages.empty")}</p>
</div>
) : (
<div className="mb-8 flex flex-col gap-2">
@@ -107,11 +108,11 @@ export function StatusPagesPage() {
<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>
<Button variant="outline" size="icon" aria-label={t("statusPages.open")}><ExternalLink size={12} /></Button>
</Link>
<Button variant="outline" size="icon" aria-label="Delete"
<Button variant="outline" size="icon" aria-label={t("common.delete")}
onClick={() => {
if (p.id && confirm(`Delete status page "${p.title}"?`)) {
if (p.id && confirm(t("statusPages.deleteConfirm", { title: p.title ?? "" }))) {
void api.DELETE("/api/status-pages/{id}", { params: { path: { id: p.id } } }).then(load);
}
}}>
@@ -124,17 +125,17 @@ export function StatusPagesPage() {
<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>
<h2 className="text-[15px] font-semibold tracking-tight">{t("maintenance.title")}</h2>
<p className="text-[12px] text-text-faint">{t("maintenance.subtitle")}</p>
</div>
<Button variant="outline" onClick={() => setWinOpen(true)}>
<Plus size={13} strokeWidth={2.2} /> Schedule
<Plus size={13} strokeWidth={2.2} /> {t("maintenance.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>
<p className="text-[13px] text-text-dim">{t("maintenance.empty")}</p>
</div>
) : (
<div className="flex flex-col gap-2">
@@ -146,11 +147,11 @@ export function StatusPagesPage() {
{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"}
{w.active ? t("maintenance.active") : t("maintenance.scheduled")}
</span>
<Button variant="outline" size="icon" aria-label="Delete"
<Button variant="outline" size="icon" aria-label={t("common.delete")}
onClick={() => {
if (w.id && confirm(`Delete window "${w.title}"?`)) {
if (w.id && confirm(t("maintenance.deleteConfirm", { title: w.title ?? "" }))) {
void api.DELETE("/api/maintenance/{id}", { params: { path: { id: w.id } } }).then(load);
}
}}>
@@ -164,26 +165,26 @@ export function StatusPagesPage() {
<Dialog open={pageOpen} onOpenChange={setPageOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>New status page</DialogTitle>
<DialogDescription>Public live view over selected monitors.</DialogDescription>
<DialogTitle>{t("statusPages.newTitle")}</DialogTitle>
<DialogDescription>{t("statusPages.newDesc")}</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 />
<Label htmlFor="sp-title">{t("common.title")}</Label>
<Input id="sp-title" value={pTitle} onChange={(e) => setPTitle(e.target.value)} placeholder={t("statusPages.titlePlaceholder")} 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]" />
<Label htmlFor="sp-slug">{t("common.slug")}</Label>
<Input id="sp-slug" value={pSlug} onChange={(e) => setPSlug(e.target.value)} placeholder={t("statusPages.slugPlaceholder")} 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" />
<Label htmlFor="sp-desc">{t("common.description")}</Label>
<Input id="sp-desc" value={pDesc} onChange={(e) => setPDesc(e.target.value)} placeholder={t("common.optional")} />
</div>
<div className="flex flex-col gap-1.5">
<Label>Monitors</Label>
<Label>{t("statusPages.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]">
@@ -194,16 +195,16 @@ export function StatusPagesPage() {
{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>}
{monitors.length === 0 && <p className="px-1 py-1 text-[11.5px] text-text-faint">{t("statusPages.noMonitors")}</p>}
</div>
<p className="text-[11px] text-text-faint">Leave all unchecked to include every active monitor.</p>
<p className="text-[11px] text-text-faint">{t("statusPages.allHint")}</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 variant="outline" onClick={() => setPageOpen(false)} disabled={busy}>{t("common.cancel")}</Button>
<Button onClick={() => void createPage()} disabled={busy || !pTitle.trim()}>
{busy ? "Creating…" : "Create"}
{busy ? t("common.creating") : t("common.create")}
</Button>
</DialogFooter>
</DialogContent>
@@ -212,30 +213,30 @@ export function StatusPagesPage() {
<Dialog open={winOpen} onOpenChange={setWinOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Schedule maintenance</DialogTitle>
<DialogDescription>Suppresses alerts and auto-incidents for the window.</DialogDescription>
<DialogTitle>{t("maintenance.dialogTitle")}</DialogTitle>
<DialogDescription>{t("maintenance.dialogDesc")}</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 />
<Label htmlFor="mw-title">{t("common.title")}</Label>
<Input id="mw-title" value={wTitle} onChange={(e) => setWTitle(e.target.value)} placeholder={t("maintenance.titlePlaceholder")} autoFocus />
</div>
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor="mw-start">Starts</Label>
<Label htmlFor="mw-start">{t("maintenance.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>
<Label htmlFor="mw-end">{t("maintenance.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 variant="outline" onClick={() => setWinOpen(false)} disabled={busy}>{t("common.cancel")}</Button>
<Button onClick={() => void createWindow()} disabled={busy || !wTitle.trim() || !wStart || !wEnd}>
{busy ? "Saving…" : "Schedule"}
{busy ? t("common.saving") : t("maintenance.schedule")}
</Button>
</DialogFooter>
</DialogContent>
+12 -11
View File
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from "react";
import { api } from "@/api";
import type { PublicStatus } from "@/types";
import { cn } from "@/lib/utils";
import { t } from "@/i18n";
// Public status page — clean read-only view; refreshes every 30s.
// Route: /status/:slug -> GET /api/status-pages/:slug/public.
@@ -9,10 +10,10 @@ import { cn } from "@/lib/utils";
// 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" };
if (overall === "down") return { label: t("statusPublic.outage"), cls: "bg-down" };
if (overall === "maintenance") return { label: t("statusPublic.underMaintenance"), cls: "bg-text-faint/60" };
if (overall === "up") return { label: t("statusPublic.operational"), cls: "bg-up" };
return { label: t("statusPublic.checking"), cls: "bg-text-faint/40" };
}
function dotCls(status?: string) {
@@ -38,8 +39,8 @@ export function StatusPublicPage({ slug }: { slug: string }) {
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>;
if (missing) return <main className="mx-auto max-w-2xl px-7 py-16 text-center text-[13px] text-text-faint">{t("statusPublic.notFound")}</main>;
if (!st) return <main className="mx-auto max-w-2xl px-7 py-16 text-center text-[13px] text-text-faint">{t("common.loading")}</main>;
const banner = overallBanner(st.overall);
@@ -56,7 +57,7 @@ export function StatusPublicPage({ slug }: { slug: string }) {
{(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>
<h2 className="mb-2 font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">{t("statusPublic.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">
@@ -72,7 +73,7 @@ export function StatusPublicPage({ slug }: { slug: string }) {
{(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>
<h2 className="mb-2 font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">{t("statusPublic.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">
@@ -87,7 +88,7 @@ export function StatusPublicPage({ slug }: { slug: string }) {
{(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>
<h2 className="mb-2 font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">{t("statusPublic.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">
@@ -103,7 +104,7 @@ export function StatusPublicPage({ slug }: { slug: string }) {
{(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>
<h2 className="mb-2 font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">{t("statusPublic.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">
@@ -122,7 +123,7 @@ export function StatusPublicPage({ slug }: { slug: string }) {
</section>
)}
<p className="mt-10 text-center font-mono text-[10px] text-text-faint">powered by dash</p>
<p className="mt-10 text-center font-mono text-[10px] text-text-faint">{t("statusPublic.footer")}</p>
</main>
);
}
+212 -76
View File
@@ -1,12 +1,13 @@
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 { ChartCard, MetricChart, chartColor, type ChartRow, type Series } from "@/components/chart";
import type { System, SystemStat } from "@/types";
import { agentCmd, fmtBytes, fmtUptime } from "@/pages/SystemsPage";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { t } from "@/i18n";
export function SystemDetailPage({ id }: { id: string }) {
const [sys, setSys] = useState<System | null>(null);
@@ -32,28 +33,85 @@ export function SystemDetailPage({ id }: { id: string }) {
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>
<Link href="/systems" className="inline-flex items-center gap-1.5 hover:text-text"><ArrowLeft size={12} /> {t("nav.systems")}</Link>
<p className="mt-4">{t("systems.notFound")}</p>
</main>
);
if (!sys) return <main className="mx-auto max-w-5xl px-7 py-8 text-[13px] text-text-faint">Loading…</main>;
if (!sys) return <main className="mx-auto max-w-5xl px-7 py-8 text-[13px] text-text-faint">{t("common.loading")}</main>;
const l = sys.latest;
const chart = stats.map((s) => ({
t: new Date(s.ts ?? "").toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }),
const spanMs = hours * 3600_000;
// Rows carry fixed keys plus dynamic `c:`/`m:` (containers), `s:` (sensors),
// `g:`/`v:` (GPU util/VRAM) keys; series pick them up by name.
const rows: ChartRow[] = stats.map((s) => {
const r: ChartRow = {
t: new Date(s.ts ?? "").getTime(),
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 };
mem: s.memUsed,
swap: s.swapUsed,
disk: s.diskUsed,
rx: s.netRx,
tx: s.netTx,
l1: s.load1,
l5: s.load5,
l15: s.load15,
};
for (const c of s.containers ?? []) {
if (c.name) {
r[`c:${c.name}`] = c.cpu ?? 0;
r[`m:${c.name}`] = c.memUsed ?? 0;
}
}
for (const [k, v] of Object.entries(s.temps ?? {})) r[`s:${k}`] = v;
for (const g of s.gpu ?? []) {
if (g.name) {
r[`g:${g.name}`] = g.utilPct;
r[`v:${g.name}`] = g.memUsed;
}
}
return r;
});
const names = (ns: (string | undefined)[]) =>
[...new Set(ns.filter((n): n is string => n !== undefined))];
const containerNames = names(stats.flatMap((s) => (s.containers ?? []).map((c) => c.name)));
const tempNames = names(stats.flatMap((s) => Object.keys(s.temps ?? {})));
const gpuNames = names(stats.flatMap((s) => (s.gpu ?? []).map((g) => g.name)));
// Zero-fill absent containers so stacked areas stay flush over restarts.
for (const r of rows) {
for (const n of containerNames) {
r[`c:${n}`] ??= 0;
r[`m:${n}`] ??= 0;
}
}
const pct = (v: number) => `${v.toFixed(1)}%`;
const cpuSeries: Series[] = [{ key: "cpu", label: "cpu", color: chartColor(0) }];
const dockerCpuSeries: Series[] = containerNames.map((n, i) => ({ key: `c:${n}`, label: n, color: chartColor(i), stack: "a" }));
const memSeries: Series[] = [
{ key: "mem", label: t("systems.chartUsed"), color: chartColor(1) },
{ key: "swap", label: "swap", color: chartColor(3) },
];
const dockerMemSeries: Series[] = containerNames.map((n, i) => ({ key: `m:${n}`, label: n, color: chartColor(i), stack: "a" }));
const diskSeries: Series[] = [{ key: "disk", label: t("systems.chartUsed"), color: chartColor(2) }];
const netSeries: Series[] = [
{ key: "rx", label: t("systems.chartRx"), color: chartColor(4) },
{ key: "tx", label: t("systems.chartTx"), color: chartColor(5) },
];
const loadSeries: Series[] = [
{ key: "l1", label: "1 min", color: chartColor(0) },
{ key: "l5", label: "5 min", color: chartColor(3) },
{ key: "l15", label: "15 min", color: chartColor(5) },
];
const tempSeries: Series[] = tempNames.map((n, i) => ({ key: `s:${n}`, label: n, color: chartColor(i), line: true }));
const gpuSeries: Series[] = gpuNames.map((n, i) => ({ key: `g:${n}`, label: n, color: chartColor(i) }));
const vramSeries: Series[] = gpuNames.map((n, i) => ({ key: `v:${n}`, label: n, color: chartColor(i) }));
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
<ArrowLeft size={12} /> {t("nav.systems")}
</Link>
<div className="mb-6 flex items-start justify-between">
@@ -66,14 +124,14 @@ export function SystemDetailPage({ id }: { id: string }) {
</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"}
{sys.cpuModel ? ` · ${sys.cpuModel}` : ""}{sys.cores ? ` · ${t("systems.cores", { n: sys.cores })}` : ""}
{sys.lastSeenAt ? ` · ${t("systems.seenAt", { time: new Date(sys.lastSeenAt).toLocaleTimeString() })}` : ` · ${t("systems.neverSeen")}`}
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" aria-label="Delete"
<Button variant="outline" size="sm" aria-label={t("common.delete")}
onClick={() => {
if (confirm(`Delete system "${sys.name}"?`)) {
if (confirm(t("systems.deleteConfirm", { name: sys.name ?? "" }))) {
void api.DELETE("/api/systems/{id}", { params: { path: { id } } }).then(() => { location.href = "/systems"; });
}
}}>
@@ -84,10 +142,10 @@ export function SystemDetailPage({ id }: { id: string }) {
<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) : "—"],
[t("systems.cpu"), l?.cpu !== undefined ? `${l.cpu.toFixed(1)}%` : "—"],
[t("systems.mem"), l?.memTotal ? `${fmtBytes(l.memUsed ?? 0)} / ${fmtBytes(l.memTotal)}` : "—"],
[t("systems.disk") + " /", l?.diskTotal ? `${fmtBytes(l.diskUsed ?? 0)} / ${fmtBytes(l.diskTotal)}` : "—"],
[t("systems.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>
@@ -97,7 +155,7 @@ export function SystemDetailPage({ id }: { id: string }) {
</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>
<h2 className="font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">{t("systems.usage")}</h2>
<div className="flex gap-1">
{[1, 6, 24, 168].map((h) => (
<button key={h} type="button" onClick={() => setHours(h)}
@@ -110,62 +168,53 @@ export function SystemDetailPage({ id }: { id: string }) {
</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>
<ChartCard title={t("systems.chartCpu")} hint={t("systems.chartCpuHint")}>
<MetricChart rows={rows} series={cpuSeries} fmt={pct} domain={[0, 100]} spanMs={spanMs} />
</ChartCard>
{dockerCpuSeries.length > 0 && (
<ChartCard title={t("systems.chartDockerCpu")} hint={t("systems.chartDockerCpuHint")} series={dockerCpuSeries}>
<MetricChart rows={rows} series={dockerCpuSeries} fmt={pct} spanMs={spanMs} />
</ChartCard>
)}
<ChartCard title={t("systems.chartMem")} hint={t("systems.chartMemHint")} series={memSeries}>
<MetricChart rows={rows} series={memSeries} fmt={fmtBytes} spanMs={spanMs} />
</ChartCard>
{dockerMemSeries.length > 0 && (
<ChartCard title={t("systems.chartDockerMem")} hint={t("systems.chartDockerMemHint")} series={dockerMemSeries}>
<MetricChart rows={rows} series={dockerMemSeries} fmt={fmtBytes} spanMs={spanMs} />
</ChartCard>
)}
<ChartCard title={t("systems.chartDisk")} hint={t("systems.chartDiskHint")}>
<MetricChart rows={rows} series={diskSeries} fmt={fmtBytes} domain={l?.diskTotal ? [0, l.diskTotal] : undefined} spanMs={spanMs} />
</ChartCard>
<ChartCard title={t("systems.chartNet")} hint={t("systems.chartNetHint")} series={netSeries}>
<MetricChart rows={rows} series={netSeries} fmt={(v) => `${fmtBytes(v)}/s`} spanMs={spanMs} />
</ChartCard>
<ChartCard title={t("systems.chartLoad")} hint={t("systems.chartLoadHint")} series={loadSeries}>
<MetricChart rows={rows} series={loadSeries} fmt={(v) => v.toFixed(2)} spanMs={spanMs} />
</ChartCard>
{tempSeries.length > 0 && (
<ChartCard title={t("systems.chartTemp")} hint={t("systems.chartTempHint")} series={tempSeries}>
<MetricChart rows={rows} series={tempSeries} fmt={(v) => `${v.toFixed(0)}°C`} spanMs={spanMs} />
</ChartCard>
)}
{gpuSeries.length > 0 && (
<ChartCard title={t("systems.chartGpu")} hint={t("systems.chartGpuHint")} series={gpuSeries}>
<MetricChart rows={rows} series={gpuSeries} fmt={pct} domain={[0, 100]} spanMs={spanMs} />
</ChartCard>
)}
{vramSeries.length > 0 && (
<ChartCard title={t("systems.chartVram")} hint={t("systems.chartVramHint")} series={vramSeries}>
<MetricChart rows={rows} series={vramSeries} fmt={fmtBytes} spanMs={spanMs} />
</ChartCard>
)}
</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
{t("systems.temperatures")}
</h2>
<table className="w-full text-[12px]">
<tbody>
@@ -182,13 +231,23 @@ export function SystemDetailPage({ id }: { id: string }) {
{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
{t("systems.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 font-mono text-[11px] text-text-faint">
{ctr.state === "running" && (ctr.cpu || ctr.memUsed) ? (
<>
{(ctr.cpu ?? 0).toFixed(1)}%
{" · "}
{fmtBytes(ctr.memUsed ?? 0)}
{ctr.memLimit ? ` / ${fmtBytes(ctr.memLimit)}` : ""}
</>
) : null}
</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")}>
@@ -201,15 +260,92 @@ export function SystemDetailPage({ id }: { id: string }) {
</table>
</div>
)}
{l?.smart && l.smart.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">
{t("systems.drivesSmart")}
</h2>
<table className="w-full text-[12px]">
<tbody>
{l.smart.map((d) => (
<tr key={d.device} className="border-b border-border/60 last:border-0">
<td className="px-4 py-2 font-mono text-[11px] text-text-dim">{d.device}</td>
<td className="px-4 py-2 text-[11px] text-text-dim">{d.model}</td>
<td className="px-4 py-2 text-right font-mono text-[11px]">
{d.tempC ? `${d.tempC.toFixed(0)}°C` : ""}
</td>
<td className="px-4 py-2 text-right">
<span className={cn("font-mono text-[10.5px] uppercase",
d.passed === false ? "text-destructive" : "text-up")}>
{d.passed === false ? t("systems.failing") : d.passed ? "ok" : "—"}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{l?.zfs && l.zfs.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">
{t("systems.zfs")}
</h2>
<table className="w-full text-[12px]">
<tbody>
{l.zfs.map((p) => (
<tr key={p.name} className="border-b border-border/60 last:border-0">
<td className="px-4 py-2 font-mono text-[11px] text-text-dim">{p.name}</td>
<td className="px-4 py-2 font-mono text-[11px] text-text-dim">
{p.size ? `${fmtBytes((p.size ?? 0) - (p.free ?? 0))} / ${fmtBytes(p.size)}` : ""}
</td>
<td className="px-4 py-2 text-right">
<span className={cn("font-mono text-[10.5px] uppercase",
p.health === "ONLINE" ? "text-up" : "text-destructive")}>
{p.health}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{l?.gpu && l.gpu.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">
GPU
</h2>
<table className="w-full text-[12px]">
<tbody>
{l.gpu.map((g) => (
<tr key={g.name} className="border-b border-border/60 last:border-0">
<td className="px-4 py-2 font-mono text-[11px] text-text-dim">{g.name}</td>
<td className="px-4 py-2 text-right font-mono text-[11px] text-text-dim">
{[
g.utilPct ? `${g.utilPct.toFixed(0)}%` : "",
g.tempC ? `${g.tempC.toFixed(0)}°C` : "",
g.memTotal ? `${fmtBytes(g.memUsed ?? 0)} / ${fmtBytes(g.memTotal)}` : "",
].filter(Boolean).join(" · ")}
</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="mb-1.5 font-mono text-[10px] uppercase tracking-[0.1em] text-text-faint">{t("systems.agentCmd")}</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"
<Button variant="outline" size="icon" aria-label={t("systems.copy")}
onClick={() => void navigator.clipboard.writeText(agentCmd(sys))}>
<Copy size={12} />
</Button>
+24 -20
View File
@@ -10,15 +10,19 @@ import {
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import { t } from "@/i18n";
const TB = 2 ** 40;
const GB = 2 ** 30;
const MB = 2 ** 20;
const KB = 2 ** 10;
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`;
if (b >= MB) return `${(b / MB).toFixed(1)} MB`;
if (b >= KB) return `${(b / KB).toFixed(0)} KB`;
return `${Math.round(b)} B`;
}
export function fmtUptime(s: number) {
@@ -79,7 +83,7 @@ export function SystemsPage() {
}
async function remove(id: string | undefined, n: string | undefined) {
if (!id || !confirm(`Delete system "${n}"?`)) return;
if (!id || !confirm(t("systems.deleteConfirm", { name: n ?? "" }))) return;
await api.DELETE("/api/systems/{id}", { params: { path: { id } } });
await load();
}
@@ -88,19 +92,19 @@ export function SystemsPage() {
<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>
<h1 className="text-[15px] font-semibold tracking-tight">{t("systems.title")}</h1>
<p className="text-[12px] text-text-faint">{t("systems.subtitle")}</p>
</div>
<Button onClick={() => { setCreated(null); setDlgOpen(true); }}>
<Plus size={13} strokeWidth={2.2} /> New system
<Plus size={13} strokeWidth={2.2} /> {t("systems.new")}
</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="text-[13px] text-text-dim">{t("systems.empty")}</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.
{t("systems.emptyHintPre")} <code className="font-mono">dash-agent</code> {t("systems.emptyHintPost")}
</p>
</div>
) : (
@@ -122,18 +126,18 @@ export function SystemsPage() {
<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"
<Button variant="outline" size="icon" aria-label={t("common.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`}
{sys.host} · {sys.cpuModel || t("systems.cores", { n: sys.cores ?? 0 })}
{l?.uptimeS ? ` · up ${fmtUptime(l.uptimeS)}` : ""}
</p>
) : (
<p className="mb-2.5 font-mono text-[10.5px] text-text-faint">waiting for agent…</p>
<p className="mb-2.5 font-mono text-[10.5px] text-text-faint">{t("systems.waiting")}</p>
)}
<div className="flex flex-col gap-1.5">
{rows.map(([label, pct, txt]) => (
@@ -153,43 +157,43 @@ export function SystemsPage() {
<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>
<DialogTitle>{t("systems.newTitle")}</DialogTitle>
<DialogDescription>{t("systems.newDesc")}</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>:
{t("systems.agentCmdFor")} <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"
<Button variant="outline" size="icon" aria-label={t("systems.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.
{t("systems.tokenWarning")}
</p>
</div>
) : (
<div className="flex flex-col gap-1.5">
<Label htmlFor="sys-name">Name</Label>
<Label htmlFor="sys-name">{t("common.name")}</Label>
<Input id="sys-name" value={name} onChange={(e) => setName(e.target.value)}
placeholder="nas" autoFocus
placeholder={t("systems.namePlaceholder")} autoFocus
onKeyDown={(e) => { if (e.key === "Enter") void create(); }} />
</div>
)}
<DialogFooter>
{created ? (
<Button onClick={() => setDlgOpen(false)}>Done</Button>
<Button onClick={() => setDlgOpen(false)}>{t("systems.done")}</Button>
) : (
<>
<Button variant="outline" onClick={() => setDlgOpen(false)} disabled={busy}>Cancel</Button>
<Button variant="outline" onClick={() => setDlgOpen(false)} disabled={busy}>{t("common.cancel")}</Button>
<Button onClick={() => void create()} disabled={busy || !name.trim()}>
{busy ? "Creating…" : "Create"}
{busy ? t("common.creating") : t("common.create")}
</Button>
</>
)}
+3 -2
View File
@@ -1,5 +1,6 @@
import { ChevronDown, GripVertical, Pencil, Trash2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { t } from "@/i18n";
import { useItemDnd, useSectionDnd } from "@/board/dnd";
import { IconImg, ItemAnchor, StatusChip, hostOf } from "@/board/primitives";
import { WidgetContent } from "@/widgets";
@@ -28,7 +29,7 @@ function SectionView({ section, onToggle, onDelete, children }: SectionViewProps
<div className="group/sec flex cursor-pointer select-none items-center gap-3 px-0.5 py-1">
<button
type="button"
aria-label="Drag section"
aria-label={t("renderer.dragSection")}
className="cursor-grab text-text-faint opacity-0 transition-opacity group-hover/sec:opacity-100 active:cursor-grabbing"
{...dnd.attributes}
{...dnd.listeners}
@@ -54,7 +55,7 @@ function SectionView({ section, onToggle, onDelete, children }: SectionViewProps
</button>
<button
type="button"
aria-label="Delete section"
aria-label={t("renderer.deleteSection")}
onClick={onDelete}
className="text-text-faint opacity-0 transition-opacity hover:text-destructive group-hover/sec:opacity-100"
>
+3 -2
View File
@@ -1,5 +1,6 @@
import { ChevronDown, GripVertical, Pencil, Trash2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { t } from "@/i18n";
import { useItemDnd, useSectionDnd } from "@/board/dnd";
import { IconImg, ItemAnchor, StatusChip, hostOf } from "@/board/primitives";
import { WidgetContent } from "@/widgets";
@@ -32,7 +33,7 @@ function SectionView({ section, onToggle, onDelete, children }: SectionViewProps
</button>
<button
type="button"
aria-label="Drag section"
aria-label={t("renderer.dragSection")}
className="cursor-grab text-text-faint opacity-0 transition-opacity group-hover/sec:opacity-100 active:cursor-grabbing"
{...dnd.attributes}
{...dnd.listeners}
@@ -41,7 +42,7 @@ function SectionView({ section, onToggle, onDelete, children }: SectionViewProps
</button>
<button
type="button"
aria-label="Delete section"
aria-label={t("renderer.deleteSection")}
onClick={onDelete}
className="text-text-faint opacity-0 transition-opacity hover:text-destructive group-hover/sec:opacity-100"
>
@@ -1,6 +1,7 @@
import { useEffect, useState } from "react";
import { ChevronDown, GripVertical, Pencil, Trash2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { t } from "@/i18n";
import { useItemDnd, useSectionDnd } from "@/board/dnd";
import { useBoard } from "@/board/store";
import { IconImg, ItemAnchor, hostOf } from "@/board/primitives";
@@ -37,10 +38,10 @@ function BoardView({ children }: BoardViewProps) {
}).format(now);
const cells: [string, string, string][] = [
["Local time", time, date.split(",")[0] ?? ""],
[t("renderer.localTime"), time, date.split(",")[0] ?? ""],
["Services", String(services.length), `${board.sections.length} sections`],
["Up", String(up), "responding"],
["Down", String(down), down === 0 ? "all green" : "offline"],
[t("renderer.down"), String(down), down === 0 ? t("renderer.allGreen") : t("renderer.offline")],
["Widgets", String(widgets.length), "registered"],
];
@@ -77,7 +78,7 @@ function SectionView({ section, index, onToggle, onDelete, children }: SectionVi
<div className="group/sec mb-0.5 flex cursor-pointer select-none items-baseline gap-3.5 border-b border-border-strong pb-2.5">
<button
type="button"
aria-label="Drag section"
aria-label={t("renderer.dragSection")}
className="cursor-grab self-center text-text-faint opacity-0 transition-opacity group-hover/sec:opacity-100 active:cursor-grabbing"
{...dnd.attributes}
{...dnd.listeners}
@@ -96,7 +97,7 @@ function SectionView({ section, index, onToggle, onDelete, children }: SectionVi
{section.name}
</h2>
<span className="font-mono text-[11px] text-text-faint">
{section.items.length} {section.items.length === 1 ? "entry" : "entries"}
{section.items.length} {t(section.items.length === 1 ? "renderer.entry" : "renderer.entries")}
</span>
<ChevronDown
size={14}
@@ -105,7 +106,7 @@ function SectionView({ section, index, onToggle, onDelete, children }: SectionVi
</button>
<button
type="button"
aria-label="Delete section"
aria-label={t("renderer.deleteSection")}
onClick={onDelete}
className="self-center text-text-faint opacity-0 transition-opacity hover:text-destructive group-hover/sec:opacity-100"
>
+8
View File
@@ -1,3 +1,4 @@
import { LayoutGrid, LayoutList, Newspaper, SquareTerminal, type LucideIcon } from "lucide-react";
import { bento } from "./bento";
import { cards } from "./cards";
import { editorial } from "./editorial";
@@ -27,3 +28,10 @@ export const rendererDescriptions: Record<RendererName, string> = {
index: "editorial list · masthead",
console: "terminal panels · mono",
};
export const rendererIcons: Record<RendererName, LucideIcon> = {
bento: LayoutGrid,
cards: LayoutList,
index: Newspaper,
console: SquareTerminal,
};
@@ -1,6 +1,7 @@
import { useEffect, useState } from "react";
import { ChevronDown, Pencil, Trash2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { t } from "@/i18n";
import { useItemDnd, useSectionDnd } from "@/board/dnd";
import { useBoard } from "@/board/store";
import { IconImg, ItemAnchor, hostOf } from "@/board/primitives";
@@ -42,7 +43,7 @@ function BoardView({ children }: BoardViewProps) {
const panels: { head: string; v: string; s: string; live?: boolean; wide?: boolean }[] = [
{ head: "clock --local", v: time, s: date, live: true, wide: true },
{ head: "services --up", v: String(up), s: `of ${probed} probed` },
{ head: "services --down", v: String(down), s: down === 0 ? "all green" : "needs attention" },
{ head: "services --down", v: String(down), s: down === 0 ? t("renderer.allGreen") : t("renderer.needsAttention") },
{ head: "widgets", v: String(widgets.length), s: "registered" },
{ head: "endpoints", v: String(endpoints), s: "urls indexed" },
{ head: "uptime --probes", v: probed > 0 ? `${Math.round((up / probed) * 100)}%` : "—", s: `${up}/${probed} responding` },
@@ -56,7 +57,7 @@ function BoardView({ children }: BoardViewProps) {
<span className="font-medium text-text-dim">{services.length} services</span>
{` indexed · `}
<span className="text-up">{up} up</span>
{down > 0 ? <span>{` · `}<span className="text-down">{down} down</span></span> : ` · all green`}
{down > 0 ? <span>{` · `}<span className="text-down">{t("renderer.downCount", { n: down })}</span></span> : ` · ${t("renderer.allGreen")}`}
{` · `}
<span className="font-medium text-text-dim">{widgets.length} widgets</span>
{`\ndrag rows to reorder · click rows with multiple endpoints to choose`}
@@ -70,7 +71,7 @@ function BoardView({ children }: BoardViewProps) {
>
<div className="flex items-center gap-1.5 border-b border-border px-3 py-2 text-[10px] uppercase tracking-[0.1em] text-text-faint">
{p.head}
{p.live ? <span className="ml-auto text-[9px] text-up">● live</span> : null}
{p.live ? <span className="ml-auto text-[9px] text-up">● {t("renderer.live")}</span> : null}
</div>
<div className="px-3 py-3">
<div className={cn("font-medium leading-none tracking-tight tabular-nums", p.wide ? "text-[26px]" : "text-[22px]")}>{p.v}</div>
@@ -108,7 +109,7 @@ function SectionView({ section, onToggle, onDelete, children }: SectionViewProps
</button>
<button
type="button"
aria-label="Drag section"
aria-label={t("renderer.dragSection")}
className="cursor-grab text-text-faint opacity-0 transition-opacity group-hover/sec:opacity-100 active:cursor-grabbing"
{...dnd.attributes}
{...dnd.listeners}
@@ -117,7 +118,7 @@ function SectionView({ section, onToggle, onDelete, children }: SectionViewProps
</button>
<button
type="button"
aria-label="Delete section"
aria-label={t("renderer.deleteSection")}
onClick={onDelete}
className="text-text-faint opacity-0 transition-opacity hover:text-destructive group-hover/sec:opacity-100"
>
+18
View File
@@ -2,6 +2,7 @@ import type { components, paths } from "@dash/api-client";
// Board entities — generated from openapi.yaml. Never hand-edit shapes here.
export type Section = components["schemas"]["Section"];
export type Board = components["schemas"]["Board"];
export type Item = components["schemas"]["Item"];
export type BoardUrl = components["schemas"]["Url"];
export type UrlInput = components["schemas"]["UrlInput"];
@@ -19,7 +20,11 @@ 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 DNSRecord = components["schemas"]["DNSRecord"];
export type Provider = components["schemas"]["Provider"];
export type Providers = components["schemas"]["Providers"];
export type DomainCheck = components["schemas"]["DomainCheck"];
export type Subdomain = components["schemas"]["Subdomain"];
export type System = components["schemas"]["System"];
export type StatSample = components["schemas"]["StatSample"];
/** Stats rows are StatSample plus the server-side timestamp. */
@@ -41,13 +46,26 @@ export type SettingsWire = NonNullable<
export interface Prefs {
theme: Theme;
renderer: RendererName;
/** CSS color override for --accent; empty = theme default. */
accent: string;
/** Background image URL for the board; empty = none. */
wallpaper: string;
/** Raw user CSS injected into a <style> tag; empty = none. */
customCss: string;
/** UI language — matches a key in i18n LOCALES; empty = en. */
locale: string;
}
/** Narrow the settings map to typed prefs; unknown/absent keys fall back. */
export function parsePrefs(raw: SettingsWire): Prefs {
const r = raw.renderer;
const s = (v: unknown) => (typeof v === "string" ? v : "");
return {
theme: raw.theme === "light" ? "light" : "dark",
renderer: r === "cards" || r === "index" || r === "console" ? r : "bento",
accent: s(raw.accent),
wallpaper: s(raw.wallpaper),
customCss: s(raw.custom_css),
locale: s(raw.locale),
};
}
+5 -4
View File
@@ -1,5 +1,6 @@
import type { Item } from "@/types";
import type { WidgetData } from "@/widgets/index";
import { t } from "@/i18n";
// Shared DNS-blocker layout: big block-rate, queries + blocked counts.
// Pi-hole and AdGuard return the same fields from their fetchers.
@@ -23,12 +24,12 @@ function BlockerWidget({ item, data, error }: { item: Item; data: WidgetData | u
<span className="font-mono text-[22px] font-medium leading-none tracking-tight tabular-nums">
{data ? `${pct.toFixed(1)}%` : "…"}
</span>
<span className="text-[10.5px] uppercase tracking-[0.1em] text-text-faint">blocked</span>
<span className="text-[10.5px] uppercase tracking-[0.1em] text-text-faint">{t("widget.blockedLbl")}</span>
</div>
<div className="ml-auto flex flex-col items-end gap-1 font-mono text-[10.5px] text-text-faint">
<span>{fmt(num(data, "queriesToday"))} queries</span>
<span>{fmt(num(data, "blockedToday"))} blocked</span>
{typeof data?.avgProcessMs === "number" && <span>{num(data, "avgProcessMs").toFixed(0)} ms avg</span>}
<span>{t("widget.queries", { n: fmt(num(data, "queriesToday")) })}</span>
<span>{t("widget.blocked", { n: fmt(num(data, "blockedToday")) })}</span>
{typeof data?.avgProcessMs === "number" && <span>{t("widget.msAvg", { n: num(data, "avgProcessMs").toFixed(0) })}</span>}
</div>
</div>
);
+2 -1
View File
@@ -1,6 +1,7 @@
import type { WidgetData } from "@/widgets";
import type { Item } from "@/types";
import { cn } from "@/lib/utils";
import { t } from "@/i18n";
// Board tile for a tracked domain: favicon, name, expiry countdown.
export function DomainWidget({ item, data, error }: {
@@ -29,7 +30,7 @@ export function DomainWidget({ item, data, error }: {
"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`}
{error ? t("widget.err") : days === undefined ? "…" : days < 0 ? t("domains.expired") : `${days}d`}
{sslDays !== undefined && sslDays <= 30 ? ` · ssl ${sslDays}d` : ""}
</span>
</a>
+23
View File
@@ -0,0 +1,23 @@
import type { Item } from "@/types";
import { cfgStr } from "@/widgets/index";
import { t } from "@/i18n";
// Embed — sandboxed iframe. Fully client-side; the tile chrome still shows
// the item name above the frame.
export function EmbedWidget({ item }: { item: Item }) {
const url = cfgStr(item, "url");
// Block script-capable schemes; http(s)/relative URLs are fine.
const bad = /^\s*(javascript|data|vbscript|file):/i.test(url);
if (!url || bad) {
return <span className="text-[11px] text-text-faint">{t("widget.embedHint")}</span>;
}
return (
<iframe
src={url}
title={item.name || "embed"}
sandbox="allow-scripts allow-same-origin allow-forms"
referrerPolicy="no-referrer"
className="pointer-events-auto h-full min-h-[120px] w-full rounded-[6px] border border-border bg-bg"
/>
);
}
+3 -2
View File
@@ -1,5 +1,6 @@
import type { Item } from "@/types";
import type { WidgetData } from "@/widgets/index";
import { t } from "@/i18n";
// Immich — photo/video counts and library footprint.
@@ -24,10 +25,10 @@ export function ImmichWidget({ item, data, error }: { item: Item; data: WidgetDa
<span className="font-mono text-[22px] font-medium leading-none tracking-tight tabular-nums">
{data ? fmtNum(num(data, "photos")) : "…"}
</span>
<span className="text-[10.5px] uppercase tracking-[0.1em] text-text-faint">photos</span>
<span className="text-[10.5px] uppercase tracking-[0.1em] text-text-faint">{t("widget.photosLbl")}</span>
</div>
<div className="ml-auto flex flex-col items-end gap-1 font-mono text-[10.5px] text-text-faint">
<span>{fmtNum(num(data, "videos"))} videos</span>
<span>{t("widget.videos", { n: fmtNum(num(data, "videos")) })}</span>
<span>{fmtBytes(num(data, "usageBytes"))}</span>
</div>
</div>
+7 -2
View File
@@ -1,11 +1,14 @@
import { useEffect, useState } from "react";
import type { Item } from "@/types";
import { t } from "@/i18n";
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";
import { EmbedWidget } from "@/widgets/embed";
import { JsonPathWidget } from "@/widgets/jsonpath";
// 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
@@ -34,6 +37,8 @@ const registry: WidgetRegistry = {
monitor: { view: MonitorWidget },
domain: { view: DomainWidget },
system: { view: SystemWidget },
embed: { local: true, view: EmbedWidget },
jsonpath: { view: JsonPathWidget },
};
export function cfgStr(item: Item, key: string): string {
@@ -66,7 +71,7 @@ export function useWidgetData(item: Item): { data: WidgetData | undefined; error
const body: unknown = await res.json();
if (dead) return;
if (!res.ok) {
let msg = "fetch failed";
let msg = t("widget.fetchFailed");
if (typeof body === "object" && body !== null && "error" in body) {
msg = String(body.error);
}
@@ -79,7 +84,7 @@ export function useWidgetData(item: Item): { data: WidgetData | undefined; error
setState({ data, error: undefined });
}
} catch {
if (!dead) setState((s) => ({ data: s.data, error: "offline" }));
if (!dead) setState((s) => ({ data: s.data, error: t("widget.offline") }));
}
if (!dead) timer = setTimeout(tick, 30_000);
};
+22
View File
@@ -0,0 +1,22 @@
import type { WidgetData } from "@/widgets";
import type { Item } from "@/types";
// JSON value — renders the scalar the backend extracted at config.path.
export function JsonPathWidget({ item, data, error }: {
item: Item;
data: WidgetData | undefined;
error: string | undefined;
}) {
const label = typeof data?.label === "string" && data.label ? data.label : item.name;
const value = data?.value;
return (
<div className="flex min-w-0 flex-col justify-center gap-0.5">
<span className="truncate text-[10.5px] uppercase tracking-[0.1em] text-text-faint">
{label}
</span>
<span className="truncate font-mono text-[18px] font-medium leading-tight">
{error ? "err" : value === undefined ? "…" : typeof value === "object" ? JSON.stringify(value) : String(value)}
</span>
</div>
);
}
+2 -1
View File
@@ -1,6 +1,7 @@
import type { WidgetData } from "@/widgets";
import type { Item } from "@/types";
import { cn } from "@/lib/utils";
import { t } from "@/i18n";
// Board tile for an uptime monitor: status dot, uptime %, avg ping.
// config.monitorId binds it; data comes from the monitor fetcher.
@@ -30,7 +31,7 @@ export function MonitorWidget({ item, data, error }: {
{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)}%` : "…"}
{error ? t("widget.err") : uptime !== undefined ? `${uptime.toFixed(1)}%` : "…"}
{ping ? ` · ${Math.round(ping)}ms` : ""}
</span>
</a>
+2 -1
View File
@@ -1,6 +1,7 @@
import type { WidgetData } from "@/widgets";
import type { Item } from "@/types";
import { cn } from "@/lib/utils";
import { t } from "@/i18n";
// Board tile for a monitored system: status dot + cpu/mem/disk mini bars.
// config.systemId binds it; data comes from the system fetcher.
@@ -47,7 +48,7 @@ export function SystemWidget({ item, data, error }: {
{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="shrink-0 font-mono text-[10.5px] text-text-faint">{t("widget.err")}</span>
) : (
<span className="flex shrink-0 items-center gap-2">
{bar(cpu, "cpu")}
+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" rx="104" fill="#0a0a0a"/>
<g fill="#3b82f6">
<rect x="120" y="120" width="124" height="124" rx="28"/>
<rect x="268" y="120" width="124" height="124" rx="28" fill="#6b9ef8"/>
<rect x="120" y="268" width="124" height="124" rx="28" fill="#1d4fd7"/>
<rect x="268" y="268" width="124" height="124" rx="28"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 431 B

+5
View File
@@ -1,10 +1,15 @@
services:
dash:
# Prebuilt release image — `docker compose pull && docker compose up -d`.
# To build from source instead: `docker compose up -d --build`.
image: ghcr.io/dvorinka/dash:latest
build:
context: .
dockerfile: infra/Dockerfile
ports:
- "3000:3000"
volumes:
# CasaOS: use infra/compose.casaos.yaml — CasaOS runs compose from /tmp,
# so this relative path fails there and would not survive a reboot.
- ./data:/data
restart: unless-stopped
+13 -8
View File
@@ -1,11 +1,11 @@
# v1.0.0 launch post — draft
# v2.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.
Single Go binary, no YAML, optional auth.
**Body:**
@@ -19,13 +19,17 @@ What it does:
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.
for cron jobs. Uptime graphs, per-monitor alert rules, and notifications via
webhook, Slack, Discord, Telegram, Gotify, ntfy, or SMTP.
- **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.
- **Systems** — a ~4 MB static agent pushes CPU/mem/disk/net/temps, per-
container stats, and SMART/ZFS/GPU when present. Inspired by Beszel,
push-JSON instead of SSH.
- **Ops** — incidents, maintenance windows, public status pages, SVG badges,
Prometheus `/api/metrics`.
- **Extras** — multiple boards, iframe/JSON-path widgets, CT-log subdomain
discovery, EN/CZ locales, installable PWA, custom CSS/wallpaper.
Deployment is one container:
@@ -33,8 +37,9 @@ Deployment is one container:
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.
SQLite inside, no database to babysit. Auth is opt-in: local
username/password with bcrypt + session cookies, off by default; public
status pages stay open either way.
Stack: Go + Gin + SQLite (single static binary serving an embedded React UI).
MIT licensed.
@@ -53,5 +58,5 @@ 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.
single binary, SQLite storage, no YAML. Optional local auth. MIT.
https://github.com/Dvorinka/Dash
+31 -6
View File
@@ -21,12 +21,16 @@ 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.
**Settings → Notifications** configures any number of transports — generic
webhook, Slack, Discord, Telegram, Gotify, ntfy, and SMTP — each stored as a
`notify_<name>` settings object. Monitor up/down transitions and domain
events fan out to every configured transport; the payload carries `event`,
`name`, `text`, `content`, `message`, and `at` fields. **Test** sends a `test`
event. Delivery is async and non-fatal; failures are logged.
Per-monitor alert rules (consecutive failures before down, latency-warn ms,
mute) live on each monitor; domains get a cert-days threshold and mute.
Maintenance windows still take precedence.
## Monitors
@@ -47,6 +51,27 @@ 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.
## Auth
Off by default — existing installs see no gate. **Settings → Authentication →
Enable** flips to a first-user setup screen (username/email + password,
bcrypt). Sessions are SQLite-backed 7-day HttpOnly cookies with rotation;
login is rate-limited per IP. Disable only works from an authenticated
session. Public status pages, badges, `/api/metrics`, `/api/push/*`, and
agent ingest stay open under auth.
## Boards
The header switcher manages multiple boards — each gets a slug and a
`/b/:slug` route with its own sections/items. Monitors, domains, systems, and
status pages stay global. Exports carry all boards.
## Appearance & language
Theme (dark/light), accent color, wallpaper URL, and custom CSS in Settings;
the language picker switches the UI between English and Czech. A PWA manifest
and service worker make the app installable.
## Operations
- **Incidents** — open/ack/resolve/close, with per-incident update timeline.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

+23 -1
View File
@@ -3,7 +3,16 @@
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)
## One-line install
```sh
curl -fsSL https://raw.githubusercontent.com/Dvorinka/Dash/main/install.sh | sh
```
Creates `./dash`, pulls the image, and starts on port 3000. Override with
`DASH_DIR` / `DASH_PORT` environment variables.
## Docker (manual)
```sh
docker run -d --name dash \
@@ -34,6 +43,19 @@ To build the image locally instead of pulling:
docker compose up --build
```
## CasaOS
Do not paste `docker-compose.yml` into CasaOS's custom install. CasaOS copies
imported compose files to `/tmp/casaos-compose-app-*` and runs them there, so
the relative `./data` bind resolves under `/tmp` — install fails with
`bind source path does not exist`, and anything written would be wiped on
reboot anyway.
Use `infra/compose.casaos.yaml` instead (Apps → Custom Install → Import). It
mounts `/DATA/AppData/dash` on the host — persistent across reboots and
browsable in the CasaOS Files app — and carries the `x-casaos` metadata the
app tile uses.
## Binary
Release archives contain a static `dash` binary (no CGO, no dependencies).
Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 72 KiB

Loaded 100 of 129 files, more files were not shown because too many files have changed in this diff. Show more