Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d18b4f6825 | ||
|
|
0612beb37e | ||
|
|
a54ba4c544 | ||
|
|
1bb5441eea | ||
|
|
cfacc4ed7c | ||
|
|
10d9171031 | ||
|
|
8ff13f74f7 | ||
|
|
041c54c18c | ||
|
|
f4ebdf1f33 | ||
|
|
c5339a24b4 | ||
|
|
5473881f93 | ||
|
|
c336408152 | ||
|
|
ddd29aad24 | ||
|
|
35602c2e52 | ||
|
|
eb84939a19 | ||
|
|
afa0c931b9 | ||
|
|
54b5497121 | ||
|
|
0fa2ea82d8 | ||
|
|
e0ef60eca6 | ||
|
|
f3143af351 | ||
|
|
8973be3b41 | ||
|
|
fbc4ed4f23 |
No files matched your search
@@ -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
|
||||
|
||||
@@ -20,6 +20,9 @@ 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/
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
# 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
|
||||
|
||||
|
||||
@@ -6,6 +6,55 @@ All notable changes to Dash. Format follows
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [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 +126,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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||

|
||||
<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 |
|
||||
|:-:|:-:|:-:|
|
||||
|  |  |  |
|
||||
|
||||
<details>
|
||||
<summary>More screenshots</summary>
|
||||
|
||||
| Domain intel | Domains | Status page |
|
||||
|:-:|:-:|:-:|
|
||||
|  |  |  |
|
||||
|
||||
</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
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
|  |  |
|
||||
|  |  |
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -99,19 +107,27 @@ func (s *Server) listSections(c *gin.Context) {
|
||||
|
||||
func (s *Server) createSection(c *gin.Context) {
|
||||
var in struct {
|
||||
Name string `json:"name"`
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
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
|
||||
}
|
||||
s.notify("test", "dash", "Dash test notification")
|
||||
c.JSON(http.StatusOK, gin.H{"status": "sent"})
|
||||
c.JSON(http.StatusOK, gin.H{"results": results})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"`
|
||||
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.
|
||||
|
||||
@@ -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;
|
||||
@@ -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"},
|
||||
}
|
||||
for _, n := range ns {
|
||||
n = strings.ToLower(n)
|
||||
for _, t := range table {
|
||||
if strings.Contains(n, t.sub) {
|
||||
return t.name
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
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)}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
func matchProvider(haystack string, table []provMatch) *Provider {
|
||||
h := strings.ToLower(haystack)
|
||||
for _, t := range table {
|
||||
if strings.Contains(h, t.sub) {
|
||||
return prov(t.name, t.dom)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
return nil
|
||||
}
|
||||
|
||||
func detectHostingProvider(h map[string]string) string {
|
||||
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"},
|
||||
}
|
||||
|
||||
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"},
|
||||
}
|
||||
for _, t := range table {
|
||||
if strings.Contains(i, t.sub) {
|
||||
return t.name
|
||||
}
|
||||
}
|
||||
return ""
|
||||
// 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"},
|
||||
}
|
||||
|
||||
func matchTXTProvider(txt string) *Provider {
|
||||
return matchProvider(txt, txtProviders)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
@@ -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 |
@@ -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" }
|
||||
]
|
||||
}
|
||||
@@ -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;
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,106 +150,212 @@ 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) => (
|
||||
<button
|
||||
key={r}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={board.renderer === r}
|
||||
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"
|
||||
: "border-border hover:border-border-strong hover:bg-surface-hover",
|
||||
)}
|
||||
>
|
||||
<span className="block text-[12.5px] font-medium">{rendererLabels[r]}</span>
|
||||
<span className="block font-mono text-[10px] text-text-faint">{rendererDescriptions[r]}</span>
|
||||
</button>
|
||||
))}
|
||||
<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={active}
|
||||
onClick={() => void board.setSetting("renderer", r)}
|
||||
className={cn(
|
||||
"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 truncate font-mono text-[10px] text-text-faint">{rendererDescriptions[r]}</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<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);
|
||||
}}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="dark">Dark</SelectItem>
|
||||
<SelectItem value="light">Light</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<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>{t("settings.language")}</Label>
|
||||
<Select
|
||||
value={board.locale || "en"}
|
||||
onValueChange={(v) => void board.setSetting("locale", v)}
|
||||
>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{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="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>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="notify-webhook">Notification webhook</Label>
|
||||
<Group icon={Image} title={t("settings.wallpaper")}>
|
||||
<Input
|
||||
id="wallpaper"
|
||||
value={board.wallpaper}
|
||||
onChange={(e) => void board.setSetting("wallpaper", e.target.value)}
|
||||
placeholder={t("settings.wallpaperPh")}
|
||||
className="font-mono text-[12px]"
|
||||
/>
|
||||
<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">
|
||||
<Input
|
||||
id="notify-webhook"
|
||||
value={webhook}
|
||||
onChange={(e) => { setWebhook(e.target.value); setWebhookMsg(""); }}
|
||||
placeholder="https://ntfy.sh/dash or Slack/Discord hook"
|
||||
className="font-mono text-[12px]"
|
||||
/>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => void saveWebhook()}>
|
||||
Save
|
||||
</Button>
|
||||
<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>
|
||||
{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">
|
||||
Monitor up/down flips and domain/certificate expiry post JSON here.
|
||||
Works with Slack, Discord, ntfy, or any JSON receiver.
|
||||
{t("settings.notifyFanout")}
|
||||
</p>
|
||||
{webhookMsg ? <p className="text-[11px] text-text-faint">{webhookMsg}</p> : null}
|
||||
</div>
|
||||
{notifyMsg ? <p className="text-[11px] text-text-faint">{notifyMsg}</p> : null}
|
||||
</Group>
|
||||
|
||||
<div className="flex gap-2 pt-1">
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => void exportJson()}>
|
||||
<Download size={12} /> Export JSON
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => fileRef.current?.click()}>
|
||||
<Upload size={12} /> Import
|
||||
</Button>
|
||||
<input
|
||||
ref={fileRef} type="file" accept=".json,.yml,.yaml" className="hidden"
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f) void importJson(f);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[11px] text-text-faint">Import accepts Dash exports plus Homepage, Homarr, and Dashy configs.</p>
|
||||
{msg ? <p className="text-[12px] text-destructive">{msg}</p> : null}
|
||||
<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} /> {t("settings.exportJson")}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => fileRef.current?.click()}>
|
||||
<Upload size={12} /> {t("settings.import")}
|
||||
</Button>
|
||||
<input
|
||||
ref={fileRef} type="file" accept=".json,.yml,.yaml" className="hidden"
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f) void importJson(f);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,34 +88,38 @@ 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>
|
||||
<div className="flex gap-1">
|
||||
{[24, 168, 720].map((h) => (
|
||||
<button key={h} type="button" onClick={() => setHours(h)}
|
||||
className={cn("rounded-[6px] px-2 py-1 font-mono text-[10.5px]",
|
||||
hours === h ? "bg-surface-hover text-text" : "text-text-faint hover:text-text")}>
|
||||
{h === 24 ? "24h" : h === 168 ? "7d" : "30d"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-6 h-56 rounded-[10px] border border-border bg-surface p-3">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={chart} margin={{ top: 4, right: 4, bottom: 0, left: -18 }}>
|
||||
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" vertical={false} />
|
||||
<XAxis dataKey="t" tick={{ fontSize: 10, fill: "var(--text-faint)" }} tickLine={false} axisLine={false} minTickGap={40} />
|
||||
<YAxis tick={{ fontSize: 10, fill: "var(--text-faint)" }} tickLine={false} axisLine={false} unit=" ms" width={62} />
|
||||
<Tooltip
|
||||
contentStyle={{ background: "var(--surface)", border: "1px solid var(--border)", borderRadius: 8, fontSize: 11 }}
|
||||
labelStyle={{ color: "var(--text-faint)" }}
|
||||
/>
|
||||
<Area type="monotone" dataKey="ping" stroke="var(--up)" fill="var(--up)" fillOpacity={0.12} strokeWidth={1.5} dot={false} isAnimationActive={false} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
<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>
|
||||
|
||||
<h2 className="mb-2 font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">Recent checks</h2>
|
||||
<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)}
|
||||
className={cn("rounded-[6px] px-2 py-1 font-mono text-[10.5px]",
|
||||
hours === h ? "bg-surface-hover text-text" : "text-text-faint hover:text-text")}>
|
||||
{h === 24 ? "24h" : h === 168 ? "7d" : "30d"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<MetricChart
|
||||
rows={rows}
|
||||
series={[{ key: "ping", label: "ms", color: chartColor(0) }]}
|
||||
fmt={(v) => `${Math.round(v)} ms`}
|
||||
spanMs={hours * 3600_000}
|
||||
/>
|
||||
</ChartCard>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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/<slug></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/<slug></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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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" }),
|
||||
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 };
|
||||
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,
|
||||
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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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 |
@@ -1,5 +1,8 @@
|
||||
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
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
|
After Width: | Height: | Size: 193 KiB |
@@ -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 \
|
||||
|
||||
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 72 KiB |