26 Commits
Author SHA1 Message Date
Tomáš Dvořák 9b79bce174 chore: add FUNDING.yml — GitHub Sponsors button
ci / backend (push) Canceled after 0s
ci / frontend (push) Canceled after 0s
2026-10-04 19:50:29 +02:00
Tomáš Dvořák 1e4e39f001 Merge pull request #14 from Dvorinka/feat/external-monitoring
ci / backend (push) Canceled after 0s
ci / frontend (push) Canceled after 0s
feat(infra): external monitoring stack (Beszel + Prometheus/Grafana)
2026-10-03 10:38:50 +02:00
Tomas Dvorak 4557af6c93 Merge remote-tracking branch 'origin/main' into feat/external-monitoring
# Conflicts:
#	CHANGELOG.md
2026-10-03 10:38:47 +02:00
Tomáš Dvořák a59d2eb558 Merge pull request #13 from Dvorinka/feat/casaos-compose
feat(infra): CasaOS compose with persistent /DATA bind
2026-10-03 10:38:29 +02:00
Tomas DvorakandDevin f2aa9ceba8 feat(infra): external monitoring stack (Beszel + Prometheus/Grafana)
Homelab monitoring design (docs/specs/2026-10-03-external-monitoring.md):
Beszel agents own the two physical hosts, prometheus-pve-exporter covers
every LXC/VM via the PVE API with no guest agents, and Grafana supplies
graphs. Dash needs zero code changes — JSON-path widgets already carry an
Authorization header and can walk Prometheus/Beszel JSON, embed widgets
iframe Grafana panels, and monitors + notification transports provide
the alerting layer for unmanaged hardware like the CM834 switch.

infra/monitoring/ ships compose.yaml, prometheus.yml, pve.yml.example
(gitignored real file), and Grafana datasource provisioning.
docs/monitoring.md documents deployment and widget recipes.

Authored By: TDvorak <[email protected]>

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-03 10:36:27 +02:00
Tomas DvorakandDevin a94ae1889a feat(infra): add CasaOS compose with persistent /DATA bind
CasaOS copies imported compose files to /tmp/casaos-compose-app-* and
runs them from there, so the repo compose's relative ./data bind fails
at install ("bind source path does not exist") and would be wiped on
reboot. infra/compose.casaos.yaml mounts /DATA/AppData/dash instead and
carries x-casaos metadata for the app tile.

Authored By: TDvorak <[email protected]>

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

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

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

Authored By: TDvorak <[email protected]>

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

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

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

Authored By: TDvorak <[email protected]>

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

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

Authored By: TDvorak <[email protected]>

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

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

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

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

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

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

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

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

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

Authored By: TDvorak <[email protected]>

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

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

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

Authored By: TDvorak <[email protected]>

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

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

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

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

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

Authored By: TDvorak <[email protected]>

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

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

Authored By: TDvorak <[email protected]>

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

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

Authored By: TDvorak <[email protected]>

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-22 13:10:29 +02:00
68 changed files with 5338 additions and 347 deletions

No files matched your search

+1
View File
@@ -0,0 +1 @@
github: Dvorinka
+77 -4
View File
@@ -1,20 +1,89 @@
name: release
# Releases are cut automatically from main: after the ci workflow passes on a
# main push, the version job bumps semver from conventional commit subjects
# (BREAKING/! -> major, feat -> minor, otherwise patch), pushes the tag, and
# the build jobs release it. Manual tags are not used; workflow_dispatch
# re-runs the same flow on demand.
on:
push:
tags: ["v*"]
workflow_run:
workflows: [ci]
types: [completed]
branches: [main]
workflow_dispatch:
permissions:
contents: write
packages: write
# Two merges racing would compute the same tag; serialize instead.
concurrency:
group: release
cancel-in-progress: false
jobs:
version:
runs-on: ubuntu-latest
if: github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success'
outputs:
version: ${{ steps.v.outputs.version }}
semver: ${{ steps.v.outputs.semver }}
major_minor: ${{ steps.v.outputs.major_minor }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
- id: v
run: |
set -euo pipefail
latest="$(git tag -l 'v*' --sort=-v:refname | head -n1)"
if [ -z "$latest" ]; then
version="v0.1.0"; bump="minor"
else
subjects="$(git log "$latest"..HEAD --format=%s)"
if grep -qiE 'BREAKING[ -]CHANGE|^[a-z]+(\([^)]*\))?!:' <<< "$subjects"; then
bump="major"
elif grep -qE '^feat(\([^)]*\))?:' <<< "$subjects"; then
bump="minor"
else
bump="patch"
fi
IFS=. read -r major minor patch <<< "${latest#v}"
case "$bump" in
major) major=$((major+1)); minor=0; patch=0 ;;
minor) minor=$((minor+1)); patch=0 ;;
*) patch=$((patch+1)) ;;
esac
version="v${major}.${minor}.${patch}"
fi
semver="${version#v}"
IFS=. read -r mj mn _ <<< "$semver"
{
echo "version=$version"
echo "semver=$semver"
echo "major_minor=$mj.$mn"
} >> "$GITHUB_OUTPUT"
echo "latest=${latest:-none} bump=$bump -> $version"
- name: Push tag
env:
VERSION: ${{ steps.v.outputs.version }}
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git tag "$VERSION"
git push origin "$VERSION"
binaries:
needs: version
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # goreleaser needs full history for tag + changelog
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
- uses: actions/setup-node@v4
with:
@@ -40,11 +109,15 @@ jobs:
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GORELEASER_CURRENT_TAG: ${{ needs.version.outputs.version }}
docker:
needs: version
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
- uses: docker/setup-buildx-action@v3
@@ -59,8 +132,8 @@ jobs:
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=${{ needs.version.outputs.semver }}
type=raw,value=${{ needs.version.outputs.major_minor }}
type=raw,value=latest
- uses: docker/build-push-action@v6
+6
View File
@@ -15,12 +15,18 @@ data/*
# env / secrets
.env
.env.*
infra/monitoring/pve.yml
# misc
.DS_Store
coverage/
dist/
# the blanket dist/ above excludes the embedded-UI dir itself, which would
# make the .keep negation unreachable — re-include the dir, contents stay ignored
!apps/backend/internal/web/dist/
# local tooling artifacts
.playwright-mcp/
.playwright-cli/
.vercel
.env*
+4 -1
View File
@@ -1,13 +1,16 @@
# Dash
Self-hosted homelab dashboard. Single Go binary serving an embedded React UI.
No auth, no YAML. See `ROADMAP.md` (canonical) and `docs/specs/` for design.
Optional auth (off by default), no YAML. See `ROADMAP.md` (canonical) and
`docs/specs/` for design.
## Layout
- `apps/frontend` — React 18 + Vite + TS strict + Tailwind v4 + shadcn/ui
- `apps/backend` — Go + Gin + zap, CGO-free, SQLite via `modernc.org/sqlite`
- `packages/api-client` — generated TS client; `openapi.yaml` at repo root is the contract
- `landing/` — Next.js 15 marketing site (own package.json, not a workspace);
Vercel project `dash`, root dir `landing`, git-connected to this repo
- `infra/` — Dockerfile (multi-stage, one image)
- `tools/oxlint/anti-slop` — vendored lint plugin (do not extend casually)
- `data/` — runtime SQLite + uploaded icons (gitignored)
+64 -1
View File
@@ -6,6 +6,67 @@ All notable changes to Dash. Format follows
## [Unreleased]
### Added
- External monitoring stack `infra/monitoring/` (Prometheus + Grafana +
prometheus-pve-exporter + Beszel hub) with a `docs/monitoring.md` guide:
Beszel agents cover the physical hosts, pve-exporter covers all LXCs/VMs
via the PVE API, and existing JSON-path/embed widgets plus monitors
consume the data — no app code required
- CasaOS deployment file `infra/compose.casaos.yaml` with app-store
(`x-casaos`) metadata and a persistent `/DATA/AppData/dash` bind mount;
the repo compose's relative `./data` fails under CasaOS, which runs
compose from `/tmp` and loses it on reboot
## [2.1.0] — 2026-09-23
### Added
- Domain detail now identifies vendors per record: A/AAAA/CNAME/NS/MX/TXT
rows carry provider badges (Cloudflare, Google, Purelymail, …) with favicon
icons, plus a five-role provider strip (registrar, DNS, hosting, email, CA)
above the detail sections. Detection covers ~70 NS providers, ~45 mail
providers, ~60 CNAME targets, ASN/org matching, response-header signals,
and TXT verification/SPF attribution
### Changed
- Settings dialog regrouped into icon-labelled sections (renderer, appearance,
wallpaper/CSS, notifications, auth, backup); renderer tiles now show a
per-style glyph and the theme picker is a segmented control
- Monitoring charts restyled to the Beszel school: titled chart cards with
subtitles, soft area fills on a true time-scaled axis, unit-aware Y ticks,
sorted dotted tooltips, and legend chips on multi-series charts
- System detail gains stacked per-container Docker CPU/memory charts,
per-sensor temperature lines, load 1/5/15, and GPU usage/VRAM charts —
all from data the agent already sends
## [2.0.0] — 2026-09-22
### Added
- Opt-in local auth: first-user setup, bcrypt passwords, SQLite sessions,
rotating 7-day HttpOnly cookie, login rate limit, sign-out/disable in
settings. Off by default; status pages, badges, metrics, push, and agent
ingest stay public
- Multiple boards: named boards with slugs, header switcher, `/b/:slug`
routes, per-board sections, board-aware export
- Full i18n: every UI string via typed `t()` keys with interpolation;
English + Czech locales, language select in settings
- Uptime bar strip + latency chart on monitor detail
- iframe embed widget and generic JSON-path widget (backend fetcher)
- Custom accent color, wallpaper URL, and custom CSS in settings
- PWA: manifest, icons, minimal service worker
- Alert transports: webhook, Slack, Discord, Telegram, Gotify, ntfy, SMTP
(stdlib `net/smtp`); transport picker + test button in settings
- Per-monitor alert rules: consecutive-failure threshold, latency warn, mute;
per-domain cert-days threshold and mute
- Per-container CPU/memory in `dash-agent` (Docker stats when socket present);
optional SMART, ZFS, and GPU collectors (exec-or-sysfs, skipped when absent)
- Subdomain discovery via Certificate Transparency + DNS, daily sweep,
subdomains section on domain detail
## [0.7.0] — 2026-09-21
### Added
@@ -77,7 +138,9 @@ All notable changes to Dash. Format follows
- Single Go binary serving the embedded React UI; SQLite via goose migrations;
Docker image on distroless
[Unreleased]: https://github.com/Dvorinka/Dash/compare/v0.7.0...HEAD
[Unreleased]: https://github.com/Dvorinka/Dash/compare/v2.1.0...HEAD
[2.1.0]: https://github.com/Dvorinka/Dash/compare/v2.0.0...v2.1.0
[2.0.0]: https://github.com/Dvorinka/Dash/compare/v0.7.0...v2.0.0
[0.7.0]: https://github.com/Dvorinka/Dash/compare/v0.6.0...v0.7.0
[0.6.0]: https://github.com/Dvorinka/Dash/compare/v0.5.0...v0.6.0
[0.5.0]: https://github.com/Dvorinka/Dash/compare/v0.4.0...v0.5.0
+2 -2
View File
@@ -28,8 +28,8 @@ DASH_DEV=1 go run ./cmd/dash # API on :8080
and `-- +goose Down`.
- Board renderers share state, drag-drop, and popover primitives. Never
reimplement them per renderer.
- No auth, no YAML-as-source-of-truth, no external database. These are
non-goals for v1.
- Auth is opt-in and local-only: no OIDC/SSO, no multi-user RBAC. No
YAML-as-source-of-truth, no external database. These are non-goals.
- Standard library before dependencies; existing helpers before new ones.
- User-visible strings go through `t("key")` from `src/i18n` — add the key to
`en.ts` rather than inlining literals in new UI.
+105 -57
View File
@@ -1,53 +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. Optional auth.
<h1 align="center">Dash</h1>
![Dash demo](docs/demo.gif)
<p align="center">
Self-hosted homelab dashboard.<br>
Service boards, uptime monitors, domain watch, and system metrics in one container.
</p>
<p align="center">
<a href="#quick-start">Quick Start</a> ·
<a href="docs/installation.md">Documentation</a> ·
<a href="https://github.com/Dvorinka/Dash/releases">Releases</a> ·
<a href="CONTRIBUTING.md">Contributing</a>
</p>
<p align="center">
<a href="https://github.com/Dvorinka/Dash/actions/workflows/ci.yml"><img src="https://github.com/Dvorinka/Dash/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
<a href="https://github.com/Dvorinka/Dash/releases"><img src="https://img.shields.io/github/v/release/Dvorinka/Dash" alt="Release"></a>
<a href="LICENSE"><img src="https://img.shields.io/github/license/Dvorinka/Dash" alt="License"></a>
</p>
## What is Dash?
Dash is a self-hosted homelab dashboard: service boards with drag-and-drop,
uptime monitors, domain intelligence, push-based system metrics, incidents,
and public status pages — one static Go binary serving an embedded React UI.
No YAML, no external database; auth is optional and off by default.
## Screenshots
| Board | Monitors | Systems |
|:-:|:-:|:-:|
| ![Service board](docs/screenshots/board.png) | ![Uptime monitors](docs/screenshots/monitors.png) | ![System metrics](docs/screenshots/systems.png) |
<details>
<summary>More screenshots</summary>
| Domain intel | Domains | Status page |
|:-:|:-:|:-:|
| ![Domain detail with provider detection](docs/screenshots/domain-detail.png) | ![Domain watch](docs/screenshots/domains.png) | ![Public status page](docs/screenshots/status-page.png) |
</details>
## Features
- **Service board** — 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, DNS records, TLS certificate expiry,
registrar and provider detection, CT-log subdomain discovery
- **System monitoring** — lightweight `dash-agent` pushes CPU, memory, disk,
network, load, temperatures, per-container stats, and SMART/ZFS/GPU when
available
- **Incidents & status pages** — manual and automatic incidents, 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 with bcrypt + session cookies,
off by default; public status pages stay open either way
- **i18n + PWA** — English and Czech UI, installable manifest/service worker
- **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
@@ -57,22 +110,17 @@ Monitor a Linux host: create a system in the UI, copy the token, then:
dash-agent -url http://dash:3000 -token <token>
```
One static binary, no dependencies. See
[docs/installation.md](docs/installation.md) for systemd and Docker setups.
One static binary, no dependencies. systemd unit and container image covered
in [docs/installation.md](docs/installation.md#dash-agent-system-monitoring).
## Screenshots
## Documentation
| | |
|---|---|
| ![Monitors](docs/screenshots/monitors.png) | ![Domains](docs/screenshots/domains.png) |
| ![Systems](docs/screenshots/systems.png) | ![Status page](docs/screenshots/status-page.png) |
## Docs
- [Installation](docs/installation.md)
- [Configuration](docs/configuration.md)
- [Widget development](docs/widgets.md)
- [Roadmap](ROADMAP.md) · [Changelog](CHANGELOG.md) · [Contributing](CONTRIBUTING.md)
- [docs/installation.md](docs/installation.md) — Docker, binary, agent, reverse proxy, upgrades
- [docs/configuration.md](docs/configuration.md) — env vars, notifications, auth, boards, operations
- [docs/monitoring.md](docs/monitoring.md) — external monitoring stack (Beszel + Prometheus/Grafana) and how Dash consumes it
- [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
@@ -80,21 +128,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
+16 -16
View File
@@ -149,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
+1 -1
View File
@@ -7,7 +7,7 @@
| latest release | yes |
| older releases | no |
Dash is pre-1.0; only the latest release receives fixes.
Only the latest release receives fixes.
## Reporting a vulnerability
+13 -4
View File
@@ -27,6 +27,7 @@ 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,
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.
@@ -82,6 +83,8 @@ 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"`
@@ -93,7 +96,7 @@ type domainRow struct {
func scanDomain(row interface{ Scan(...any) error }) (*domainRow, error) {
var d domainRow
var tags, statuses, ns, mx, txt, v4, v6, altNames, headers, alerts 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,
@@ -104,7 +107,7 @@ 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,
&headers, &records, &providers, &d.FaviconURL, &d.LookupError, &d.IntervalH, &d.LastChecked,
&d.Position, &alerts, &d.CreatedAt)
if err != nil {
return nil, err
@@ -124,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
}
@@ -417,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=?,
@@ -428,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,
@@ -440,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
@@ -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;
+719 -94
View File
@@ -88,9 +88,46 @@ type Result struct {
HostingProvider string `json:"hostingProvider"`
CAProvider string `json:"caProvider"`
// Structured intel — domainstack-style record list + vendor strip.
Records []DNSRecord `json:"records"`
Providers Providers `json:"providers"`
Headers map[string]string `json:"headers"`
FaviconURL string `json:"faviconUrl"`
Error string `json:"error"` // non-fatal partial failure note
mxPairs []mxPair // structured MX; MXRecords keeps the display string
}
// Provider names the vendor behind a record or role. Domain feeds the
// favicon icon rendered next to the name in the UI.
type Provider struct {
Name string `json:"name"`
Domain string `json:"domain"`
Icon string `json:"icon"`
}
// Providers is the detected-vendor strip: registrar, DNS host, hosting,
// email, and certificate authority.
type Providers struct {
Registrar *Provider `json:"registrar,omitempty"`
DNS *Provider `json:"dns,omitempty"`
Email *Provider `json:"email,omitempty"`
Hosting *Provider `json:"hosting,omitempty"`
CA *Provider `json:"ca,omitempty"`
}
// DNSRecord is one resolved record with the vendor its value points at.
type DNSRecord struct {
Type string `json:"type"` // A AAAA CNAME NS MX TXT
Value string `json:"value"`
Priority *int `json:"priority,omitempty"` // MX preference
Provider *Provider `json:"provider,omitempty"`
}
type mxPair struct {
host string
pref int
}
// Clean normalises user input to a bare registrable hostname.
@@ -125,6 +162,7 @@ func (l *Lookup) Run(ctx context.Context, rawName string) *Result {
IPv4: []string{},
IPv6: []string{},
SSLAltNames: []string{},
Records: []DNSRecord{},
Headers: map[string]string{},
FaviconURL: fmt.Sprintf("https://www.google.com/s2/favicons?domain=%s&sz=128", name),
}
@@ -155,14 +193,68 @@ func (l *Lookup) Run(ctx context.Context, rawName string) *Result {
l.host(r.IPv4[0], r)
}
l.headers(ctx, name, r)
r.DNSProvider = detectDNSProvider(r.NameServers)
r.EmailProvider = detectEmailProvider(r.MXRecords)
r.HostingProvider = detectHostingProvider(r.Headers)
r.CAProvider = detectCA(r.SSLIssuer)
l.identify(r)
return r
}
// identify resolves provider vendors after every probe has run, then builds
// the structured record list the detail page renders.
func (l *Lookup) identify(r *Result) {
r.Providers.Registrar = matchProvider(r.RegistrarName, registrarProviders)
for _, n := range r.NameServers {
if p := matchProvider(n, nsProviders); p != nil {
r.Providers.DNS = p
break
}
}
for _, m := range r.mxPairs {
if p := matchProvider(m.host, mailProviders); p != nil {
r.Providers.Email = p
break
}
}
r.Providers.Hosting = detectHostProvider(r)
r.Providers.CA = matchProvider(r.SSLIssuer, caProviders)
// Legacy flat names mirror the strip for the list page + old clients.
r.DNSProvider = nameOf(r.Providers.DNS)
r.EmailProvider = nameOf(r.Providers.Email)
r.HostingProvider = nameOf(r.Providers.Hosting)
r.CAProvider = nameOf(r.Providers.CA)
recs := make([]DNSRecord, 0,
len(r.IPv4)+len(r.IPv6)+len(r.NameServers)+len(r.mxPairs)+len(r.TXTRecords)+1)
add := func(t, v string, pri *int, p *Provider) {
recs = append(recs, DNSRecord{Type: t, Value: v, Priority: pri, Provider: p})
}
for _, ip := range r.IPv4 {
add("A", ip, nil, r.Providers.Hosting)
}
for _, ip := range r.IPv6 {
add("AAAA", ip, nil, r.Providers.Hosting)
}
if r.CNAME != "" {
add("CNAME", r.CNAME, nil, matchProvider(r.CNAME, hostTargets))
}
for _, n := range r.NameServers {
add("NS", n, nil, matchProvider(n, nsProviders))
}
for _, m := range r.mxPairs {
pri := m.pref
add("MX", m.host, &pri, matchProvider(m.host, mailProviders))
}
for _, t := range r.TXTRecords {
add("TXT", t, nil, matchTXTProvider(t))
}
r.Records = recs
}
func nameOf(p *Provider) string {
if p == nil {
return ""
}
return p.Name
}
// --- DNS --------------------------------------------------------------------
func (l *Lookup) dns(ctx context.Context, name string, r *Result) {
@@ -174,7 +266,9 @@ func (l *Lookup) dns(ctx context.Context, name string, r *Result) {
}
if mx, err := res.LookupMX(ctx, name); err == nil {
for _, m := range mx {
r.MXRecords = append(r.MXRecords, fmt.Sprintf("%s (pri %d)", strings.TrimSuffix(m.Host, "."), m.Pref))
host := strings.TrimSuffix(strings.ToLower(m.Host), ".")
r.mxPairs = append(r.mxPairs, mxPair{host: host, pref: int(m.Pref)})
r.MXRecords = append(r.MXRecords, fmt.Sprintf("%s (pri %d)", host, m.Pref))
}
}
if txt, err := res.LookupTXT(ctx, name); err == nil {
@@ -297,108 +391,639 @@ func (l *Lookup) headers(ctx context.Context, name string, r *Result) {
}
// --- Provider detection (condensed port of beszel's detect package) -----------
//
// Tables map a substring of the record value / org / issuer to a vendor name
// plus the brand domain used for the favicon icon. Order matters — first
// match wins, so specific substrings precede generic ones.
func detectDNSProvider(ns []string) string {
table := []struct{ sub, name string }{
{"cloudflare", "Cloudflare"}, {"awsdns", "Amazon Route 53"},
{"googledomains", "Google Domains"}, {"google.com", "Google Domains"},
{"namecheap", "Namecheap"}, {"godaddy", "GoDaddy"}, {"domaincontrol", "GoDaddy"},
{"nsone.net", "NS1"}, {"digitalocean", "DigitalOcean"}, {"linode", "Linode"},
{"vultr", "Vultr"}, {"he.net", "Hurricane Electric"}, {"dynect", "Dyn (Oracle)"},
{"ultradns", "UltraDNS"}, {"dnsimple", "DNSimple"}, {"hover", "Hover"},
{"enom", "eNom"}, {"worldnic", "Network Solutions"}, {"zoneedit", "ZoneEdit"},
{"easydns", "EasyDNS"}, {"gandi", "Gandi"}, {"ovh", "OVH"},
{"hetzner", "Hetzner"}, {"azure-dns", "Microsoft Azure"},
{"dns.google", "Google Cloud DNS"}, {"porkbun", "Porkbun"},
{"cloudns", "ClouDNS"}, {"desec", "deSEC"}, {"nic.cz", "CZ.NIC"},
}
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)
func matchProvider(haystack string, table []provMatch) *Provider {
h := strings.ToLower(haystack)
for _, t := range table {
if strings.Contains(m, t.sub) {
return t.name
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)
}
View File
Whitespace-only changes.
+79 -49
View File
@@ -1,8 +1,8 @@
import { useEffect, useRef, useState } from "react";
import { Bell, Download, Lock, 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";
@@ -44,6 +44,19 @@ const TRANSPORTS = {
} 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);
@@ -143,50 +156,70 @@ export function SettingsDialog({ open, onOpenChange }: { open: boolean; onOpenCh
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-sm">
<DialogContent className="max-w-xl">
<DialogHeader>
<DialogTitle>{t("settings.title")}</DialogTitle>
<DialogDescription>{t("settings.desc")}</DialogDescription>
</DialogHeader>
<div className="flex max-h-[70vh] flex-col gap-4 overflow-y-auto pr-1">
<div className="flex flex-col gap-1.5">
<Label>{t("settings.renderer")}</Label>
<div className="grid grid-cols-2 gap-1.5" role="radiogroup" aria-label={t("settings.renderer")}>
{rendererList.map((r) => (
<div className="flex max-h-[72vh] flex-col gap-5 overflow-y-auto pr-1">
<Group icon={LayoutGrid} title={t("settings.renderer")}>
<div className="grid grid-cols-2 gap-2" role="radiogroup" aria-label={t("settings.renderer")}>
{rendererList.map((r) => {
const Icon = rendererIcons[r];
const active = board.renderer === r;
return (
<button
key={r}
type="button"
role="radio"
aria-checked={board.renderer === r}
aria-checked={active}
onClick={() => void board.setSetting("renderer", r)}
className={cn(
"rounded-[8px] border px-3 py-2.5 text-left transition-colors",
board.renderer === r
? "border-border-strong bg-surface-hover"
"flex items-center gap-3 rounded-[9px] border px-3 py-2.5 text-left transition-colors",
active
? "border-accent bg-accent/10"
: "border-border hover:border-border-strong hover:bg-surface-hover",
)}
>
<span className={cn(
"flex size-8 shrink-0 items-center justify-center rounded-[7px] border",
active ? "border-accent/40 bg-accent/15 text-accent" : "border-border bg-bg text-text-faint",
)}>
<Icon size={15} />
</span>
<span className="min-w-0">
<span className="block text-[12.5px] font-medium">{rendererLabels[r]}</span>
<span className="block font-mono text-[10px] text-text-faint">{rendererDescriptions[r]}</span>
<span className="block truncate font-mono text-[10px] text-text-faint">{rendererDescriptions[r]}</span>
</span>
</button>
);
})}
</div>
</Group>
<Group icon={Palette} title={t("settings.appearance")}>
<div className="grid grid-cols-2 gap-3 max-[560px]:grid-cols-1">
<div className="flex flex-col gap-1.5">
<Label>{t("settings.theme")}</Label>
<div className="flex overflow-hidden rounded-[7px] border border-border">
{(["dark", "light"] as const).map((th) => (
<button
key={th}
type="button"
aria-pressed={board.theme === th}
onClick={() => void board.setSetting("theme", th)}
className={cn(
"flex flex-1 items-center justify-center gap-1.5 px-3 py-1.5 text-[12px] transition-colors",
board.theme === th ? "bg-surface-hover text-text" : "text-text-faint hover:text-text",
)}
>
{th === "dark" ? <Moon size={12} /> : <Sun size={12} />}
{th === "dark" ? t("settings.dark") : t("settings.light")}
</button>
))}
</div>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t("settings.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">{t("settings.dark")}</SelectItem>
<SelectItem value="light">{t("settings.light")}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t("settings.language")}</Label>
<Select
@@ -201,24 +234,25 @@ export function SettingsDialog({ open, onOpenChange }: { open: boolean; onOpenCh
</SelectContent>
</Select>
</div>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="accent">{t("settings.accent")}</Label>
<div className="flex gap-2">
<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="wallpaper">{t("settings.wallpaper")}</Label>
<Group icon={Image} title={t("settings.wallpaper")}>
<Input
id="wallpaper"
value={board.wallpaper}
@@ -226,22 +260,19 @@ export function SettingsDialog({ open, onOpenChange }: { open: boolean; onOpenCh
placeholder={t("settings.wallpaperPh")}
className="font-mono text-[12px]"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="custom-css">{t("settings.customCss")}</Label>
<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"
/>
</div>
<p className="text-[11px] text-text-faint">{t("settings.customCssHint")}</p>
</Group>
<div className="flex flex-col gap-1.5">
<Label>{t("settings.notifications")}</Label>
<Group icon={Bell} title={t("settings.notifications")}>
<div className="flex gap-2">
<Select value={transport} onValueChange={(v) => {
// SAFETY: Select only yields TRANSPORTS keys.
@@ -281,13 +312,13 @@ export function SettingsDialog({ open, onOpenChange }: { open: boolean; onOpenCh
{t("settings.notifyFanout")}
</p>
{notifyMsg ? <p className="text-[11px] text-text-faint">{notifyMsg}</p> : null}
</div>
</Group>
<div className="flex flex-col gap-2.5 rounded-lg border border-border p-3">
<div className="flex items-center justify-between">
<Label className="flex items-center gap-1.5">
<Lock size={12} /> {t("auth.section")}
</Label>
<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()}>
@@ -303,12 +334,10 @@ export function SettingsDialog({ open, onOpenChange }: { open: boolean; onOpenCh
</Button>
)}
</div>
<p className="text-[11px] text-text-faint">
{auth.enabled ? `${t("auth.onHint")} ${auth.username ?? ""}` : t("auth.offHint")}
</p>
</div>
</Group>
<div className="flex gap-2 pt-1">
<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>
@@ -326,6 +355,7 @@ export function SettingsDialog({ open, onOpenChange }: { open: boolean; onOpenCh
</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>
+235
View File
@@ -0,0 +1,235 @@
// Shared time-series chart primitives — the Beszel-grade look on our tokens:
// titled cards with a subtitle, soft area fills over a real time axis,
// unit-aware Y ticks, a dotted tooltip sorted by value, and legend chips.
import type { ReactNode } from "react";
import {
Area,
CartesianGrid,
ComposedChart,
Line,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { t } from "@/i18n";
/** One chart row: epoch ms plus named series values. */
export type ChartRow = { t: number } & Record<string, number | undefined>;
export interface Series {
key: string;
label: string;
color: string;
/** Shared value → stacked areas (container charts). */
stack?: string;
/** Render as a bare line, no fill (temperatures). */
line?: boolean;
}
export const CHART_COLORS = [
"var(--chart-1)",
"var(--chart-2)",
"var(--chart-3)",
"var(--chart-4)",
"var(--chart-5)",
"var(--chart-6)",
"var(--chart-7)",
"var(--chart-8)",
];
export const chartColor = (i: number) => CHART_COLORS[i % CHART_COLORS.length] ?? "var(--accent)";
const axisTick = { fontSize: 10, fill: "var(--text-faint)" };
/** Auto Y-domain with headroom, rounding to a readable ceiling. */
export function niceMax(dataMax: number): number {
if (!Number.isFinite(dataMax) || dataMax <= 0) return 1;
if (dataMax > 10) return Math.ceil(dataMax * 1.05);
if (dataMax > 1) return Math.ceil(dataMax * 10.5) / 10;
return Math.ceil(dataMax * 105) / 100;
}
/** Tick format by range: HH:mm up to 2 days, day+time to 2 weeks, then dates. */
export function xTick(spanMs: number): (v: number) => string {
if (spanMs <= 48 * 3600_000)
return (v) => new Date(v).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
if (spanMs <= 14 * 86400_000)
return (v) =>
new Date(v).toLocaleString([], { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" });
return (v) => new Date(v).toLocaleDateString([], { month: "short", day: "numeric" });
}
const fullTime = (ms: number) =>
new Date(ms).toLocaleString([], {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
interface TipItem {
name?: string;
value?: number | string;
color?: string;
stroke?: string;
dataKey?: string | number;
payload?: ChartRow;
}
/** Dotted tooltip: timestamp header, per-series rows sorted by value desc. */
function ChartTip({
active,
payload,
fmt,
}: {
active?: boolean;
payload?: TipItem[];
fmt: (v: number) => string;
}) {
if (!active || !payload?.length) return null;
const t0 = payload[0]?.payload?.t;
const rows = payload
.filter((p): p is TipItem & { value: number } => typeof p.value === "number")
.sort((a, b) => b.value - a.value);
if (!rows.length) return null;
return (
<div className="min-w-28 rounded-lg border border-border-strong bg-popover px-2.5 py-1.5 text-[11px] shadow-xl">
{t0 !== undefined && <div className="mb-1 font-medium text-text-dim">{fullTime(t0)}</div>}
<div className="grid gap-1">
{rows.map((p) => (
<div key={String(p.dataKey ?? p.name)} className="flex items-center gap-2">
<span
className="size-2 shrink-0 rounded-[2px]"
style={{ background: p.color ?? p.stroke ?? "var(--text-faint)" }}
/>
<span className="flex-1 truncate text-text-faint">{p.name}</span>
<span className="font-mono font-medium">{fmt(p.value)}</span>
</div>
))}
</div>
</div>
);
}
/** Area/line chart on a time-scaled X axis. Empty data renders a hint, not a blank box. */
export function MetricChart({
rows,
series,
fmt,
domain,
spanMs,
}: {
rows: ChartRow[];
series: Series[];
fmt: (v: number) => string;
domain?: [number, number] | undefined;
spanMs: number;
}) {
if (!rows.length) {
return (
<div className="flex h-full items-center justify-center text-[11px] text-text-faint">
{t("chart.noData")}
</div>
);
}
return (
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={rows} margin={{ top: 4, right: 8, bottom: 0, left: 0 }}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" vertical={false} />
<XAxis
dataKey="t"
type="number"
scale="time"
domain={["dataMin", "dataMax"]}
tick={axisTick}
tickLine={false}
axisLine={false}
tickFormatter={xTick(spanMs)}
minTickGap={28}
/>
<YAxis
tick={axisTick}
tickLine={false}
axisLine={false}
width="auto"
domain={domain ?? [0, niceMax]}
tickFormatter={(v) => fmt(Number(v))}
/>
<Tooltip content={<ChartTip fmt={fmt} />} cursor={{ stroke: "var(--border-strong)" }} />
{series.map((s) =>
s.line ? (
<Line
key={s.key}
dataKey={s.key}
name={s.label}
type="monotone"
stroke={s.color}
strokeWidth={1.5}
dot={false}
connectNulls
isAnimationActive={false}
/>
) : (
<Area
key={s.key}
dataKey={s.key}
name={s.label}
type="monotone"
stroke={s.color}
fill={s.color}
fillOpacity={s.stack ? 0.65 : 0.14}
strokeWidth={1.5}
dot={false}
{...(s.stack !== undefined ? { stackId: s.stack } : {})}
isAnimationActive={false}
/>
),
)}
</ComposedChart>
</ResponsiveContainer>
);
}
/** Titled chart card in the Beszel layout: title + subtitle on top, legend chips below. */
export function ChartCard({
title,
hint,
actions,
series,
height = 160,
children,
}: {
title: string;
hint?: string;
actions?: ReactNode;
series?: Series[];
height?: number;
children: ReactNode;
}) {
return (
<div className="rounded-[10px] border border-border bg-surface p-3 pt-3.5">
<div className="mb-2 flex items-start justify-between gap-2 px-1">
<div>
<h3 className="text-[13px] font-medium tracking-tight">{title}</h3>
{hint ? <p className="text-[10.5px] text-text-faint">{hint}</p> : null}
</div>
{actions}
</div>
<div style={{ height }}>{children}</div>
{series && series.length > 1 && (
<div className="mt-2 flex flex-wrap gap-x-3.5 gap-y-1 px-1">
{series.map((s) => (
<span
key={s.key}
className="flex items-center gap-1.5 font-mono text-[10px] text-text-faint"
>
<span className="size-2 rounded-[2px]" style={{ background: s.color }} />
{s.label}
</span>
))}
</div>
)}
</div>
);
}
+26 -4
View File
@@ -109,6 +109,7 @@ export const cs: Partial<Record<Key, string>> = {
"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",
@@ -323,10 +324,29 @@ export const cs: Partial<Record<Key, string>> = {
"systems.addDesc": "Vytvoří bearer token — spusťte s ním dash-agent.",
"systems.notFound": "Systém nenalezen.",
"systems.usage": "Využití",
"systems.cpuPct": "CPU %",
"systems.memPct": "Paměť %",
"systems.netKbs": "Síť KB/s",
"systems.load1m": "Load 1m",
"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",
@@ -335,6 +355,8 @@ export const cs: Partial<Record<Key, string>> = {
"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",
+26 -4
View File
@@ -108,6 +108,7 @@ export const en = {
"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",
@@ -322,10 +323,29 @@ export const en = {
"systems.addDesc": "Creates a bearer token — run dash-agent with it.",
"systems.notFound": "System not found.",
"systems.usage": "Usage",
"systems.cpuPct": "CPU %",
"systems.memPct": "Memory %",
"systems.netKbs": "Network KB/s",
"systems.load1m": "Load 1m",
"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",
@@ -334,6 +354,8 @@ export const en = {
"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",
+20
View File
@@ -25,6 +25,16 @@
--icon-bg: #1c1c1e;
--popover: #141414;
/* chart series palette (monitoring graphs) */
--chart-1: #3b82f6;
--chart-2: #10b981;
--chart-3: #8b5cf6;
--chart-4: #f59e0b;
--chart-5: #22d3ee;
--chart-6: #fb7185;
--chart-7: #a3e635;
--chart-8: #c084fc;
--background: var(--bg);
--foreground: var(--text);
--card: var(--surface);
@@ -57,6 +67,16 @@
--icon-bg: #f4f4f5;
--popover: #ffffff;
/* chart series palette (monitoring graphs) — darker for light bg */
--chart-1: #2563eb;
--chart-2: #059669;
--chart-3: #7c3aed;
--chart-4: #d97706;
--chart-5: #0891b2;
--chart-6: #e11d48;
--chart-7: #65a30d;
--chart-8: #9333ea;
--background: var(--bg);
--foreground: var(--text);
--card: var(--surface);
+82 -16
View File
@@ -1,8 +1,8 @@
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, Subdomain } 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";
@@ -32,6 +32,61 @@ 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[]>([]);
@@ -133,13 +188,26 @@ export function DomainDetailPage({ id }: { id: string }) {
<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">{t("domains.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">
{/* 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)} />
@@ -153,19 +221,17 @@ export function DomainDetailPage({ id }: { id: string }) {
</Section>
<Section title={t("domains.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={t("domains.dnsProvider")} v={d.dnsProvider} /> : null}
{d.emailProvider ? <Row k={t("domains.email")} v={d.emailProvider} /> : null}
{[...(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={t("domains.tlsCert")}>
<Row k={t("domains.issuer")} v={d.sslIssuer} />
<Row k="CA" v={d.caProvider} />
<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 />
@@ -181,7 +247,7 @@ export function DomainDetailPage({ id }: { id: string }) {
<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={t("domains.hosting")} v={d.hostingProvider} />
<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>
+17 -19
View File
@@ -1,8 +1,8 @@
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";
@@ -32,10 +32,9 @@ export function MonitorDetailPage({ id }: { id: string }) {
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 (
@@ -94,8 +93,12 @@ export function MonitorDetailPage({ id }: { id: string }) {
<UptimeBar hbs={hbs} hours={hours} />
</div>
<div className="mb-2 flex items-center justify-between">
<h2 className="font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">{t("monitors.responseTime")}</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)}
@@ -105,20 +108,15 @@ export function MonitorDetailPage({ id }: { id: string }) {
</button>
))}
</div>
</div>
<div className="mb-6 h-56 rounded-[10px] border border-border bg-surface p-3">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={chart} margin={{ top: 4, right: 4, bottom: 0, left: -18 }}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="t" tick={{ fontSize: 10, fill: "var(--text-faint)" }} tickLine={false} axisLine={false} minTickGap={40} />
<YAxis tick={{ fontSize: 10, fill: "var(--text-faint)" }} tickLine={false} axisLine={false} unit=" ms" width={62} />
<Tooltip
contentStyle={{ background: "var(--surface)", border: "1px solid var(--border)", borderRadius: 8, fontSize: 11 }}
labelStyle={{ color: "var(--text-faint)" }}
}
>
<MetricChart
rows={rows}
series={[{ key: "ping", label: "ms", color: chartColor(0) }]}
fmt={(v) => `${Math.round(v)} ms`}
spanMs={hours * 3600_000}
/>
<Area type="monotone" dataKey="ping" stroke="var(--up)" fill="var(--up)" fillOpacity={0.12} strokeWidth={1.5} dot={false} isAnimationActive={false} />
</AreaChart>
</ResponsiveContainer>
</ChartCard>
</div>
<h2 className="mb-2 font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">{t("monitors.recentChecks")}</h2>
+107 -59
View File
@@ -1,8 +1,8 @@
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";
@@ -40,16 +40,73 @@ export function SystemDetailPage({ id }: { id: string }) {
if (!sys) return <main className="mx-auto max-w-5xl px-7 py-8 text-[13px] text-text-faint">{t("common.loading")}</main>;
const l = sys.latest;
const chart = stats.map((s) => ({
t: new Date(s.ts ?? "").toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }),
const spanMs = hours * 3600_000;
// Rows carry fixed keys plus dynamic `c:`/`m:` (containers), `s:` (sensors),
// `g:`/`v:` (GPU util/VRAM) keys; series pick them up by name.
const rows: ChartRow[] = stats.map((s) => {
const r: ChartRow = {
t: new Date(s.ts ?? "").getTime(),
cpu: s.cpu,
memPct: s.memTotal ? ((s.memUsed ?? 0) / s.memTotal) * 100 : undefined,
rx: (s.netRx ?? 0) / 1024,
tx: (s.netTx ?? 0) / 1024,
load: s.load1,
}));
const axisTick = { fontSize: 10, fill: "var(--text-faint)" };
const tipStyle = { background: "var(--surface)", border: "1px solid var(--border)", borderRadius: 8, fontSize: 11 };
mem: s.memUsed,
swap: s.swapUsed,
disk: s.diskUsed,
rx: s.netRx,
tx: s.netTx,
l1: s.load1,
l5: s.load5,
l15: s.load15,
};
for (const c of s.containers ?? []) {
if (c.name) {
r[`c:${c.name}`] = c.cpu ?? 0;
r[`m:${c.name}`] = c.memUsed ?? 0;
}
}
for (const [k, v] of Object.entries(s.temps ?? {})) r[`s:${k}`] = v;
for (const g of s.gpu ?? []) {
if (g.name) {
r[`g:${g.name}`] = g.utilPct;
r[`v:${g.name}`] = g.memUsed;
}
}
return r;
});
const names = (ns: (string | undefined)[]) =>
[...new Set(ns.filter((n): n is string => n !== undefined))];
const containerNames = names(stats.flatMap((s) => (s.containers ?? []).map((c) => c.name)));
const tempNames = names(stats.flatMap((s) => Object.keys(s.temps ?? {})));
const gpuNames = names(stats.flatMap((s) => (s.gpu ?? []).map((g) => g.name)));
// Zero-fill absent containers so stacked areas stay flush over restarts.
for (const r of rows) {
for (const n of containerNames) {
r[`c:${n}`] ??= 0;
r[`m:${n}`] ??= 0;
}
}
const pct = (v: number) => `${v.toFixed(1)}%`;
const cpuSeries: Series[] = [{ key: "cpu", label: "cpu", color: chartColor(0) }];
const dockerCpuSeries: Series[] = containerNames.map((n, i) => ({ key: `c:${n}`, label: n, color: chartColor(i), stack: "a" }));
const memSeries: Series[] = [
{ key: "mem", label: t("systems.chartUsed"), color: chartColor(1) },
{ key: "swap", label: "swap", color: chartColor(3) },
];
const dockerMemSeries: Series[] = containerNames.map((n, i) => ({ key: `m:${n}`, label: n, color: chartColor(i), stack: "a" }));
const diskSeries: Series[] = [{ key: "disk", label: t("systems.chartUsed"), color: chartColor(2) }];
const netSeries: Series[] = [
{ key: "rx", label: t("systems.chartRx"), color: chartColor(4) },
{ key: "tx", label: t("systems.chartTx"), color: chartColor(5) },
];
const loadSeries: Series[] = [
{ key: "l1", label: "1 min", color: chartColor(0) },
{ key: "l5", label: "5 min", color: chartColor(3) },
{ key: "l15", label: "15 min", color: chartColor(5) },
];
const tempSeries: Series[] = tempNames.map((n, i) => ({ key: `s:${n}`, label: n, color: chartColor(i), line: true }));
const gpuSeries: Series[] = gpuNames.map((n, i) => ({ key: `g:${n}`, label: n, color: chartColor(i) }));
const vramSeries: Series[] = gpuNames.map((n, i) => ({ key: `v:${n}`, label: n, color: chartColor(i) }));
return (
<main className="mx-auto w-full max-w-5xl px-7 py-8">
@@ -111,55 +168,46 @@ 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">{t("systems.cpuPct")}</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">{t("systems.memPct")}</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">{t("systems.netKbs")}</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">{t("systems.load1m")}</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">
+4 -1
View File
@@ -15,11 +15,14 @@ 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) {
+8
View File
@@ -1,3 +1,4 @@
import { LayoutGrid, LayoutList, Newspaper, SquareTerminal, type LucideIcon } from "lucide-react";
import { bento } from "./bento";
import { cards } from "./cards";
import { editorial } from "./editorial";
@@ -27,3 +28,10 @@ export const rendererDescriptions: Record<RendererName, string> = {
index: "editorial list · masthead",
console: "terminal panels · mono",
};
export const rendererIcons: Record<RendererName, LucideIcon> = {
bento: LayoutGrid,
cards: LayoutList,
index: Newspaper,
console: SquareTerminal,
};
+3
View File
@@ -20,6 +20,9 @@ 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"];
+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" rx="104" fill="#0a0a0a"/>
<g fill="#3b82f6">
<rect x="120" y="120" width="124" height="124" rx="28"/>
<rect x="268" y="120" width="124" height="124" rx="28" fill="#6b9ef8"/>
<rect x="120" y="268" width="124" height="124" rx="28" fill="#1d4fd7"/>
<rect x="268" y="268" width="124" height="124" rx="28"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 431 B

+5
View File
@@ -1,10 +1,15 @@
services:
dash:
# Prebuilt release image — `docker compose pull && docker compose up -d`.
# To build from source instead: `docker compose up -d --build`.
image: ghcr.io/dvorinka/dash:latest
build:
context: .
dockerfile: infra/Dockerfile
ports:
- "3000:3000"
volumes:
# CasaOS: use infra/compose.casaos.yaml — CasaOS runs compose from /tmp,
# so this relative path fails there and would not survive a reboot.
- ./data:/data
restart: unless-stopped
+13 -8
View File
@@ -1,11 +1,11 @@
# v1.0.0 launch post — draft
# v2.0.0 launch post — draft
For r/selfhosted, HN Show, and similar. Edit freely before posting.
---
**Title:** Dash — a self-hosted homelab dashboard with monitoring built in.
Single Go binary, no YAML, no auth.
Single Go binary, no YAML, optional auth.
**Body:**
@@ -19,13 +19,17 @@ What it does:
upload. Imports your existing Homepage (`services.yml`), Homarr, or Dashy
config in one click.
- **Monitors** — HTTP/TCP/ping/DNS/keyword/JSON-path checks plus push monitors
for cron jobs. Webhook alerts (Slack/Discord/ntfy). Uptime history.
for cron jobs. Uptime graphs, per-monitor alert rules, and notifications via
webhook, Slack, Discord, Telegram, Gotify, ntfy, or SMTP.
- **Domains** — WHOIS/RDAP, DNS, TLS certificate expiry, provider detection.
Inspired by Domain Locker, minus the paid APIs.
- **Systems** — a ~4 MB static agent pushes CPU/mem/disk/net/temps/Docker
container states. Inspired by Beszel, push-JSON instead of SSH.
- **Systems** — a ~4 MB static agent pushes CPU/mem/disk/net/temps, per-
container stats, and SMART/ZFS/GPU when present. Inspired by Beszel,
push-JSON instead of SSH.
- **Ops** — incidents, maintenance windows, public status pages, SVG badges,
Prometheus `/api/metrics`.
- **Extras** — multiple boards, iframe/JSON-path widgets, CT-log subdomain
discovery, EN/CZ locales, installable PWA, custom CSS/wallpaper.
Deployment is one container:
@@ -33,8 +37,9 @@ Deployment is one container:
docker run -d -v ./data:/data -p 3000:3000 ghcr.io/dvorinka/dash:latest
```
SQLite inside, no database to babysit, no accounts. Deliberately no auth —
it expects a trusted LAN or your own proxy in front.
SQLite inside, no database to babysit. Auth is opt-in: local
username/password with bcrypt + session cookies, off by default; public
status pages stay open either way.
Stack: Go + Gin + SQLite (single static binary serving an embedded React UI).
MIT licensed.
@@ -53,5 +58,5 @@ Show HN: Dash – self-hosted homelab dashboard with monitoring, one Go binary
Dash combines a service board, uptime monitors, domain/SSL watching, system
metrics via a tiny push agent, and public status pages. Single container or
single binary, SQLite storage, no YAML, no auth (LAN-trust model). MIT.
single binary, SQLite storage, no YAML. Optional local auth. MIT.
https://github.com/Dvorinka/Dash
+31 -6
View File
@@ -21,12 +21,16 @@ snapshot; `/api/import/csv` bulk-loads monitors and domains from CSV.
## Notifications
**Settings → Notifications** holds one generic webhook URL
(`notify_webhook`). On monitor up/down transitions and domain events, Dash
POSTs a JSON payload with `event`, `name`, `text`, `content`, `message`, and
`at` fields — shaped so Slack, Discord, and ntfy all render it sensibly.
**Test** sends a `test` event. Delivery is async and non-fatal; failures are
logged.
**Settings → Notifications** configures any number of transports — generic
webhook, Slack, Discord, Telegram, Gotify, ntfy, and SMTP — each stored as a
`notify_<name>` settings object. Monitor up/down transitions and domain
events fan out to every configured transport; the payload carries `event`,
`name`, `text`, `content`, `message`, and `at` fields. **Test** sends a `test`
event. Delivery is async and non-fatal; failures are logged.
Per-monitor alert rules (consecutive failures before down, latency-warn ms,
mute) live on each monitor; domains get a cert-days threshold and mute.
Maintenance windows still take precedence.
## Monitors
@@ -47,6 +51,27 @@ Bearer-token push ingest at `POST /api/systems/ingest`. A system is marked
offline when no sample arrives within a few intervals. Stats are pruned during
the hourly sweep.
## Auth
Off by default — existing installs see no gate. **Settings → Authentication →
Enable** flips to a first-user setup screen (username/email + password,
bcrypt). Sessions are SQLite-backed 7-day HttpOnly cookies with rotation;
login is rate-limited per IP. Disable only works from an authenticated
session. Public status pages, badges, `/api/metrics`, `/api/push/*`, and
agent ingest stay open under auth.
## Boards
The header switcher manages multiple boards — each gets a slug and a
`/b/:slug` route with its own sections/items. Monitors, domains, systems, and
status pages stay global. Exports carry all boards.
## Appearance & language
Theme (dark/light), accent color, wallpaper URL, and custom CSS in Settings;
the language picker switches the UI between English and Czech. A PWA manifest
and service worker make the app installable.
## Operations
- **Incidents** — open/ack/resolve/close, with per-incident update timeline.
+23 -1
View File
@@ -3,7 +3,16 @@
Dash ships as a single container image or a single static binary. All state
lives in one directory: a SQLite database and uploaded icons.
## Docker (recommended)
## One-line install
```sh
curl -fsSL https://raw.githubusercontent.com/Dvorinka/Dash/main/install.sh | sh
```
Creates `./dash`, pulls the image, and starts on port 3000. Override with
`DASH_DIR` / `DASH_PORT` environment variables.
## Docker (manual)
```sh
docker run -d --name dash \
@@ -34,6 +43,19 @@ To build the image locally instead of pulling:
docker compose up --build
```
## CasaOS
Do not paste `docker-compose.yml` into CasaOS's custom install. CasaOS copies
imported compose files to `/tmp/casaos-compose-app-*` and runs them there, so
the relative `./data` bind resolves under `/tmp` — install fails with
`bind source path does not exist`, and anything written would be wiped on
reboot anyway.
Use `infra/compose.casaos.yaml` instead (Apps → Custom Install → Import). It
mounts `/DATA/AppData/dash` on the host — persistent across reboots and
browsable in the CasaOS Files app — and carries the `x-casaos` metadata the
app tile uses.
## Binary
Release archives contain a static `dash` binary (no CGO, no dependencies).
+119
View File
@@ -0,0 +1,119 @@
# Monitoring your infrastructure
Dash displays and alerts; it does not collect metrics beyond what
`dash-agent` pushes. For real infrastructure monitoring, run the reference
stack in [`infra/monitoring/`](../infra/monitoring/) next to Dash — it works
on any host. A small Debian LXC on the Proxmox box is a good home.
## What watches what
| Layer | Tool | Covers |
|---|---|---|
| Physical hosts | Beszel hub + agents | Proxmox host, Windows 11 box — CPU/mem/disk/net/temps |
| Cluster + apps | Prometheus + pve-exporter | Every LXC/VM via the PVE API (no guest agents), Dash's own `/api/metrics` |
| Dashboards | Grafana | Graphs over Prometheus, embedded into Dash via iframes |
| Up/down + alerts | Dash monitors | Router, hosts, services — with notification transports and incidents |
Two agents total — Beszel deliberately owns only the physical machines; the
PVE API already knows everything about the guests.
## 1. Deploy the stack
```sh
cd infra/monitoring
cp pve.yml.example pve.yml # fill in the PVE API token (see below)
# edit prometheus.yml: replace PVE_HOST and DASH_HOST with real addresses
docker compose up -d
```
UIs: Prometheus `:9090`, Grafana `:3001`, Beszel `:8090`, pve-exporter `:9221`.
### Proxmox API token (for pve-exporter)
On the PVE host:
```sh
pveum user add dashmon@pve --comment "read-only monitoring"
pveum aclmod / -user dashmon@pve -role PVEAuditor
```
Then **Datacenter → Permissions → API Tokens → Add** a token named `dash`
for `dashmon@pve`, uncheck *Privilege Separation*, copy the secret into
`pve.yml`. Token auth means no password in the file.
### Beszel agents (physical hosts)
1. Open Beszel `http://<hub>:8090`, create the admin account.
2. **Add System** → the hub generates the exact install command with the
key embedded — binary/systemd for the PVE host, Windows build for the
dev box (run it under NSSM or Task Scheduler for boot persistence).
3. Agents listen on `:45876`; the hub connects to them. On the same LAN
this needs no extra plumbing.
## 2. Show it in Dash — no code required
### Live values → JSON value widget
Add item → widget type **JSON value**:
**Beszel** — fields are version-dependent; browse the API to pick a path:
- URL: `http://<beszel>:8090/api/collections/systems/records`
- Header: `Authorization: Bearer <token>` — mint a superuser token/key in
the PocketBase admin at `http://<beszel>:8090/_/`
- Path: e.g. `items[0].info.cpu` (verify in the JSON response)
**Prometheus** — instant queries return JSON:
- URL: `http://<prometheus>:9090/api/v1/query?query=pve_cpu_usage_ratio`
- Path: `data.result[0].value[1]`
- Useful queries: `pve_cpu_usage_ratio`, `pve_memory_usage_ratio`,
`pve_disk_usage_ratio`, `pve_up`
### Graphs → Embed widget
Grafana dashboard → share a panel → **Embed** gives a `d-solo` URL. Paste it
into a Dash **Embed** widget (append `&kiosk` for chrome-free panels). The
shipped compose enables anonymous Viewer + embedding, so iframes render
without login on the LAN — remove the `GF_AUTH_ANONYMOUS_*` and
`GF_SECURITY_ALLOW_EMBEDDING` env lines if you'd rather require sign-in, and
use Grafana *public dashboard* links instead.
The whole Beszel UI also iframes cleanly if you want its charts in a tile.
## 3. Alert on outages → Dash monitors
The point of the exercise: when the unmanaged switch hangs, everything
*behind* it drops at once while router-side checks stay green. That
co-failure pattern — timestamped, pushed to your phone — is as close to
"the switch died" as an unmanaged box allows.
Recommended monitors:
| Target | Type | Why |
|---|---|---|
| Router / gateway IP | Ping | Upstream reference point |
| Proxmox host | Ping + HTTP `:8006` | Behind the switch |
| Each LXC's service | HTTP/TCP | Per-app liveness |
| Win11 box | TCP `:3389` (RDP) | Behind the switch |
| Prometheus/Grafana/Beszel | HTTP | Watch the watchers |
Settings → Notifications: configure ntfy/Telegram/etc. once and every
down-transition alerts you with an incident record.
**Possible switch fix, unproven:** cheap-switch hangs are often EEE
(energy-efficient ethernet) negotiation bugs. `ethtool --set-eee <nic> eee
off` on the host NICs is a reasonable mitigation. Failing that, the usual
suspects are heat and the power brick.
## 4. Files
```
infra/monitoring/compose.yaml stack definition
infra/monitoring/prometheus.yml scrape config (edit hostnames)
infra/monitoring/pve.yml.example PVE token template → copy to pve.yml
infra/monitoring/grafana/datasources.yaml Prometheus source, auto-provisioned
```
`pve.yml` holds a credential and is gitignored. Stack design rationale:
[`docs/specs/2026-10-03-external-monitoring.md`](specs/2026-10-03-external-monitoring.md).
Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 244 KiB

@@ -0,0 +1,81 @@
# External Monitoring Stack — Design
Date: 2026-10-03
Status: approved in chat 2026-10-03 — implementing
## 1. Goal
Monitor a homelab (one Proxmox host with LXCs, one always-on Windows 11 dev
box, an unmanaged Ugreen CM834 switch, a router) with the collection layer
living **outside** Dash. Dash consumes data for display and owns up/down
alerting. Trigger: an unmanaged switch hung overnight with no way to know
when or what failed.
## 2. Lane split — why two engines
Two engines were requested. They earn their place only if lanes don't
overlap:
| Tool | Lane | Scope |
|---|---|---|
| Beszel (hub + agents) | The two **physical** machines: PVE host, Win11 | Agent push, Windows support, instant host tiles, standalone alerts |
| Prometheus + `prometheus-pve-exporter` | Everything **scrapable**: all LXCs/VMs via the PVE API (no guest agents), Dash's own `/api/metrics` | System of record, PromQL |
| Grafana | Graphing front for Prometheus | Panel iframes for Dash |
Deliberately absent: `node_exporter`, `windows_exporter` — Beszel already
owns the physical hosts. Two agents total, not two per machine.
## 3. Dash integration — zero code changes
Existing primitives cover the whole display layer:
- **JSON-path widget** → Beszel PocketBase API and Prometheus
`/api/v1/query` (`data.result[0].value[1]` shape already walkable;
`header` config field carries `Authorization: Bearer …`)
- **Embed widget** → Grafana panel URLs (anonymous Viewer + embedding
enabled on the LAN) and the Beszel UI
- **Monitors** → ping/TCP/HTTP checks + notification transports +
auto-incidents = the "something died at 03:12" alarm the user lacked
- **Service links** → per-console deep links
## 4. The switch problem
The Ugreen CM834 is unmanaged: no IP, no SNMP, no telemetry. Root cause is
unobservable. What monitoring delivers instead:
- Monitors on router IP, PVE host, and one service per LXC — all reached
*through* the switch. When it hangs, behind-switch targets fail together
while router-side checks stay green: a timestamped "it was the switch"
signature plus an alert to ntfy/Telegram.
- Plausible mitigation (not proven): disable EEE on host NICs —
`ethtool --set-eee <nic> eee off`. Cheap-switch EEE hangs are a known
failure mode.
## 5. Deliverables
```
infra/monitoring/
compose.yaml # prometheus + grafana + pve-exporter + beszel-hub
prometheus.yml # scrape config: pve (via exporter) + dash /api/metrics
pve.yml.example # PVE API token config (copy → pve.yml, gitignored)
grafana/datasources.yaml # auto-provisions the Prometheus source
docs/monitoring.md # deploy guide + Dash widget/monitor recipes
```
No application code. No schema changes. Stack runs anywhere — an LXC on the
Proxmox host is the suggested home.
## 6. Security notes
- Grafana anonymous **Viewer** + `allow_embedding` are set for LAN iframe
use; both are called out in the guide and removable.
- `pve.yml` carries an API token — `pve.yml.example` is committed, the real
file is documented as gitignored-adjacent (lives next to the compose,
outside the repo if preferred).
- Widget secrets already live in item config JSON per the original spec.
## 7. Verification
- `docker compose -f infra/monitoring/compose.yaml config` valid
- `promtool check config prometheus.yml` (dockerized promtool) valid
- Docs-only otherwise; repo gate (typecheck/lint/build/go tests) unchanged
+57
View File
@@ -0,0 +1,57 @@
# CasaOS-ready compose. Import via Apps -> Custom Install -> Import from file.
#
# Do NOT paste the repo's docker-compose.yml into CasaOS: it copies imported
# files to /tmp/casaos-compose-app-* and runs them there, so a relative
# "./data" bind resolves under /tmp — install fails with "bind source path
# does not exist", and even if created the data dies on reboot. This file
# binds /DATA/AppData/dash instead — CasaOS's persistent app-data root,
# browsable in the Files app.
name: dash
services:
dash:
image: ghcr.io/dvorinka/dash:latest
container_name: dash
restart: unless-stopped
ports:
- "3000:3000"
environment:
- TZ=${TZ:-UTC}
volumes:
# Short-form bind auto-creates the host dir; long-form "type: bind" does
# not unless bind.create_host_path is set.
- /DATA/AppData/dash:/data
x-casaos:
envs:
- container: TZ
description:
en_us: "Timezone, e.g. Europe/Prague"
ports:
- container: "3000"
description:
en_us: "Web UI and API"
volumes:
- container: /data
description:
en_us: "All Dash state: SQLite database and uploaded icons"
x-casaos:
architectures:
- amd64
- arm64
main: dash
author: Dvorinka
category: Dashboard
developer: Dvorinka
description:
en_us: "Self-hosted homelab dashboard: services, uptime monitors, domain watch, system metrics. Single container, all state in /data."
icon: https://raw.githubusercontent.com/Dvorinka/Dash/main/dash.svg
tagline:
en_us: "Homelab dashboard in a single container"
thumbnail: ""
title:
en_us: Dash
scheme: http
port_map: "3000"
index: /
store_app_id: dash
+65
View File
@@ -0,0 +1,65 @@
# External monitoring stack — independent of the Dash container.
# Run on any host (a small Debian LXC on Proxmox works well):
# cp pve.yml.example pve.yml # fill in your PVE API token
# docker compose up -d
# All state lives in named volumes.
name: dash-monitoring
services:
prometheus:
image: prom/prometheus:latest
container_name: prometheus
restart: unless-stopped
command:
- --config.file=/etc/prometheus/prometheus.yml
- --storage.tsdb.retention.time=30d
- --web.enable-lifecycle # POST /-/reload applies config changes
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml:ro
- prometheus-data:/prometheus
ports:
- "9090:9090"
grafana:
image: grafana/grafana:latest
container_name: grafana
restart: unless-stopped
environment:
# LAN convenience for Dash iframe widgets: read-only anonymous access.
# Anyone on your network can VIEW dashboards without logging in.
# Delete these three lines to require sign-in instead.
GF_AUTH_ANONYMOUS_ENABLED: "true"
GF_AUTH_ANONYMOUS_ORG_ROLE: Viewer
GF_SECURITY_ALLOW_EMBEDDING: "true" # required for iframe embeds
GF_USERS_ALLOW_SIGN_UP: "false"
volumes:
- ./grafana/datasources.yaml:/etc/grafana/provisioning/datasources/datasources.yaml:ro
- grafana-data:/var/lib/grafana
ports:
- "3001:3000"
depends_on:
- prometheus
pve-exporter:
image: prompve/prometheus-pve-exporter:latest
container_name: pve-exporter
restart: unless-stopped
command: ["--config.file", "/etc/pve.yml"]
volumes:
- ./pve.yml:/etc/pve.yml:ro
ports:
- "9221:9221"
beszel-hub:
image: henrygd/beszel:latest
container_name: beszel
restart: unless-stopped
volumes:
- beszel-data:/beszel_data
ports:
- "8090:8090"
volumes:
prometheus-data:
grafana-data:
beszel-data:
+10
View File
@@ -0,0 +1,10 @@
# Auto-provisions the Prometheus datasource so Grafana works on first boot.
apiVersion: 1
datasources:
- name: Prometheus
type: prometheus
access: proxy
url: http://prometheus:9090
isDefault: true
editable: true
+31
View File
@@ -0,0 +1,31 @@
# Prometheus scrape config for the Dash-adjacent monitoring stack.
# Adjust the host addresses to your LAN before starting.
global:
scrape_interval: 30s
evaluation_interval: 30s
scrape_configs:
# Proxmox cluster: node + every LXC/VM via the PVE API.
# No agents inside guests — pve-exporter asks the API on :8006.
- job_name: pve
metrics_path: /pve
params:
module: [default]
cluster: ["1"]
node: ["1"]
static_configs:
- targets: ["PVE_HOST:8006"] # <- your Proxmox host
relabel_configs:
- source_labels: [__address__]
target_label: __param_target
- source_labels: [__param_target]
target_label: instance
- target_label: __address__
replacement: pve-exporter:9221
# Dash itself — it exposes Prometheus-format metrics already.
- job_name: dash
metrics_path: /api/metrics
static_configs:
- targets: ["DASH_HOST:3000"] # <- wherever Dash runs (CasaOS box, etc.)
+13
View File
@@ -0,0 +1,13 @@
# prometheus-pve-exporter config — copy to pve.yml and fill in.
# pve.yml contains a credential: keep it out of git.
#
# Recommended setup on the Proxmox host:
# pveum user add dashmon@pve --comment "read-only monitoring"
# pveum aclmod / -user dashmon@pve -role PVEAuditor
# Datacenter -> Permissions -> API Tokens -> add token "dash" for dashmon@pve,
# uncheck "Privilege Separation" (the token inherits the user's PVEAuditor role)
default:
user: dashmon@pve
token_name: dash
token_value: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
verify_ssl: false # PVE ships self-signed certs on LAN
Executable
+80
View File
@@ -0,0 +1,80 @@
#!/bin/sh
# Dash installer — curl -fsSL https://raw.githubusercontent.com/Dvorinka/Dash/main/install.sh | sh
# Creates ./dash, writes a compose file, pulls the image, starts on :3000.
set -eu
DIR="${DASH_DIR:-dash}"
PORT="${DASH_PORT:-3000}"
IMAGE="ghcr.io/dvorinka/dash:latest"
DATA_HINT=""
info() { printf ' %s\n' "$1"; }
fail() { printf 'error: %s\n' "$1" >&2; exit 1; }
command -v docker >/dev/null 2>&1 || fail "docker not found — install Docker first: https://docs.docker.com/engine/install"
docker info >/dev/null 2>&1 || fail "docker daemon not reachable — start it, or run with sudo/root"
mkdir -p "$DIR/data"
cd "$DIR"
# compose.yml with a bind mount (data dir visible next to the file) or, when
# Docker Desktop file sharing forbids it, a named volume.
write_compose() {
if [ "$1" = "named" ]; then
cat > compose.yml <<EOF
# Generated by Dash install.sh - safe to edit.
services:
dash:
image: $IMAGE
ports:
- "$PORT:3000"
volumes:
- dash-data:/data
restart: unless-stopped
volumes:
dash-data:
EOF
else
cat > compose.yml <<EOF
# Generated by Dash install.sh - safe to edit.
services:
dash:
image: $IMAGE
ports:
- "$PORT:3000"
volumes:
- ./data:/data
restart: unless-stopped
EOF
fi
}
if docker compose version >/dev/null 2>&1; then
write_compose bind
docker compose pull
if up_out=$(docker compose up -d --remove-orphans 2>&1); then
DATA_HINT="$(pwd)/data"
elif printf '%s' "$up_out" | grep -qi "not shared\|mounts denied"; then
info "Host path not shareable - retrying with a named volume"
write_compose named
docker compose up -d --remove-orphans
DATA_HINT="the 'dash-data' docker volume (docker volume ls | grep dash-data)"
else
printf '%s\n' "$up_out" >&2
exit 1
fi
else
# No compose plugin — named volume keeps docker run free of host-path rules.
info "Compose plugin absent; using docker run"
docker rm -f dash >/dev/null 2>&1 || true
docker pull "$IMAGE"
docker run -d --name dash --restart unless-stopped \
-p "$PORT:3000" -v dash-data:/data "$IMAGE"
DATA_HINT="docker volume 'dash-data'"
fi
info ""
info "Dash is up: http://localhost:$PORT"
info "Data lives in $DATA_HINT — back it up to back up everything."
info "Upgrade later: docker compose pull && docker compose up -d (or: docker pull $IMAGE && docker restart dash)"
+4
View File
@@ -0,0 +1,4 @@
.vercel
.env*
node_modules/
.next/
Binary file not shown.
Binary file not shown.
+67
View File
@@ -0,0 +1,67 @@
@import "tailwindcss";
/* Product tokens — same palette as the app (apps/frontend/src/index.css). */
@theme {
--color-bg: #0a0a0a;
--color-surface: #111111;
--color-surface-hover: #161616;
--color-line: rgba(255, 255, 255, 0.08);
--color-line-strong: rgba(255, 255, 255, 0.14);
--color-text: #fafafa;
--color-dim: #a1a1aa;
--color-faint: #52525b;
--color-accent: #3b82f6;
--color-up: #22c55e;
--color-down: #f59e0b;
--color-destructive: #ef4444;
--color-iconbg: #1c1c1e;
--ease-snap: cubic-bezier(0.2, 0, 0, 1);
--font-sans: var(--font-geist), ui-sans-serif, system-ui, sans-serif;
--font-mono: var(--font-geist-mono), ui-monospace, monospace;
--animate-pulse-dot: pulse-dot 2.4s ease-in-out infinite;
--animate-float: float 7s ease-in-out infinite;
--animate-scan: scan 5s linear infinite;
--animate-draw: draw 2.2s var(--ease-snap) forwards;
@keyframes pulse-dot {
0%, 100% { opacity: 1; box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.35); }
50% { opacity: 0.75; box-shadow: 0 0 0 4px rgba(34, 197, 94, 0); }
}
@keyframes float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-5px); }
}
@keyframes scan {
0% { transform: translateX(-100%); }
100% { transform: translateX(400%); }
}
@keyframes draw {
to { stroke-dashoffset: 0; }
}
}
html {
-webkit-font-smoothing: antialiased;
scroll-behavior: smooth;
}
body {
background: var(--color-bg);
color: var(--color-text);
min-height: 100dvh;
}
::selection {
background: rgba(59, 130, 246, 0.3);
}
@media (prefers-reduced-motion: reduce) {
html { scroll-behavior: auto; }
*, *::before, *::after {
animation: none !important;
transition-duration: 0.01ms !important;
}
}
+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" rx="104" fill="#0a0a0a"/>
<g fill="#3b82f6">
<rect x="120" y="120" width="124" height="124" rx="28"/>
<rect x="268" y="120" width="124" height="124" rx="28" fill="#6b9ef8"/>
<rect x="120" y="268" width="124" height="124" rx="28" fill="#1d4fd7"/>
<rect x="268" y="268" width="124" height="124" rx="28"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 431 B

+45
View File
@@ -0,0 +1,45 @@
import type { Metadata, Viewport } from "next";
import localFont from "next/font/local";
import "./globals.css";
const geist = localFont({
src: "./fonts/geist-var.woff2",
variable: "--font-geist",
weight: "100 900",
display: "swap",
});
const geistMono = localFont({
src: "./fonts/geist-mono-var.woff2",
variable: "--font-geist-mono",
weight: "100 900",
display: "swap",
});
export const metadata: Metadata = {
metadataBase: new URL("https://dash-homelab.vercel.app"),
title: "Dash - Self-hosted homelab dashboard",
description:
"Service boards, uptime monitors, domain watch, and system metrics in one container. A single static Go binary with an embedded React UI. No YAML, no external database.",
openGraph: {
title: "Dash - Self-hosted homelab dashboard",
description:
"Service boards, uptime monitors, domain watch, and system metrics in one container. One static Go binary, no YAML.",
type: "website",
images: ["/dash.svg"],
},
};
export const viewport: Viewport = {
themeColor: "#0a0a0a",
};
export default function RootLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="en" className={`${geist.variable} ${geistMono.variable}`}>
<body className="font-sans">{children}</body>
</html>
);
}
+270
View File
@@ -0,0 +1,270 @@
import { Nav } from "../components/Nav";
import { Board } from "../components/Board";
import { RendererDemo } from "../components/RendererDemo";
import { MonitorMock, DomainMock, SystemMock, StatusBand } from "../components/Mocks";
import { Install } from "../components/Install";
import { CopyButton } from "../components/CopyButton";
import { Reveal } from "../components/Reveal";
function SectionHead({ label, tag }: { label: string; tag: string }) {
return (
<div className="mb-7 flex items-center gap-3">
<h2 className="font-mono text-[11px] font-semibold uppercase tracking-[0.14em] text-dim">
{label}
</h2>
<span className="h-px flex-1 bg-line" />
<span className="font-mono text-[11px] text-faint">{tag}</span>
</div>
);
}
function Feature({
flip = false,
title,
children,
mock,
}: {
flip?: boolean;
title: string;
children: React.ReactNode;
mock: React.ReactNode;
}) {
return (
<Reveal>
<div className="grid items-center gap-8 md:grid-cols-[5fr_7fr]">
<div className={flip ? "md:order-2" : ""}>
<h3 className="text-[22px] font-medium tracking-tight">{title}</h3>
<p className="mt-2.5 max-w-[46ch] text-[14.5px] leading-relaxed text-dim">{children}</p>
</div>
<div className={flip ? "md:order-1" : ""}>{mock}</div>
</div>
</Reveal>
);
}
export default function Page() {
return (
<>
<Nav />
{/* ---------- hero ---------- */}
<header className="mx-auto max-w-5xl px-5 pt-20 sm:pt-24">
<Reveal>
<span className="inline-flex items-center gap-2 rounded-full border border-line bg-surface px-3 py-1 font-mono text-[11.5px] text-dim">
<span className="h-1.5 w-1.5 rounded-full bg-up animate-pulse-dot" />
self-hosted · open source · MIT
</span>
</Reveal>
<Reveal delay={0.06}>
<h1 className="mt-6 max-w-[16ch] text-[42px] font-medium leading-[1.04] tracking-[-0.035em] sm:text-[64px]">
Your homelab, on one calm screen.
</h1>
</Reveal>
<Reveal delay={0.12}>
<p className="mt-5 max-w-[58ch] text-[16.5px] leading-relaxed text-dim">
Service boards, uptime monitors, domain watch, and system metrics - a
single static Go binary with the UI baked in. No YAML, no external
database, no mandatory accounts.
</p>
</Reveal>
<Reveal delay={0.18} className="mt-8 flex flex-wrap items-center gap-2.5">
<a
href="#install"
className="flex h-10 items-center rounded-lg bg-text px-4 text-[14px] font-medium text-bg transition-transform duration-100 active:scale-[0.97]"
>
Get started
</a>
<a
href="https://github.com/Dvorinka/Dash"
className="flex h-10 items-center rounded-lg border border-line bg-surface px-4 text-[14px] font-medium text-dim transition-colors hover:border-line-strong hover:text-text"
>
GitHub
</a>
<span className="flex h-10 items-center gap-1 rounded-lg border border-line bg-surface pl-3.5 pr-1.5 font-mono text-[12.5px]">
<span className="text-faint">$</span>
<code>docker run -p 3000:3000 ghcr.io/dvorinka/dash</code>
<CopyButton
text="docker run -d -v ./data:/data -p 3000:3000 ghcr.io/dvorinka/dash:latest"
label="Copy docker command"
/>
</span>
</Reveal>
{/* the product, rendered live */}
<Reveal delay={0.24} className="relative mt-14">
<div
aria-hidden="true"
className="pointer-events-none absolute -inset-x-8 -top-10 h-40 bg-[radial-gradient(60%_100%_at_50%_0%,rgba(59,130,246,0.10),transparent)]"
/>
<Board />
</Reveal>
<Reveal className="mt-14 grid grid-cols-2 overflow-hidden rounded-xl border border-line sm:grid-cols-4">
{[
["Deploy", "one static binary"],
["Database", "SQLite, embedded"],
["Config", "UI-managed, no YAML"],
["License", "MIT"],
].map(([k, v], i) => (
<div key={k} className={`px-5 py-4 ${i > 0 ? "border-l border-line" : ""} ${i >= 2 ? "max-sm:border-t max-sm:border-line" : ""} ${i === 2 ? "max-sm:border-l-0" : ""}`}>
<div className="font-mono text-[10px] uppercase tracking-[0.12em] text-faint">{k}</div>
<div className="mt-1.5 text-[14.5px] font-medium">{v}</div>
</div>
))}
</Reveal>
</header>
<main className="mx-auto max-w-5xl px-5">
{/* ---------- renderers ---------- */}
<section id="renderers" className="scroll-mt-24 pt-24">
<SectionHead label="Boards" tag="the differentiator" />
<div className="grid items-center gap-8 md:grid-cols-[4fr_8fr]">
<Reveal>
<h3 className="text-[22px] font-medium tracking-tight">
One board state. Four skins.
</h3>
<p className="mt-2.5 max-w-[42ch] text-[14.5px] leading-relaxed text-dim">
Bento, Cards, Index, and Console renderers share the same drag-drop,
popovers, and state. Switching the look is a setting, not a migration.
Try it - this demo is live.
</p>
</Reveal>
<Reveal delay={0.1}>
<RendererDemo />
</Reveal>
</div>
</section>
{/* ---------- monitors ---------- */}
<section id="monitors" className="scroll-mt-24 pt-24">
<SectionHead label="Monitors" tag="checks on a schedule" />
<Feature title="Uptime that watches itself" mock={<MonitorMock />}>
HTTP, TCP, ping, DNS, keyword, JSON-path, and push checks on an
in-process scheduler. Heartbeat history, latency graphs, and alert
rules per monitor - down means you hear about it, not discover it.
</Feature>
</section>
{/* ---------- domains ---------- */}
<section className="pt-24">
<SectionHead label="Domains" tag="rdap + whois + tls" />
<Feature
flip
title="Every domain, accounted for"
mock={<DomainMock />}
>
Expiry countdowns, TLS certificate chains, DNS records attributed to
their provider, and CT-log subdomain discovery. Field-level history on
every change.
</Feature>
</section>
{/* ---------- systems ---------- */}
<section className="pt-24">
<SectionHead label="Systems" tag="push-based agent" />
<Feature title="Hosts that report in" mock={<SystemMock />}>
A tiny <code className="rounded border border-line bg-surface px-1.5 py-0.5 font-mono text-[12.5px]">dash-agent</code>{" "}
pushes CPU, memory, disk, network, temperatures, and per-container
stats - SMART, ZFS, and GPU when the tooling exists. One binary per
host, nothing else.
</Feature>
</section>
{/* ---------- status + alerts ---------- */}
<section className="pt-24">
<SectionHead label="Status & alerts" tag="public pages, real transports" />
<Feature
flip
title="Tell users before they ask"
mock={<StatusBand />}
>
Automatic incidents on monitor down, maintenance windows, public
status pages, and SVG badges. Alerts dispatch through webhook, Slack,
Discord, Telegram, Gotify, ntfy, or SMTP.
</Feature>
</section>
{/* ---------- stack ---------- */}
<section id="stack" className="scroll-mt-24 pt-24">
<SectionHead label="Stack" tag="boring, on purpose" />
<Reveal>
<div className="flex flex-wrap gap-2">
{[
"go 1.24",
"gin",
"sqlite (modernc)",
"embed.FS",
"openapi contract",
"react 18",
"docker · ~34 MB",
"goreleaser binaries",
"prometheus /api/metrics",
"pwa",
].map((t) => (
<span
key={t}
className="rounded-md border border-line bg-surface px-3 py-1.5 font-mono text-[12px] text-dim"
>
{t}
</span>
))}
</div>
</Reveal>
<Reveal delay={0.08}>
<p className="mt-5 max-w-[62ch] text-[14.5px] leading-relaxed text-dim">
It needs a persistent scheduler, WHOIS on port 43, ICMP, and SMTP
egress - none of which exist on serverless. So the deploy story is{" "}
<code className="rounded border border-line bg-surface px-1.5 py-0.5 font-mono text-[12.5px]">docker run</code>
, and this page is the only part of Dash hosted here.
</p>
</Reveal>
</section>
{/* ---------- install ---------- */}
<section id="install" className="scroll-mt-24 pt-24">
<SectionHead label="Install" tag="~1 min" />
<Install />
<Reveal delay={0.1}>
<p className="mt-5 max-w-[62ch] text-[14.5px] leading-relaxed text-dim">
Everything is configured from the UI at{" "}
<code className="rounded border border-line bg-surface px-1.5 py-0.5 font-mono text-[12.5px]">localhost:3000</code>
. All state lives in one directory. Importers for Homepage, Homarr,
and Dashy configs are built in.
</p>
</Reveal>
</section>
{/* ---------- bottom cta ---------- */}
<Reveal className="mt-24">
<div className="flex flex-wrap items-center justify-between gap-6 rounded-2xl border border-line bg-gradient-to-b from-surface to-bg p-10">
<div>
<h2 className="text-[26px] font-medium tracking-tight">Run it in one command.</h2>
<p className="mt-2 text-[14.5px] text-dim">MIT licensed. Self-hosted. Yours.</p>
</div>
<a
href="https://github.com/Dvorinka/Dash"
className="flex h-11 items-center rounded-lg bg-text px-5 text-[14px] font-medium text-bg transition-transform duration-100 active:scale-[0.97]"
>
Star on GitHub
</a>
</div>
</Reveal>
</main>
<footer className="mt-24 border-t border-line">
<div className="mx-auto flex max-w-5xl flex-wrap items-center gap-5 px-5 py-8 text-[13px] text-faint">
<span className="flex items-center gap-2">
<img src="/dash.svg" alt="" width={18} height={18} className="rounded-[4px]" />
Dash
</span>
<span>© 2026 · MIT License</span>
<div className="ml-auto flex gap-5">
<a className="text-dim transition-colors hover:text-text" href="https://github.com/Dvorinka/Dash">GitHub</a>
<a className="text-dim transition-colors hover:text-text" href="https://github.com/Dvorinka/Dash/tree/main/docs">Docs</a>
<a className="text-dim transition-colors hover:text-text" href="https://github.com/Dvorinka/Dash/releases">Releases</a>
</div>
</div>
</footer>
</>
);
}
+268
View File
@@ -0,0 +1,268 @@
"use client";
import { useEffect, useState } from "react";
import { motion } from "motion/react";
/* A live miniature of the product - rendered, not screenshotted. */
type Service = {
name: string;
host: string;
letter: string;
tint: string;
};
const MEDIA: Service[] = [
{ name: "Plex", host: "plex.localhost:32400", letter: "P", tint: "#e5a00d" },
{ name: "Jellyfin", host: "jellyfin.localhost:8096", letter: "J", tint: "#aa5cc3" },
{ name: "Immich", host: "immich.localhost:2283", letter: "I", tint: "#f16d9a" },
];
const INFRA: Service[] = [
{ name: "Proxmox", host: "proxmox.localhost:8006", letter: "P", tint: "#e57000" },
{ name: "Pi-hole", host: "pihole.localhost", letter: "P", tint: "#f05454" },
{ name: "Gitea", host: "gitea.localhost:3000", letter: "G", tint: "#609926" },
];
function useReduced() {
const [reduced, setReduced] = useState(false);
useEffect(() => {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
setReduced(mq.matches);
const fn = (e: MediaQueryListEvent) => setReduced(e.matches);
mq.addEventListener("change", fn);
return () => mq.removeEventListener("change", fn);
}, []);
return reduced;
}
function useClock() {
const [now, setNow] = useState<Date | null>(null);
const reduced = useReduced();
useEffect(() => {
setNow(new Date());
if (reduced) return;
const t = setInterval(() => setNow(new Date()), 1000);
return () => clearInterval(t);
}, [reduced]);
return now;
}
function useWander(base: number, spread: number, ms: number) {
const [v, setV] = useState(base);
const reduced = useReduced();
useEffect(() => {
if (reduced) return;
const t = setInterval(
() => setV(Math.round(base + (Math.random() - 0.35) * spread)),
ms,
);
return () => clearInterval(t);
}, [base, spread, ms, reduced]);
return v;
}
function StatusDot({ down = false }: { down?: boolean }) {
return (
<span
className={`inline-block h-1.5 w-1.5 rounded-full ${down ? "bg-down" : "bg-up animate-pulse-dot"}`}
/>
);
}
function Tile({
i,
className = "",
children,
}: {
i: number;
className?: string;
children: React.ReactNode;
}) {
return (
<motion.div
initial={{ opacity: 0, y: 10 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.45, delay: 0.05 * i, ease: [0.2, 0, 0, 1] }}
className={`rounded-lg border border-line bg-surface p-3 transition-colors duration-150 hover:border-line-strong hover:bg-surface-hover ${className}`}
>
{children}
</motion.div>
);
}
function ServiceTile({ s, i, down }: { s: Service; i: number; down?: boolean }) {
return (
<Tile i={i} className="col-span-3 flex items-center gap-3 sm:col-span-2">
<span
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-iconbg text-sm font-semibold"
style={{ color: s.tint }}
>
{s.letter}
</span>
<span className="min-w-0">
<span className="block truncate text-[13px] font-medium">{s.name}</span>
<span className="block truncate font-mono text-[10.5px] text-faint">{s.host}</span>
</span>
<span className="ml-auto flex items-center gap-1.5 rounded border border-line px-1.5 py-0.5 font-mono text-[10px] text-dim">
<StatusDot down={down} />
{down ? "degraded" : "up"}
</span>
</Tile>
);
}
function ClockTile({ i }: { i: number }) {
const now = useClock();
return (
<Tile i={i} className="col-span-3 sm:col-span-2">
<div className="font-mono text-[10px] uppercase tracking-[0.14em] text-faint">local time</div>
<div className="mt-1.5 font-mono text-xl font-medium tabular-nums">
{now ? now.toLocaleTimeString("en-GB") : "--:--:--"}
</div>
<div className="mt-1 font-mono text-[10.5px] text-dim">Europe/Prague</div>
</Tile>
);
}
function PiholeTile({ i }: { i: number }) {
const blocked = useWander(24, 6, 2600);
return (
<Tile i={i} className="col-span-3 sm:col-span-2">
<div className="font-mono text-[10px] uppercase tracking-[0.14em] text-faint">pi-hole</div>
<div className="mt-1.5 text-xl font-medium tabular-nums">
{blocked.toFixed(1)}
<span className="text-sm text-dim">%</span>
</div>
<div className="mt-1 font-mono text-[10.5px] text-dim">queries blocked</div>
</Tile>
);
}
function HeartbeatTile({ i }: { i: number }) {
const [bars, setBars] = useState<number[]>(() => Array.from({ length: 26 }, () => 1));
const ping = useWander(42, 30, 1500);
const reduced = useReduced();
useEffect(() => {
if (reduced) return;
const t = setInterval(() => {
setBars((b) => [...b.slice(1), Math.random() > 0.06 ? 1 : 0]);
}, 1100);
return () => clearInterval(t);
}, [reduced]);
return (
<Tile i={i} className="col-span-3 sm:col-span-2">
<div className="flex items-baseline justify-between">
<span className="font-mono text-[10px] uppercase tracking-[0.14em] text-faint">api</span>
<span className="font-mono text-[10.5px] tabular-nums text-dim">{ping} ms</span>
</div>
<div className="mt-2 flex h-7 items-end gap-[3px]">
{bars.map((v, k) => (
<span
key={k}
className={`flex-1 rounded-[2px] ${v ? "bg-up" : "bg-down"} transition-opacity duration-300`}
style={{ height: `${v ? 60 + ((k * 37) % 40) : 100}%`, opacity: v ? 0.85 : 1 }}
/>
))}
</div>
</Tile>
);
}
function SystemTile({ i }: { i: number }) {
const cpu = useWander(34, 26, 2200);
return (
<Tile i={i} className="col-span-3 sm:col-span-2">
<div className="font-mono text-[10px] uppercase tracking-[0.14em] text-faint">nas-01</div>
<div className="mt-1.5 flex items-baseline gap-1">
<span className="text-xl font-medium tabular-nums">{cpu}%</span>
<span className="font-mono text-[10.5px] text-dim">cpu</span>
</div>
<div className="mt-2 space-y-1">
<div className="h-1 w-full overflow-hidden rounded bg-iconbg">
<div className="h-full rounded bg-accent transition-all duration-700" style={{ width: `${Math.min(cpu, 100)}%` }} />
</div>
<div className="h-1 w-full overflow-hidden rounded bg-iconbg">
<div className="h-full rounded bg-up/70 transition-all duration-700" style={{ width: "61%" }} />
</div>
</div>
</Tile>
);
}
function DomainTile({ i }: { i: number }) {
return (
<Tile i={i} className="col-span-3 sm:col-span-2">
<div className="font-mono text-[10px] uppercase tracking-[0.14em] text-faint">tdvorak.dev</div>
<div className="mt-1.5 text-xl font-medium">
260<span className="text-sm text-dim">d</span>
</div>
<div className="mt-1 font-mono text-[10.5px] text-dim">until expiry · tls 81d</div>
</Tile>
);
}
export function Board() {
const [warn, setWarn] = useState(-1);
const reduced = useReduced();
useEffect(() => {
if (reduced) return;
const t = setInterval(() => {
const pick = Math.floor(Math.random() * MEDIA.length);
setWarn(pick);
setTimeout(() => setWarn(-1), 2600);
}, 11000);
return () => clearInterval(t);
}, [reduced]);
return (
<div className="overflow-hidden rounded-xl border border-line-strong bg-bg shadow-[0_24px_80px_-24px_rgba(0,0,0,0.7)]">
{/* window chrome */}
<div className="flex h-9 items-center gap-2 border-b border-line bg-surface px-3.5">
<span className="h-2.5 w-2.5 rounded-full bg-line-strong" />
<span className="h-2.5 w-2.5 rounded-full bg-line-strong" />
<span className="h-2.5 w-2.5 rounded-full bg-line-strong" />
<span className="ml-2 rounded-md border border-line bg-bg px-2 py-0.5 font-mono text-[10px] text-faint">
localhost:3000
</span>
<span className="ml-auto hidden items-center gap-1 rounded-md border border-line px-2 py-0.5 font-mono text-[10px] text-faint sm:flex">
⌘K
</span>
</div>
<div className="p-3.5 sm:p-5">
<div className="mb-2.5 flex items-center gap-3">
<span className="font-mono text-[10px] font-semibold uppercase tracking-[0.14em] text-dim">
Media
</span>
<span className="font-mono text-[10px] text-faint">05</span>
<span className="h-px flex-1 bg-line" />
</div>
<div className="grid grid-cols-6 gap-2.5">
{MEDIA.map((s, k) => (
<ServiceTile key={s.name} s={s} i={k} down={warn === k} />
))}
<ClockTile i={3} />
<PiholeTile i={4} />
</div>
<div className="mb-2.5 mt-5 flex items-center gap-3">
<span className="font-mono text-[10px] font-semibold uppercase tracking-[0.14em] text-dim">
Infrastructure
</span>
<span className="font-mono text-[10px] text-faint">06</span>
<span className="h-px flex-1 bg-line" />
</div>
<div className="grid grid-cols-6 gap-2.5">
{INFRA.map((s, k) => (
<ServiceTile key={s.name} s={s} i={k + 5} />
))}
<HeartbeatTile i={8} />
<SystemTile i={9} />
<DomainTile i={10} />
</div>
</div>
</div>
);
}
+35
View File
@@ -0,0 +1,35 @@
"use client";
import { useState } from "react";
export function CopyButton({ text, label }: { text: string; label: string }) {
const [copied, setCopied] = useState(false);
return (
<button
type="button"
aria-label={label}
onClick={async () => {
try {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 1400);
} catch {
/* clipboard unavailable - select manually */
}
}}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-faint transition-colors hover:bg-surface-hover hover:text-text"
>
{copied ? (
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="var(--color-up)" strokeWidth="2.5" aria-hidden="true">
<path d="M20 6 9 17l-5-5" />
</svg>
) : (
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
<rect x="9" y="9" width="13" height="13" rx="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
)}
</button>
);
}
+38
View File
@@ -0,0 +1,38 @@
import { CopyButton } from "./CopyButton";
import { Reveal } from "./Reveal";
const CURL = "curl -fsSL https://raw.githubusercontent.com/Dvorinka/Dash/main/install.sh | sh";
const DOCKER = "docker run -d -v ./data:/data -p 3000:3000 ghcr.io/dvorinka/dash:latest";
export function Install() {
return (
<div className="grid gap-2.5 md:grid-cols-2">
<Reveal className="overflow-hidden rounded-xl border border-line bg-surface/50">
<div className="flex items-center justify-between border-b border-line px-4 py-2.5">
<span className="font-mono text-[10px] uppercase tracking-[0.14em] text-faint">
one-liner
</span>
<CopyButton text={CURL} label="Copy install command" />
</div>
<pre className="overflow-x-auto p-4 font-mono text-[12.5px] leading-7 text-dim">
<span className="text-text">$</span> curl -fsSL https://raw.githubusercontent.com/
{"\n"} Dvorinka/Dash/main/install.sh | sh{"\n"}
<span className="text-faint"># installs into ./dash, serves :3000</span>
</pre>
</Reveal>
<Reveal delay={0.08} className="overflow-hidden rounded-xl border border-line bg-surface/50">
<div className="flex items-center justify-between border-b border-line px-4 py-2.5">
<span className="font-mono text-[10px] uppercase tracking-[0.14em] text-faint">
docker
</span>
<CopyButton text={DOCKER} label="Copy docker command" />
</div>
<pre className="overflow-x-auto p-4 font-mono text-[12.5px] leading-7 text-dim">
<span className="text-text">$</span> docker run -d -v ./data:/data \{"\n"}
{" "}-p 3000:3000 \{"\n"}
{" "}ghcr.io/dvorinka/dash:latest
</pre>
</Reveal>
</div>
);
}
+241
View File
@@ -0,0 +1,241 @@
"use client";
import { useEffect, useState } from "react";
/* Small live mocks for feature rows - data-shaped divs, no images. */
function useReduced() {
const [r, setR] = useState(false);
useEffect(() => {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
setR(mq.matches);
const fn = (e: MediaQueryListEvent) => setR(e.matches);
mq.addEventListener("change", fn);
return () => mq.removeEventListener("change", fn);
}, []);
return r;
}
function usePing(base: number, spread: number, ms: number) {
const [v, setV] = useState(base);
const reduced = useReduced();
useEffect(() => {
if (reduced) return;
const t = setInterval(() => setV(Math.round(base + (Math.random() - 0.4) * spread)), ms);
return () => clearInterval(t);
}, [base, spread, ms, reduced]);
return v;
}
function HeartbeatStrip({ seed, ms }: { seed: number; ms: number }) {
const [bars, setBars] = useState<number[]>(() =>
Array.from({ length: 32 }, (_, k) => ((k * seed) % 23 === 0 ? 0 : 1)),
);
const reduced = useReduced();
useEffect(() => {
if (reduced) return;
const t = setInterval(
() => setBars((b) => [...b.slice(1), Math.random() > 0.045 ? 1 : 0]),
ms,
);
return () => clearInterval(t);
}, [seed, ms, reduced]);
return (
<div className="flex h-6 flex-1 items-end gap-[2px]">
{bars.map((v, k) => (
<span
key={k}
className={`flex-1 rounded-[1.5px] transition-colors duration-300 ${v ? "bg-up" : "bg-down"}`}
style={{ height: v ? `${55 + ((k * 29) % 45)}%` : "100%", opacity: v ? 0.8 : 1 }}
/>
))}
</div>
);
}
const MONITORS = [
{ name: "gitea.tdvorak.dev", kind: "https · 30s", up: "99.98", base: 38, seed: 3 },
{ name: "api.internal", kind: "tcp · 60s", up: "100.00", base: 12, seed: 7 },
{ name: "pihole.local", kind: "keyword · 60s", up: "99.62", base: 24, seed: 11 },
];
function MonitorRow({ m, i }: { m: (typeof MONITORS)[number]; i: number }) {
const ping = usePing(m.base, 18, 1700 + i * 300);
return (
<div className="flex items-center gap-4 px-4 py-3">
<div className="w-36 shrink-0">
<div className="truncate font-mono text-[12px]">{m.name}</div>
<div className="font-mono text-[10px] text-faint">{m.kind}</div>
</div>
<HeartbeatStrip seed={m.seed} ms={1300 + i * 250} />
<div className="w-14 text-right font-mono text-[11px] tabular-nums text-dim">{ping} ms</div>
<div className="w-16 rounded border border-line px-1.5 py-0.5 text-center font-mono text-[10px] text-up">
{m.up}%
</div>
</div>
);
}
export function MonitorMock() {
return (
<div className="divide-y divide-line rounded-xl border border-line bg-surface/50">
{MONITORS.map((m, i) => (
<MonitorRow key={m.name} m={m} i={i} />
))}
</div>
);
}
const DOMAINS = [
{ name: "tdvorak.dev", days: 260, pct: 71, tags: ["porkbun", "cloudflare", "tls 81d"] },
{ name: "kan.tdvorak.dev", days: 260, pct: 71, tags: ["porkbun", "cloudflare", "tls 81d"] },
{ name: "old-legacy.net", days: 12, pct: 8, tags: ["godaddy", "ns1", "tls 9d"], warn: true },
];
export function DomainMock() {
return (
<div className="divide-y divide-line rounded-xl border border-line bg-surface/50">
{DOMAINS.map((d) => (
<div key={d.name} className="flex items-center gap-4 px-4 py-3.5">
<div className="w-36 shrink-0">
<div className="truncate font-mono text-[12px]">{d.name}</div>
<div className="mt-1.5 h-1 w-full overflow-hidden rounded bg-iconbg">
<div
className={`h-full rounded ${d.warn ? "bg-down" : "bg-accent"}`}
style={{ width: `${d.pct}%` }}
/>
</div>
</div>
<span
className={`font-mono text-[12px] tabular-nums ${d.warn ? "text-down" : "text-dim"}`}
>
{d.days}d
</span>
<div className="ml-auto hidden gap-1.5 sm:flex">
{d.tags.map((t) => (
<span
key={t}
className="rounded border border-line px-1.5 py-0.5 font-mono text-[10px] text-faint"
>
{t}
</span>
))}
</div>
</div>
))}
</div>
);
}
export function SystemMock() {
const cpu = usePing(37, 30, 1900);
const mem = usePing(61, 6, 2600);
const R = 44;
const C = 2 * Math.PI * R;
return (
<div className="flex items-center gap-6 rounded-xl border border-line bg-surface/50 p-5">
<div className="relative h-28 w-28 shrink-0">
<svg viewBox="0 0 100 100" className="h-full w-full -rotate-90">
<circle cx="50" cy="50" r={R} fill="none" stroke="var(--color-iconbg)" strokeWidth="7" />
<circle
cx="50"
cy="50"
r={R}
fill="none"
stroke="var(--color-accent)"
strokeWidth="7"
strokeLinecap="round"
strokeDasharray={C}
strokeDashoffset={C - (C * Math.min(cpu, 100)) / 100}
style={{ transition: "stroke-dashoffset 0.9s cubic-bezier(0.2,0,0,1)" }}
/>
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span className="text-xl font-medium tabular-nums">{cpu}%</span>
<span className="font-mono text-[9px] uppercase tracking-[0.12em] text-faint">cpu</span>
</div>
</div>
<div className="min-w-0 flex-1 space-y-3.5">
<div>
<div className="mb-1 flex justify-between font-mono text-[10px] text-faint">
<span>memory</span>
<span className="tabular-nums">{mem}%</span>
</div>
<div className="h-1.5 overflow-hidden rounded bg-iconbg">
<div className="h-full rounded bg-up/80 transition-all duration-700" style={{ width: `${mem}%` }} />
</div>
</div>
<div>
<div className="mb-1 flex justify-between font-mono text-[10px] text-faint">
<span>disk /data</span>
<span>44%</span>
</div>
<div className="h-1.5 overflow-hidden rounded bg-iconbg">
<div className="h-full rounded bg-accent/80" style={{ width: "44%" }} />
</div>
</div>
<div className="flex items-center justify-between font-mono text-[10px] text-faint">
<span>8 containers</span>
<span className="tabular-nums">load 0.42 · up 14d</span>
</div>
</div>
</div>
);
}
const ALERTS: { at: string; text: string; kind: "ok" | "down" | "warn" }[] = [
{ at: "12:04:11", text: "pihole.local recovered", kind: "ok" },
{ at: "12:03:48", text: "pihole.local is down", kind: "down" },
{ at: "11:58:02", text: "old-legacy.net expires in 12d", kind: "warn" },
];
const ALERT_STYLE: Record<(typeof ALERTS)[number]["kind"], string> = {
ok: "text-up",
down: "text-destructive",
warn: "text-down",
};
export function StatusBand() {
const [i, setI] = useState(0);
const reduced = useReduced();
useEffect(() => {
if (reduced) return;
const t = setInterval(() => setI((v) => (v + 1) % ALERTS.length), 3000);
return () => clearInterval(t);
}, [reduced]);
const a = ALERTS[i];
return (
<div className="rounded-xl border border-line bg-surface/50 p-5">
<div className="flex items-center justify-between">
<span className="font-mono text-[10px] uppercase tracking-[0.14em] text-faint">
status page · public
</span>
<span className="rounded border border-line px-1.5 py-0.5 font-mono text-[10px] text-faint">
/status/lab
</span>
</div>
<div className="mt-4 rounded-md bg-up/90 px-3 py-2 text-center text-[12.5px] font-medium text-[#052e16]">
All systems operational
</div>
<div className="mt-4 space-y-2">
{["edge", "api", "media"].map((s) => (
<div key={s} className="flex items-center gap-2.5 font-mono text-[11px] text-dim">
<span className="h-1.5 w-1.5 rounded-full bg-up animate-pulse-dot" />
{s}
<span className="ml-auto text-faint">operational</span>
</div>
))}
</div>
<div className="mt-5 border-t border-line pt-3.5">
<div className="flex items-center gap-2 font-mono text-[10.5px]">
<span className="text-faint">{a.at}</span>
<span className={`truncate ${ALERT_STYLE[a.kind]}`}>{a.text}</span>
<span className="ml-auto shrink-0 rounded border border-line px-1.5 py-0.5 text-[9.5px] text-faint">
→ slack · ntfy · smtp
</span>
</div>
</div>
</div>
);
}
+51
View File
@@ -0,0 +1,51 @@
export function Nav() {
return (
<nav className="sticky top-0 z-20 border-b border-line bg-bg/80 backdrop-blur-md">
<div className="mx-auto flex h-14 max-w-5xl items-center gap-2.5 px-5">
<a href="#" className="flex items-center gap-2 text-[14.5px] font-semibold tracking-tight">
<img src="/dash.svg" alt="" width={20} height={20} className="rounded-[5px]" />
Dash
</a>
<div className="ml-4 hidden gap-0.5 sm:flex">
{[
["Boards", "#renderers"],
["Monitors", "#monitors"],
["Stack", "#stack"],
["Install", "#install"],
].map(([label, href]) => (
<a
key={href}
href={href}
className="rounded-md px-2.5 py-1.5 text-[13px] text-dim transition-colors hover:bg-surface-hover hover:text-text"
>
{label}
</a>
))}
<a
href="https://github.com/Dvorinka/Dash/tree/main/docs"
className="rounded-md px-2.5 py-1.5 text-[13px] text-dim transition-colors hover:bg-surface-hover hover:text-text"
>
Docs
</a>
</div>
<div className="ml-auto flex items-center gap-2">
<a
href="https://github.com/Dvorinka/Dash"
aria-label="GitHub repository"
className="flex h-[34px] w-[34px] items-center justify-center rounded-lg border border-line bg-surface text-dim transition-colors hover:border-line-strong hover:text-text"
>
<svg width="15" height="15" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8Z" />
</svg>
</a>
<a
href="#install"
className="flex h-[34px] items-center rounded-lg bg-text px-3.5 text-[13px] font-medium text-bg transition-transform duration-100 active:scale-[0.97]"
>
Get started
</a>
</div>
</div>
</nav>
);
}
+143
View File
@@ -0,0 +1,143 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { motion } from "motion/react";
/* Same state, four skins - the product's renderer seam, playable in-page. */
type Mode = "bento" | "cards" | "index" | "console";
const MODES: Mode[] = ["bento", "cards", "index", "console"];
const ITEMS = [
{ name: "Plex", host: "plex.local", letter: "P", tint: "#e5a00d" },
{ name: "Jellyfin", host: "jf.local", letter: "J", tint: "#aa5cc3" },
{ name: "Pi-hole", host: "pihole.local", letter: "P", tint: "#f05454" },
{ name: "Gitea", host: "git.local", letter: "G", tint: "#609926" },
{ name: "Grafana", host: "graf.local", letter: "G", tint: "#f46800" },
];
const BENTO_SPAN = ["sm:col-span-2", "sm:col-span-2", "sm:col-span-2", "sm:col-span-3", "sm:col-span-3"];
function Chip({ up = true }: { up?: boolean }) {
return (
<span className="flex items-center gap-1.5 rounded border border-line px-1.5 py-0.5 font-mono text-[10px] text-dim">
<span className={`h-1.5 w-1.5 rounded-full ${up ? "bg-up animate-pulse-dot" : "bg-down"}`} />
up
</span>
);
}
export function RendererDemo() {
const [mode, setMode] = useState<Mode>("bento");
const locked = useRef(false);
useEffect(() => {
const t = setInterval(() => {
if (!locked.current) {
setMode((m) => MODES[(MODES.indexOf(m) + 1) % MODES.length]);
}
}, 3400);
return () => clearInterval(t);
}, []);
const pick = (m: Mode) => {
locked.current = true;
setMode(m);
};
const gridCls =
mode === "bento"
? "grid grid-cols-2 gap-2 sm:grid-cols-6"
: mode === "cards"
? "grid grid-cols-2 gap-2 sm:grid-cols-5"
: mode === "console"
? "grid grid-cols-1 gap-2 sm:grid-cols-3"
: "flex flex-col divide-y divide-line";
return (
<div className="rounded-xl border border-line bg-bg">
<div className="flex items-center gap-2 border-b border-line px-4 py-3">
<span className="font-mono text-[10px] uppercase tracking-[0.14em] text-faint">
same board, four renderers
</span>
<div className="ml-auto flex gap-1">
{MODES.map((m) => (
<button
key={m}
type="button"
aria-pressed={mode === m}
onClick={() => pick(m)}
className={`rounded-md border px-2.5 py-1 font-mono text-[11px] transition-colors duration-150 ${
mode === m
? "border-line-strong bg-surface-hover text-text"
: "border-transparent text-faint hover:text-dim"
}`}
>
{m}
</button>
))}
</div>
</div>
<div className="p-4">
<div className={gridCls}>
{ITEMS.map((it, k) => (
<motion.div
key={it.name}
layout
transition={{ duration: 0.5, ease: [0.2, 0, 0, 1] }}
className={mode === "index" ? "" : mode === "bento" ? `col-span-1 ${BENTO_SPAN[k]}` : "col-span-1"}
>
<motion.div
key={mode}
initial={{ opacity: 0.4 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3 }}
className={
mode === "index"
? "flex items-center gap-4 py-3"
: mode === "console"
? "flex items-center gap-3 rounded-[4px] border border-line px-3 py-2.5 font-mono"
: "flex items-center gap-3 rounded-lg border border-line bg-surface p-3"
}
>
{mode === "index" ? (
<>
<span className="font-mono text-[11px] text-faint">0{k + 1}</span>
<span className="text-[14px] font-medium tracking-tight">{it.name}</span>
<span className="hidden font-mono text-[11px] text-faint sm:block">{it.host}</span>
<span className="ml-auto"><Chip /></span>
</>
) : mode === "console" ? (
<>
<span className="text-[11px] text-text">{it.name.toLowerCase()}</span>
<span className="text-[10px] text-faint">{it.host}</span>
<span className="ml-auto text-[10px] text-up">[ up ]</span>
</>
) : (
<>
<span
className={`flex shrink-0 items-center justify-center rounded-md bg-iconbg font-semibold ${mode === "cards" ? "h-7 w-7 text-[11px]" : "h-9 w-9 text-sm"}`}
style={{ color: it.tint }}
>
{it.letter}
</span>
<span className="min-w-0">
<span className={`block truncate font-medium ${mode === "cards" ? "text-[12px]" : "text-[13px]"}`}>
{it.name}
</span>
{mode === "bento" && (
<span className="block truncate font-mono text-[10.5px] text-faint">{it.host}</span>
)}
</span>
<span className="ml-auto"><Chip /></span>
</>
)}
</motion.div>
</motion.div>
))}
</div>
</div>
</div>
);
}
+26
View File
@@ -0,0 +1,26 @@
"use client";
import { motion } from "motion/react";
import type { ReactNode } from "react";
export function Reveal({
children,
delay = 0,
className,
}: {
children: ReactNode;
delay?: number;
className?: string;
}) {
return (
<motion.div
className={className}
initial={{ opacity: 0, y: 16 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-80px" }}
transition={{ duration: 0.55, delay, ease: [0.2, 0, 0, 1] }}
>
{children}
</motion.div>
);
}
+1
View File
@@ -0,0 +1 @@
declare module "*.css";
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+10
View File
@@ -0,0 +1,10 @@
import { dirname } from "node:path";
import { fileURLToPath } from "node:url";
/** @type {import('next').NextConfig} */
const nextConfig = {
// repo has a root package-lock.json too - keep Next's tracing inside landing/
outputFileTracingRoot: dirname(fileURLToPath(import.meta.url)),
};
export default nextConfig;
+1834
View File
File diff suppressed because it is too large. Load diff
+23
View File
@@ -0,0 +1,23 @@
{
"name": "dash-landing",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start"
},
"dependencies": {
"motion": "^13.4.4",
"next": "^15.5.26",
"react": "^19.3.0",
"react-dom": "^19.3.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.3.3",
"@types/node": "^26.6.2",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"tailwindcss": "^4.3.3",
"typescript": "^6.0.3"
}
}
+5
View File
@@ -0,0 +1,5 @@
const config = {
plugins: ["@tailwindcss/postcss"],
};
export default config;
+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" rx="104" fill="#0a0a0a"/>
<g fill="#3b82f6">
<rect x="120" y="120" width="124" height="124" rx="28"/>
<rect x="268" y="120" width="124" height="124" rx="28" fill="#6b9ef8"/>
<rect x="120" y="268" width="124" height="124" rx="28" fill="#1d4fd7"/>
<rect x="268" y="268" width="124" height="124" rx="28"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 431 B

+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": { "@/*": ["./*"] }
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
+51
View File
@@ -1765,6 +1765,12 @@ components:
type: string
caProvider:
type: string
records:
type: array
items:
$ref: "#/components/schemas/DNSRecord"
providers:
$ref: "#/components/schemas/Providers"
headers:
type: object
additionalProperties:
@@ -1807,6 +1813,51 @@ components:
type: string
alerts:
$ref: "#/components/schemas/AlertRules"
Provider:
type: object
description: Detected vendor behind a record or role
properties:
name:
type: string
domain:
type: string
description: brand domain the favicon icon was derived from
icon:
type: string
Providers:
type: object
description: Detected vendor strip — registrar, DNS, hosting, email, CA
properties:
registrar:
$ref: "#/components/schemas/Provider"
nullable: true
dns:
$ref: "#/components/schemas/Provider"
nullable: true
email:
$ref: "#/components/schemas/Provider"
nullable: true
hosting:
$ref: "#/components/schemas/Provider"
nullable: true
ca:
$ref: "#/components/schemas/Provider"
nullable: true
DNSRecord:
type: object
properties:
type:
type: string
enum: [A, AAAA, CNAME, NS, MX, TXT]
value:
type: string
priority:
type: integer
nullable: true
description: MX preference
provider:
$ref: "#/components/schemas/Provider"
nullable: true
DomainView:
allOf:
- $ref: "#/components/schemas/Domain"
+1
View File
@@ -6,6 +6,7 @@ export default defineConfig({
".claude/**",
"tools/oxlint/anti-slop/**",
"packages/api-client/src/schema.d.ts",
"landing/next-env.d.ts",
"**/dist/**",
"**/node_modules/**",
],
+19
View File
@@ -0,0 +1,19 @@
#!/bin/sh
# Dash setup from a clone — builds the image locally and starts the stack.
# For the prebuilt image path, use ../install.sh instead.
set -eu
cd "$(dirname "$0")/.."
command -v docker >/dev/null 2>&1 || { echo "error: docker not found" >&2; exit 1; }
docker compose version >/dev/null 2>&1 || { echo "error: docker compose plugin not found" >&2; exit 1; }
docker compose up -d --build
echo ""
echo "Dash is up: http://localhost:3000"
echo "Logs: docker compose logs -f"
echo ""
echo "For development instead:"
echo " npm install && npm run dev # vite on :5173"
echo " cd apps/backend && DASH_DEV=1 go run ./cmd/dash # API on :8080"