Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e4e39f001 | ||
|
|
4557af6c93 | ||
|
|
a59d2eb558 | ||
|
|
f2aa9ceba8 | ||
|
|
a94ae1889a | ||
|
|
3ffc01d710 | ||
|
|
d1486d5ec8 | ||
|
|
e88429514b | ||
|
|
d18b4f6825 | ||
|
|
0612beb37e | ||
|
|
a54ba4c544 | ||
|
|
1bb5441eea | ||
|
|
cfacc4ed7c | ||
|
|
10d9171031 | ||
|
|
8ff13f74f7 | ||
|
|
041c54c18c | ||
|
|
f4ebdf1f33 | ||
|
|
c5339a24b4 | ||
|
|
5473881f93 | ||
|
|
c336408152 | ||
|
|
ddd29aad24 | ||
|
|
35602c2e52 | ||
|
|
eb84939a19 | ||
|
|
afa0c931b9 | ||
|
|
54b5497121 |
No files matched your search
@@ -1,20 +1,89 @@
|
||||
name: release
|
||||
|
||||
# Releases are cut automatically from main: after the ci workflow passes on a
|
||||
# main push, the version job bumps semver from conventional commit subjects
|
||||
# (BREAKING/! -> major, feat -> minor, otherwise patch), pushes the tag, and
|
||||
# the build jobs release it. Manual tags are not used; workflow_dispatch
|
||||
# re-runs the same flow on demand.
|
||||
on:
|
||||
push:
|
||||
tags: ["v*"]
|
||||
workflow_run:
|
||||
workflows: [ci]
|
||||
types: [completed]
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
# Two merges racing would compute the same tag; serialize instead.
|
||||
concurrency:
|
||||
group: release
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
version:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success'
|
||||
outputs:
|
||||
version: ${{ steps.v.outputs.version }}
|
||||
semver: ${{ steps.v.outputs.semver }}
|
||||
major_minor: ${{ steps.v.outputs.major_minor }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
|
||||
|
||||
- id: v
|
||||
run: |
|
||||
set -euo pipefail
|
||||
latest="$(git tag -l 'v*' --sort=-v:refname | head -n1)"
|
||||
if [ -z "$latest" ]; then
|
||||
version="v0.1.0"; bump="minor"
|
||||
else
|
||||
subjects="$(git log "$latest"..HEAD --format=%s)"
|
||||
if grep -qiE 'BREAKING[ -]CHANGE|^[a-z]+(\([^)]*\))?!:' <<< "$subjects"; then
|
||||
bump="major"
|
||||
elif grep -qE '^feat(\([^)]*\))?:' <<< "$subjects"; then
|
||||
bump="minor"
|
||||
else
|
||||
bump="patch"
|
||||
fi
|
||||
IFS=. read -r major minor patch <<< "${latest#v}"
|
||||
case "$bump" in
|
||||
major) major=$((major+1)); minor=0; patch=0 ;;
|
||||
minor) minor=$((minor+1)); patch=0 ;;
|
||||
*) patch=$((patch+1)) ;;
|
||||
esac
|
||||
version="v${major}.${minor}.${patch}"
|
||||
fi
|
||||
semver="${version#v}"
|
||||
IFS=. read -r mj mn _ <<< "$semver"
|
||||
{
|
||||
echo "version=$version"
|
||||
echo "semver=$semver"
|
||||
echo "major_minor=$mj.$mn"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
echo "latest=${latest:-none} bump=$bump -> $version"
|
||||
|
||||
- name: Push tag
|
||||
env:
|
||||
VERSION: ${{ steps.v.outputs.version }}
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git tag "$VERSION"
|
||||
git push origin "$VERSION"
|
||||
|
||||
binaries:
|
||||
needs: version
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # goreleaser needs full history for tag + changelog
|
||||
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
@@ -40,11 +109,15 @@ jobs:
|
||||
args: release --clean
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GORELEASER_CURRENT_TAG: ${{ needs.version.outputs.version }}
|
||||
|
||||
docker:
|
||||
needs: version
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
|
||||
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
@@ -59,8 +132,8 @@ jobs:
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=raw,value=${{ needs.version.outputs.semver }}
|
||||
type=raw,value=${{ needs.version.outputs.major_minor }}
|
||||
type=raw,value=latest
|
||||
|
||||
- uses: docker/build-push-action@v6
|
||||
|
||||
@@ -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*
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -28,8 +28,8 @@ DASH_DEV=1 go run ./cmd/dash # API on :8080
|
||||
and `-- +goose Down`.
|
||||
- Board renderers share state, drag-drop, and popover primitives. Never
|
||||
reimplement them per renderer.
|
||||
- No auth, no YAML-as-source-of-truth, no external database. These are
|
||||
non-goals for v1.
|
||||
- Auth is opt-in and local-only: no OIDC/SSO, no multi-user RBAC. No
|
||||
YAML-as-source-of-truth, no external database. These are non-goals.
|
||||
- Standard library before dependencies; existing helpers before new ones.
|
||||
- User-visible strings go through `t("key")` from `src/i18n` — add the key to
|
||||
`en.ts` rather than inlining literals in new UI.
|
||||
|
||||
@@ -1,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>
|
||||
|
||||

|
||||
<p align="center">
|
||||
Self-hosted homelab dashboard.<br>
|
||||
Service boards, uptime monitors, domain watch, and system metrics in one container.
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="#quick-start">Quick Start</a> ·
|
||||
<a href="docs/installation.md">Documentation</a> ·
|
||||
<a href="https://github.com/Dvorinka/Dash/releases">Releases</a> ·
|
||||
<a href="CONTRIBUTING.md">Contributing</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/Dvorinka/Dash/actions/workflows/ci.yml"><img src="https://github.com/Dvorinka/Dash/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
|
||||
<a href="https://github.com/Dvorinka/Dash/releases"><img src="https://img.shields.io/github/v/release/Dvorinka/Dash" alt="Release"></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/github/license/Dvorinka/Dash" alt="License"></a>
|
||||
</p>
|
||||
|
||||
## What is Dash?
|
||||
|
||||
Dash is a self-hosted homelab dashboard: service boards with drag-and-drop,
|
||||
uptime monitors, domain intelligence, push-based system metrics, incidents,
|
||||
and public status pages — one static Go binary serving an embedded React UI.
|
||||
No YAML, no external database; auth is optional and off by default.
|
||||
|
||||
## Screenshots
|
||||
|
||||
| Board | Monitors | Systems |
|
||||
|:-:|:-:|:-:|
|
||||
|  |  |  |
|
||||
|
||||
<details>
|
||||
<summary>More screenshots</summary>
|
||||
|
||||
| Domain intel | Domains | Status page |
|
||||
|:-:|:-:|:-:|
|
||||
|  |  |  |
|
||||
|
||||
</details>
|
||||
|
||||
## Features
|
||||
|
||||
- **Service board** — 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
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
|  |  |
|
||||
|  |  |
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -88,9 +88,46 @@ type Result struct {
|
||||
HostingProvider string `json:"hostingProvider"`
|
||||
CAProvider string `json:"caProvider"`
|
||||
|
||||
// Structured intel — domainstack-style record list + vendor strip.
|
||||
Records []DNSRecord `json:"records"`
|
||||
Providers Providers `json:"providers"`
|
||||
|
||||
Headers map[string]string `json:"headers"`
|
||||
FaviconURL string `json:"faviconUrl"`
|
||||
Error string `json:"error"` // non-fatal partial failure note
|
||||
|
||||
mxPairs []mxPair // structured MX; MXRecords keeps the display string
|
||||
}
|
||||
|
||||
// Provider names the vendor behind a record or role. Domain feeds the
|
||||
// favicon icon rendered next to the name in the UI.
|
||||
type Provider struct {
|
||||
Name string `json:"name"`
|
||||
Domain string `json:"domain"`
|
||||
Icon string `json:"icon"`
|
||||
}
|
||||
|
||||
// Providers is the detected-vendor strip: registrar, DNS host, hosting,
|
||||
// email, and certificate authority.
|
||||
type Providers struct {
|
||||
Registrar *Provider `json:"registrar,omitempty"`
|
||||
DNS *Provider `json:"dns,omitempty"`
|
||||
Email *Provider `json:"email,omitempty"`
|
||||
Hosting *Provider `json:"hosting,omitempty"`
|
||||
CA *Provider `json:"ca,omitempty"`
|
||||
}
|
||||
|
||||
// DNSRecord is one resolved record with the vendor its value points at.
|
||||
type DNSRecord struct {
|
||||
Type string `json:"type"` // A AAAA CNAME NS MX TXT
|
||||
Value string `json:"value"`
|
||||
Priority *int `json:"priority,omitempty"` // MX preference
|
||||
Provider *Provider `json:"provider,omitempty"`
|
||||
}
|
||||
|
||||
type mxPair struct {
|
||||
host string
|
||||
pref int
|
||||
}
|
||||
|
||||
// Clean normalises user input to a bare registrable hostname.
|
||||
@@ -125,6 +162,7 @@ func (l *Lookup) Run(ctx context.Context, rawName string) *Result {
|
||||
IPv4: []string{},
|
||||
IPv6: []string{},
|
||||
SSLAltNames: []string{},
|
||||
Records: []DNSRecord{},
|
||||
Headers: map[string]string{},
|
||||
FaviconURL: fmt.Sprintf("https://www.google.com/s2/favicons?domain=%s&sz=128", name),
|
||||
}
|
||||
@@ -155,14 +193,68 @@ func (l *Lookup) Run(ctx context.Context, rawName string) *Result {
|
||||
l.host(r.IPv4[0], r)
|
||||
}
|
||||
l.headers(ctx, name, r)
|
||||
|
||||
r.DNSProvider = detectDNSProvider(r.NameServers)
|
||||
r.EmailProvider = detectEmailProvider(r.MXRecords)
|
||||
r.HostingProvider = detectHostingProvider(r.Headers)
|
||||
r.CAProvider = detectCA(r.SSLIssuer)
|
||||
l.identify(r)
|
||||
return r
|
||||
}
|
||||
|
||||
// identify resolves provider vendors after every probe has run, then builds
|
||||
// the structured record list the detail page renders.
|
||||
func (l *Lookup) identify(r *Result) {
|
||||
r.Providers.Registrar = matchProvider(r.RegistrarName, registrarProviders)
|
||||
for _, n := range r.NameServers {
|
||||
if p := matchProvider(n, nsProviders); p != nil {
|
||||
r.Providers.DNS = p
|
||||
break
|
||||
}
|
||||
}
|
||||
for _, m := range r.mxPairs {
|
||||
if p := matchProvider(m.host, mailProviders); p != nil {
|
||||
r.Providers.Email = p
|
||||
break
|
||||
}
|
||||
}
|
||||
r.Providers.Hosting = detectHostProvider(r)
|
||||
r.Providers.CA = matchProvider(r.SSLIssuer, caProviders)
|
||||
// Legacy flat names mirror the strip for the list page + old clients.
|
||||
r.DNSProvider = nameOf(r.Providers.DNS)
|
||||
r.EmailProvider = nameOf(r.Providers.Email)
|
||||
r.HostingProvider = nameOf(r.Providers.Hosting)
|
||||
r.CAProvider = nameOf(r.Providers.CA)
|
||||
|
||||
recs := make([]DNSRecord, 0,
|
||||
len(r.IPv4)+len(r.IPv6)+len(r.NameServers)+len(r.mxPairs)+len(r.TXTRecords)+1)
|
||||
add := func(t, v string, pri *int, p *Provider) {
|
||||
recs = append(recs, DNSRecord{Type: t, Value: v, Priority: pri, Provider: p})
|
||||
}
|
||||
for _, ip := range r.IPv4 {
|
||||
add("A", ip, nil, r.Providers.Hosting)
|
||||
}
|
||||
for _, ip := range r.IPv6 {
|
||||
add("AAAA", ip, nil, r.Providers.Hosting)
|
||||
}
|
||||
if r.CNAME != "" {
|
||||
add("CNAME", r.CNAME, nil, matchProvider(r.CNAME, hostTargets))
|
||||
}
|
||||
for _, n := range r.NameServers {
|
||||
add("NS", n, nil, matchProvider(n, nsProviders))
|
||||
}
|
||||
for _, m := range r.mxPairs {
|
||||
pri := m.pref
|
||||
add("MX", m.host, &pri, matchProvider(m.host, mailProviders))
|
||||
}
|
||||
for _, t := range r.TXTRecords {
|
||||
add("TXT", t, nil, matchTXTProvider(t))
|
||||
}
|
||||
r.Records = recs
|
||||
}
|
||||
|
||||
func nameOf(p *Provider) string {
|
||||
if p == nil {
|
||||
return ""
|
||||
}
|
||||
return p.Name
|
||||
}
|
||||
|
||||
// --- DNS --------------------------------------------------------------------
|
||||
|
||||
func (l *Lookup) dns(ctx context.Context, name string, r *Result) {
|
||||
@@ -174,7 +266,9 @@ func (l *Lookup) dns(ctx context.Context, name string, r *Result) {
|
||||
}
|
||||
if mx, err := res.LookupMX(ctx, name); err == nil {
|
||||
for _, m := range mx {
|
||||
r.MXRecords = append(r.MXRecords, fmt.Sprintf("%s (pri %d)", strings.TrimSuffix(m.Host, "."), m.Pref))
|
||||
host := strings.TrimSuffix(strings.ToLower(m.Host), ".")
|
||||
r.mxPairs = append(r.mxPairs, mxPair{host: host, pref: int(m.Pref)})
|
||||
r.MXRecords = append(r.MXRecords, fmt.Sprintf("%s (pri %d)", host, m.Pref))
|
||||
}
|
||||
}
|
||||
if txt, err := res.LookupTXT(ctx, name); err == nil {
|
||||
@@ -297,108 +391,639 @@ func (l *Lookup) headers(ctx context.Context, name string, r *Result) {
|
||||
}
|
||||
|
||||
// --- Provider detection (condensed port of beszel's detect package) -----------
|
||||
//
|
||||
// Tables map a substring of the record value / org / issuer to a vendor name
|
||||
// plus the brand domain used for the favicon icon. Order matters — first
|
||||
// match wins, so specific substrings precede generic ones.
|
||||
|
||||
func detectDNSProvider(ns []string) string {
|
||||
table := []struct{ sub, name string }{
|
||||
{"cloudflare", "Cloudflare"}, {"awsdns", "Amazon Route 53"},
|
||||
{"googledomains", "Google Domains"}, {"google.com", "Google Domains"},
|
||||
{"namecheap", "Namecheap"}, {"godaddy", "GoDaddy"}, {"domaincontrol", "GoDaddy"},
|
||||
{"nsone.net", "NS1"}, {"digitalocean", "DigitalOcean"}, {"linode", "Linode"},
|
||||
{"vultr", "Vultr"}, {"he.net", "Hurricane Electric"}, {"dynect", "Dyn (Oracle)"},
|
||||
{"ultradns", "UltraDNS"}, {"dnsimple", "DNSimple"}, {"hover", "Hover"},
|
||||
{"enom", "eNom"}, {"worldnic", "Network Solutions"}, {"zoneedit", "ZoneEdit"},
|
||||
{"easydns", "EasyDNS"}, {"gandi", "Gandi"}, {"ovh", "OVH"},
|
||||
{"hetzner", "Hetzner"}, {"azure-dns", "Microsoft Azure"},
|
||||
{"dns.google", "Google Cloud DNS"}, {"porkbun", "Porkbun"},
|
||||
{"cloudns", "ClouDNS"}, {"desec", "deSEC"}, {"nic.cz", "CZ.NIC"},
|
||||
}
|
||||
for _, n := range ns {
|
||||
n = strings.ToLower(n)
|
||||
for _, t := range table {
|
||||
if strings.Contains(n, t.sub) {
|
||||
return t.name
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
type provMatch struct{ sub, name, dom string }
|
||||
|
||||
func prov(name, dom string) *Provider {
|
||||
return &Provider{Name: name, Domain: dom,
|
||||
Icon: fmt.Sprintf("https://www.google.com/s2/favicons?domain=%s&sz=64", dom)}
|
||||
}
|
||||
|
||||
func detectEmailProvider(mx []string) string {
|
||||
table := []struct{ sub, name string }{
|
||||
{"google.com", "Google Workspace"}, {"aspmx", "Google Workspace"},
|
||||
{"outlook.com", "Microsoft 365"}, {"protection.outlook", "Microsoft 365"},
|
||||
{"mail.protection", "Microsoft 365"}, {"protonmail", "Proton Mail"},
|
||||
{"proton.me", "Proton Mail"}, {"zoho", "Zoho Mail"},
|
||||
{"fastmail", "Fastmail"}, {"mimecast", "Mimecast"},
|
||||
{"proofpoint", "Proofpoint"}, {"barracuda", "Barracuda"},
|
||||
{"mailgun", "Mailgun"}, {"sendgrid", "SendGrid"},
|
||||
{"amazonses", "Amazon SES"}, {"yandex", "Yandex"},
|
||||
{"tuta", "Tuta"}, {"mailbox.org", "mailbox.org"},
|
||||
}
|
||||
for _, m := range mx {
|
||||
m = strings.ToLower(m)
|
||||
for _, t := range table {
|
||||
if strings.Contains(m, t.sub) {
|
||||
return t.name
|
||||
}
|
||||
func matchProvider(haystack string, table []provMatch) *Provider {
|
||||
h := strings.ToLower(haystack)
|
||||
for _, t := range table {
|
||||
if strings.Contains(h, t.sub) {
|
||||
return prov(t.name, t.dom)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
return nil
|
||||
}
|
||||
|
||||
func detectHostingProvider(h map[string]string) string {
|
||||
var nsProviders = []provMatch{
|
||||
{"cloudflare", "Cloudflare", "cloudflare.com"},
|
||||
{"awsdns", "Amazon Route 53", "aws.amazon.com"},
|
||||
{"googledomains", "Google Domains", "domains.google"},
|
||||
{"dns.google", "Google Cloud DNS", "cloud.google.com"},
|
||||
{"azure-dns", "Microsoft Azure", "azure.microsoft.com"},
|
||||
{"domaincontrol", "GoDaddy", "godaddy.com"},
|
||||
{"godaddy", "GoDaddy", "godaddy.com"},
|
||||
{"registrar-servers", "Namecheap", "namecheap.com"},
|
||||
{"namecheap", "Namecheap", "namecheap.com"},
|
||||
{"nsone.net", "NS1", "ns1.com"},
|
||||
{"digitalocean", "DigitalOcean", "digitalocean.com"},
|
||||
{"linode", "Akamai", "akamai.com"},
|
||||
{"akamaitech", "Akamai", "akamai.com"},
|
||||
{"vultr", "Vultr", "vultr.com"},
|
||||
{"he.net", "Hurricane Electric", "he.net"},
|
||||
{"dynect", "Dyn (Oracle)", "dyn.com"},
|
||||
{"ultradns", "UltraDNS", "verisign.com"},
|
||||
{"dnsimple", "DNSimple", "dnsimple.com"},
|
||||
{"hover", "Hover", "hover.com"},
|
||||
{"enom", "eNom", "enom.com"},
|
||||
{"worldnic", "Network Solutions", "networksolutions.com"},
|
||||
{"zoneedit", "ZoneEdit", "zoneedit.com"},
|
||||
{"easydns", "EasyDNS", "easydns.com"},
|
||||
{"gandi", "Gandi", "gandi.net"},
|
||||
{"ovh", "OVHcloud", "ovhcloud.com"},
|
||||
{"hetzner", "Hetzner", "hetzner.com"},
|
||||
{"your-server.de", "Hetzner", "hetzner.com"},
|
||||
{"porkbun", "Porkbun", "porkbun.com"},
|
||||
{"cloudns", "ClouDNS", "cloudns.net"},
|
||||
{"desec", "deSEC", "desec.io"},
|
||||
{"nic.cz", "CZ.NIC", "nic.cz"},
|
||||
{"dnsmadeeasy", "DNS Made Easy", "dnsmadeeasy.com"},
|
||||
{"ui-dns", "IONOS", "ionos.com"},
|
||||
{"squarespacedns", "Squarespace", "squarespace.com"},
|
||||
{"wordpress.com", "WordPress.com", "wordpress.com"},
|
||||
{"dns-parking.com", "Hostinger", "hostinger.com"},
|
||||
{"hostinger", "Hostinger", "hostinger.com"},
|
||||
{"siteground", "SiteGround", "siteground.com"},
|
||||
{"dreamhost", "DreamHost", "dreamhost.com"},
|
||||
{"inmotionhosting", "InMotion", "inmotionhosting.com"},
|
||||
{"bluehost", "Bluehost", "bluehost.com"},
|
||||
{"hostgator", "HostGator", "hostgator.com"},
|
||||
{"kinstadns", "Kinsta", "kinsta.com"},
|
||||
{"kasserver", "ALL-INKL.COM", "all-inkl.com"},
|
||||
{"all-inkl", "ALL-INKL.COM", "all-inkl.com"},
|
||||
{"strato", "STRATO", "strato.de"},
|
||||
{"udagdns", "United Domains", "uniteddomains.com"},
|
||||
{"udag.de", "United Domains", "uniteddomains.com"},
|
||||
{"transip", "TransIP", "transip.nl"},
|
||||
{"infomaniak", "Infomaniak", "infomaniak.com"},
|
||||
{"one.com", "One.com", "one.com"},
|
||||
{"aruba", "Aruba", "aruba.it"},
|
||||
{"technorail", "Aruba", "aruba.it"},
|
||||
{"inwx", "INWX", "inwx.com"},
|
||||
{"kasserver.com", "ALL-INKL.COM", "all-inkl.com"},
|
||||
{"contabo", "Contabo", "contabo.com"},
|
||||
{"netcup", "netcup", "netcup.eu"},
|
||||
{"glesys", "GleSYS", "glesys.se"},
|
||||
{"scaleway", "Scaleway", "scaleway.com"},
|
||||
{"exoscale", "Exoscale", "exoscale.com"},
|
||||
{"dnspod", "DNSPod", "dnspod.com"},
|
||||
{"hicloud", "Huawei Cloud", "huaweicloud.com"},
|
||||
{"alidns", "Alibaba Cloud", "alibabacloud.com"},
|
||||
{"purelymail", "Purelymail", "purelymail.com"},
|
||||
{"migadu", "Migadu", "migadu.com"},
|
||||
{"improvmx", "ImprovMX", "improvmx.com"},
|
||||
{"bunny", "Bunny", "bunny.net"},
|
||||
{"edgekey", "Akamai", "akamai.com"},
|
||||
{"edgesuite", "Akamai", "akamai.com"},
|
||||
{"fastly", "Fastly", "fastly.com"},
|
||||
{"cdn77", "CDN77", "cdn77.com"},
|
||||
{"stackpath", "StackPath", "stackpath.com"},
|
||||
{"ns.cloudflare", "Cloudflare", "cloudflare.com"},
|
||||
{"vercel-dns", "Vercel", "vercel.com"},
|
||||
}
|
||||
|
||||
var mailProviders = []provMatch{
|
||||
{"aspmx", "Google Workspace", "google.com"},
|
||||
{"googlemail", "Google Workspace", "google.com"},
|
||||
{"google.com", "Google Workspace", "google.com"},
|
||||
{"outlook.com", "Microsoft 365", "microsoft.com"},
|
||||
{"protection.outlook", "Microsoft 365", "microsoft.com"},
|
||||
{"mail.protection", "Microsoft 365", "microsoft.com"},
|
||||
{"protonmail", "Proton Mail", "proton.me"},
|
||||
{"proton.me", "Proton Mail", "proton.me"},
|
||||
{"zoho", "Zoho Mail", "zoho.com"},
|
||||
{"fastmail", "Fastmail", "fastmail.com"},
|
||||
{"messagingengine", "Fastmail", "fastmail.com"},
|
||||
{"mimecast", "Mimecast", "mimecast.com"},
|
||||
{"proofpoint", "Proofpoint", "proofpoint.com"},
|
||||
{"barracuda", "Barracuda", "barracuda.com"},
|
||||
{"mailgun", "Mailgun", "mailgun.com"},
|
||||
{"sendgrid", "SendGrid", "sendgrid.com"},
|
||||
{"amazonses", "Amazon SES", "aws.amazon.com"},
|
||||
{"yandex", "Yandex", "yandex.com"},
|
||||
{"tuta", "Tuta", "tuta.com"},
|
||||
{"mailbox.org", "mailbox.org", "mailbox.org"},
|
||||
{"purelymail", "Purelymail", "purelymail.com"},
|
||||
{"migadu", "Migadu", "migadu.com"},
|
||||
{"mxroute", "MXroute", "mxroute.com"},
|
||||
{"mymxroute", "MXroute", "mxroute.com"},
|
||||
{"icloud.com", "iCloud Mail", "apple.com"},
|
||||
{"infomaniak", "Infomaniak", "infomaniak.com"},
|
||||
{"mailpod", "One.com", "one.com"},
|
||||
{"one.com", "One.com", "one.com"},
|
||||
{"gmx", "GMX", "gmx.net"},
|
||||
{"forwardemail", "Forward Email", "forwardemail.net"},
|
||||
{"posteo", "Posteo", "posteo.de"},
|
||||
{"mailo", "Mailo", "mailo.com"},
|
||||
{"hey.com", "HEY", "hey.com"},
|
||||
{"privateemail", "Namecheap Private Email", "namecheap.com"},
|
||||
{"registrar-servers", "Namecheap", "namecheap.com"},
|
||||
{"ovh.net", "OVHcloud Mail", "ovhcloud.com"},
|
||||
{"perfora.net", "IONOS Mail", "ionos.com"},
|
||||
{"ui-dns", "IONOS", "ionos.com"},
|
||||
{"improvmx", "ImprovMX", "improvmx.com"},
|
||||
{"porkbun", "Porkbun Mail", "porkbun.com"},
|
||||
{"kundenserver", "IONOS Mail", "ionos.com"},
|
||||
{"t-online", "T-Online", "telekom.de"},
|
||||
{"web.de", "WEB.DE", "web.de"},
|
||||
{"mail.ru", "Mail.ru", "mail.ru"},
|
||||
{"qq.com", "QQ Mail", "qq.com"},
|
||||
{"163.com", "NetEase Mail", "163.com"},
|
||||
{"exim.rs", "Exim", "exim.org"},
|
||||
{"mx.cloudflare", "Cloudflare Email Routing", "cloudflare.com"},
|
||||
}
|
||||
|
||||
// hostTargets: CNAME targets that reveal the hosting platform.
|
||||
var hostTargets = []provMatch{
|
||||
{"github.io", "GitHub Pages", "github.com"},
|
||||
{"vercel-dns", "Vercel", "vercel.com"},
|
||||
{"vercel.app", "Vercel", "vercel.com"},
|
||||
{"netlify", "Netlify", "netlify.com"},
|
||||
{"cloudfront.net", "AWS CloudFront", "aws.amazon.com"},
|
||||
{"azurefd.net", "Azure Front Door", "azure.microsoft.com"},
|
||||
{"azureedge.net", "Azure CDN", "azure.microsoft.com"},
|
||||
{"cloudapp.net", "Microsoft Azure", "azure.microsoft.com"},
|
||||
{"trafficmanager.net", "Microsoft Azure", "azure.microsoft.com"},
|
||||
{"herokuapp.com", "Heroku", "heroku.com"},
|
||||
{"herokudns.com", "Heroku", "heroku.com"},
|
||||
{"workers.dev", "Cloudflare Workers", "cloudflare.com"},
|
||||
{"pages.dev", "Cloudflare Pages", "cloudflare.com"},
|
||||
{"cdn.cloudflare", "Cloudflare", "cloudflare.com"},
|
||||
{"fly.dev", "Fly.io", "fly.io"},
|
||||
{"onrender.com", "Render", "render.com"},
|
||||
{"railway.app", "Railway", "railway.app"},
|
||||
{"surge.sh", "Surge", "surge.sh"},
|
||||
{"neocities.org", "Neocities", "neocities.org"},
|
||||
{"readthedocs", "Read the Docs", "readthedocs.io"},
|
||||
{"webflow.io", "Webflow", "webflow.com"},
|
||||
{"squarespace.com", "Squarespace", "squarespace.com"},
|
||||
{"wixdns.net", "Wix", "wix.com"},
|
||||
{"wixsite.com", "Wix", "wix.com"},
|
||||
{"editorx.com", "Wix Studio", "wix.com"},
|
||||
{"myshopify.com", "Shopify", "shopify.com"},
|
||||
{"wpengine", "WP Engine", "wpengine.com"},
|
||||
{"wordpress.com", "WordPress.com", "wordpress.com"},
|
||||
{"googlehosted.com", "Google", "google.com"},
|
||||
{"googleapis.com", "Google Cloud", "cloud.google.com"},
|
||||
{"fastly.net", "Fastly", "fastly.com"},
|
||||
{"fastlylb.net", "Fastly", "fastly.com"},
|
||||
{"akamaiedge.net", "Akamai", "akamai.com"},
|
||||
{"edgekey.net", "Akamai", "akamai.com"},
|
||||
{"edgesuite.net", "Akamai", "akamai.com"},
|
||||
{"akamai.net", "Akamai", "akamai.com"},
|
||||
{"b-cdn.net", "Bunny", "bunny.net"},
|
||||
{"bunnycdn", "Bunny", "bunny.net"},
|
||||
{"elb.amazonaws.com", "AWS ELB", "aws.amazon.com"},
|
||||
{"amazonaws.com", "Amazon AWS", "aws.amazon.com"},
|
||||
{"stackpathdns", "StackPath", "stackpath.com"},
|
||||
{"kinsta.cloud", "Kinsta", "kinsta.com"},
|
||||
{"pantheonsite.io", "Pantheon", "pantheon.io"},
|
||||
{"pantheon.io", "Pantheon", "pantheon.io"},
|
||||
{"ghost.io", "Ghost", "ghost.org"},
|
||||
{"hubspot", "HubSpot", "hubspot.com"},
|
||||
{"dnsimple.com", "DNSimple", "dnsimple.com"},
|
||||
{"pressdns.com", "Pressable", "pressable.com"},
|
||||
{"kinsta", "Kinsta", "kinsta.com"},
|
||||
{"cdn77", "CDN77", "cdn77.com"},
|
||||
{"incapsula", "Imperva", "imperva.com"},
|
||||
{"sucuri", "Sucuri", "sucuri.net"},
|
||||
{"flywheel", "Flywheel", "getflywheel.com"},
|
||||
{"gridscale", "gridscale", "gridscale.io"},
|
||||
{"webflow", "Webflow", "webflow.com"},
|
||||
{"framer.app", "Framer", "framer.com"},
|
||||
{"carrd.co", "Carrd", "carrd.co"},
|
||||
{"super.so", "Super", "super.so"},
|
||||
{"typedream.app", "Typedream", "typedream.com"},
|
||||
{"cargo.site", "Cargo", "cargo.site"},
|
||||
{"gitlab.io", "GitLab Pages", "gitlab.com"},
|
||||
{"bitbucket.io", "Bitbucket", "bitbucket.org"},
|
||||
{"codeberg.page", "Codeberg Pages", "codeberg.org"},
|
||||
{"glitch.me", "Glitch", "glitch.com"},
|
||||
{"repl.co", "Replit", "replit.com"},
|
||||
{"replit.app", "Replit", "replit.com"},
|
||||
{"deno.dev", "Deno Deploy", "deno.com"},
|
||||
{"supabase.co", "Supabase", "supabase.com"},
|
||||
{"firebaseapp.com", "Firebase", "firebase.google.com"},
|
||||
{"web.app", "Firebase", "firebase.google.com"},
|
||||
{"appspot.com", "Google App Engine", "cloud.google.com"},
|
||||
}
|
||||
|
||||
// asnProviders: ip-api org/AS strings → hosting vendor.
|
||||
var asnProviders = []provMatch{
|
||||
{"cloudflare", "Cloudflare", "cloudflare.com"},
|
||||
{"akamai", "Akamai", "akamai.com"},
|
||||
{"linode", "Akamai (Linode)", "linode.com"},
|
||||
{"amazon", "Amazon AWS", "aws.amazon.com"},
|
||||
{"aws", "Amazon AWS", "aws.amazon.com"},
|
||||
{"google", "Google Cloud", "cloud.google.com"},
|
||||
{"microsoft", "Microsoft Azure", "azure.microsoft.com"},
|
||||
{"azure", "Microsoft Azure", "azure.microsoft.com"},
|
||||
{"digitalocean", "DigitalOcean", "digitalocean.com"},
|
||||
{"hetzner", "Hetzner", "hetzner.com"},
|
||||
{"ovh", "OVHcloud", "ovhcloud.com"},
|
||||
{"kimsufi", "OVHcloud", "ovhcloud.com"},
|
||||
{"soyoustart", "OVHcloud", "ovhcloud.com"},
|
||||
{"vultr", "Vultr", "vultr.com"},
|
||||
{"choopa", "Vultr", "vultr.com"},
|
||||
{"fastly", "Fastly", "fastly.com"},
|
||||
{"vercel", "Vercel", "vercel.com"},
|
||||
{"netlify", "Netlify", "netlify.com"},
|
||||
{"github", "GitHub", "github.com"},
|
||||
{"scaleway", "Scaleway", "scaleway.com"},
|
||||
{"oracle", "Oracle Cloud", "oracle.com"},
|
||||
{"alibaba", "Alibaba Cloud", "alibabacloud.com"},
|
||||
{"tencent", "Tencent Cloud", "cloud.tencent.com"},
|
||||
{"contabo", "Contabo", "contabo.com"},
|
||||
{"ionos", "IONOS", "ionos.com"},
|
||||
{"1&1", "IONOS", "ionos.com"},
|
||||
{"shopify", "Shopify", "shopify.com"},
|
||||
{"netcup", "netcup", "netcup.eu"},
|
||||
{"upcloud", "UpCloud", "upcloud.com"},
|
||||
{"kamatera", "Kamatera", "kamatera.com"},
|
||||
{"fly.io", "Fly.io", "fly.io"},
|
||||
{"render", "Render", "render.com"},
|
||||
{"railway", "Railway", "railway.app"},
|
||||
{"bunny", "Bunny", "bunny.net"},
|
||||
{"hostinger", "Hostinger", "hostinger.com"},
|
||||
{"siteground", "SiteGround", "siteground.com"},
|
||||
{"godaddy", "GoDaddy", "godaddy.com"},
|
||||
{"automattic", "WordPress.com", "wordpress.com"},
|
||||
{"squarespace", "Squarespace", "squarespace.com"},
|
||||
{"wix", "Wix", "wix.com"},
|
||||
{"infomaniak", "Infomaniak", "infomaniak.com"},
|
||||
{"dreamhost", "DreamHost", "dreamhost.com"},
|
||||
{"liquid web", "Liquid Web", "liquidweb.com"},
|
||||
{"liquidweb", "Liquid Web", "liquidweb.com"},
|
||||
{"inmotion", "InMotion", "inmotionhosting.com"},
|
||||
{"unified layer", "Bluehost", "bluehost.com"},
|
||||
{"hostgator", "HostGator", "hostgator.com"},
|
||||
{"glesys", "GleSYS", "glesys.se"},
|
||||
{"exoscale", "Exoscale", "exoscale.com"},
|
||||
{"incapsula", "Imperva", "imperva.com"},
|
||||
{"sucuri", "Sucuri", "sucuri.net"},
|
||||
{"ddos-guard", "DDoS-Guard", "ddos-guard.net"},
|
||||
{"huawei", "Huawei Cloud", "huaweicloud.com"},
|
||||
{"leaseweb", "Leaseweb", "leaseweb.com"},
|
||||
{"aruba", "Aruba", "aruba.it"},
|
||||
{"krystal", "Krystal", "krystal.uk"},
|
||||
{"namecheap", "Namecheap", "namecheap.com"},
|
||||
{"web-hosting.com", "Namecheap", "namecheap.com"},
|
||||
{"zendesk", "Zendesk", "zendesk.com"},
|
||||
{"kinsta", "Kinsta", "kinsta.com"},
|
||||
{"flywheel", "Flywheel", "getflywheel.com"},
|
||||
{"wp engine", "WP Engine", "wpengine.com"},
|
||||
{"pantheon", "Pantheon", "pantheon.io"},
|
||||
{"meta", "Meta", "meta.com"},
|
||||
{"gitlab", "GitLab", "gitlab.com"},
|
||||
{"stackpath", "StackPath", "stackpath.com"},
|
||||
{"cdn77", "CDN77", "cdn77.com"},
|
||||
{"imperva", "Imperva", "imperva.com"},
|
||||
{"framer", "Framer", "framer.com"},
|
||||
{"webflow", "Webflow", "webflow.com"},
|
||||
{"readthedocs", "Read the Docs", "readthedocs.io"},
|
||||
{"transip", "TransIP", "transip.nl"},
|
||||
{"strato", "STRATO", "strato.de"},
|
||||
{"all-inkl", "ALL-INKL.COM", "all-inkl.com"},
|
||||
{"one.com", "One.com", "one.com"},
|
||||
{"netlify", "Netlify", "netlify.com"},
|
||||
{"neocities", "Neocities", "neocities.org"},
|
||||
}
|
||||
|
||||
var registrarProviders = []provMatch{
|
||||
{"markmonitor", "MarkMonitor", "markmonitor.com"},
|
||||
{"namecheap", "Namecheap", "namecheap.com"},
|
||||
{"cloudflare", "Cloudflare", "cloudflare.com"},
|
||||
{"godaddy", "GoDaddy", "godaddy.com"},
|
||||
{"gandi", "Gandi", "gandi.net"},
|
||||
{"porkbun", "Porkbun", "porkbun.com"},
|
||||
{"ovh", "OVHcloud", "ovhcloud.com"},
|
||||
{"name.com", "Name.com", "name.com"},
|
||||
{"ionos", "IONOS", "ionos.com"},
|
||||
{"1&1", "IONOS", "ionos.com"},
|
||||
{"1und1", "IONOS", "ionos.com"},
|
||||
{"tucows", "Tucows", "tucows.com"},
|
||||
{"squarespace", "Squarespace", "squarespace.com"},
|
||||
{"google", "Google Domains", "domains.google"},
|
||||
{"dynadot", "Dynadot", "dynadot.com"},
|
||||
{"namesilo", "NameSilo", "namesilo.com"},
|
||||
{"epik", "Epik", "epik.com"},
|
||||
{"hexonet", "HEXONET", "hexonet.net"},
|
||||
{"1api", "HEXONET", "hexonet.net"},
|
||||
{"openprovider", "Openprovider", "openprovider.com"},
|
||||
{"internetx", "InterNetX", "internetx.com"},
|
||||
{"nic.cz", "CZ.NIC", "nic.cz"},
|
||||
{"hostinger", "Hostinger", "hostinger.com"},
|
||||
{"com laude", "Com Laude", "comlaude.com"},
|
||||
{"csc", "CSC", "cscdbs.com"},
|
||||
{"safenames", "Safenames", "safenames.net"},
|
||||
{"xinnet", "Xin Net", "xinnet.com"},
|
||||
{"alibaba", "Alibaba Cloud", "alibabacloud.com"},
|
||||
{"dnspod", "DNSPod", "dnspod.com"},
|
||||
{"tencent", "Tencent Cloud", "cloud.tencent.com"},
|
||||
{"gabia", "Gabia", "gabia.com"},
|
||||
{"reg.ru", "REG.RU", "reg.ru"},
|
||||
{"nic.ru", "RU-CENTER", "nic.ru"},
|
||||
{"nordname", "NordName", "nordname.com"},
|
||||
{"internet.bs", "Internet.bs", "internetbs.net"},
|
||||
{"key-systems", "Key-Systems", "key-systems.net"},
|
||||
{"united domains", "United Domains", "uniteddomains.com"},
|
||||
{"united-domains", "United Domains", "uniteddomains.com"},
|
||||
{"scaleway", "Scaleway", "scaleway.com"},
|
||||
{"hetzner", "Hetzner", "hetzner.com"},
|
||||
{"infomaniak", "Infomaniak", "infomaniak.com"},
|
||||
{"one.com", "One.com", "one.com"},
|
||||
{"netim", "Netim", "netim.com"},
|
||||
{"inwx", "INWX", "inwx.com"},
|
||||
{"internetworx", "INWX", "inwx.com"},
|
||||
{"checkdomain", "checkdomain", "checkdomain.de"},
|
||||
{"domaindiscount24", "domaindiscount24", "domaindiscount24.com"},
|
||||
{"easyname", "easyname", "easyname.com"},
|
||||
{"world4you", "World4You", "world4you.com"},
|
||||
{"all-inkl", "ALL-INKL.COM", "all-inkl.com"},
|
||||
{"transip", "TransIP", "transip.nl"},
|
||||
{"strato", "STRATO", "strato.de"},
|
||||
{"hover", "Hover", "hover.com"},
|
||||
{"enom", "eNom", "enom.com"},
|
||||
{"namebright", "NameBright", "namebright.com"},
|
||||
{"sav.com", "Sav.com", "sav.com"},
|
||||
{"turncommerce", "DropCatch", "dropcatch.com"},
|
||||
{"eurodns", "EuroDNS", "eurodns.com"},
|
||||
{"gransy", "Gransy", "regtons.com"},
|
||||
{"101domain", "101domain", "101domain.com"},
|
||||
{"iwantmyname", "iwantmyname", "iwantmyname.com"},
|
||||
{"rebel", "Rebel", "rebel.ca"},
|
||||
{"blacknight", "Blacknight", "blacknight.com"},
|
||||
{"gname", "GNAME", "gname.com"},
|
||||
{"west.cn", "West.cn", "west.cn"},
|
||||
{"west263", "West.cn", "west.cn"},
|
||||
{"imena.ua", "Imena.ua", "imena.ua"},
|
||||
{"ukraine.com.ua", "Hosting Ukraine", "ukraine.com.ua"},
|
||||
{"beget", "Beget", "beget.com"},
|
||||
{"selectel", "Selectel", "selectel.ru"},
|
||||
{"timeweb", "Timeweb", "timeweb.ru"},
|
||||
{"papaki", "Papaki", "papaki.com"},
|
||||
{"dotname", "Dotname", "dotname.co.kr"},
|
||||
{"gabia", "Gabia", "gabia.com"},
|
||||
{"registar-se", "Namecheap", "namecheap.com"},
|
||||
{"registrar-servers", "Namecheap", "namecheap.com"},
|
||||
{"namebay", "Namebay", "namebay.com"},
|
||||
{"domainpeople", "DomainPeople", "domainpeople.com"},
|
||||
{"networksolutions", "Network Solutions", "networksolutions.com"},
|
||||
{"network solutions", "Network Solutions", "networksolutions.com"},
|
||||
{"register.com", "Register.com", "register.com"},
|
||||
{"webcentral", "Webcentral", "webcentral.com.au"},
|
||||
{"melbourne it", "Melbourne IT", "melbourneit.com.au"},
|
||||
{"corporatedomains", "CSC", "cscdbs.com"},
|
||||
{"amazon registrar", "Amazon Registrar", "aws.amazon.com"},
|
||||
{"nominet", "Nominet", "nominet.uk"},
|
||||
{"123-reg", "123 Reg", "123-reg.co.uk"},
|
||||
{"domain.com", "Domain.com", "domain.com"},
|
||||
{"dotster", "Dotster", "dotster.com"},
|
||||
{"joker", "Joker.com", "joker.com"},
|
||||
{"variomedia", "Variomedia", "variomedia.de"},
|
||||
{"goneo", "goneo", "goneo.de"},
|
||||
{"manitu", "manitu", "manitu.de"},
|
||||
{"hosteurope", "Host Europe", "hosteurope.de"},
|
||||
{"keyweb", "Keyweb", "keyweb.de"},
|
||||
{"wedos", "WEDOS", "wedos.com"},
|
||||
{"active24", "Active 24", "active24.com"},
|
||||
{"forpsi", "FORPSI", "forpsi.com"},
|
||||
{"ignum", "IGNUM", "ignum.cz"},
|
||||
{"oneprovider", "OneProvider", "oneprovider.com"},
|
||||
{"reg.cz", "REG.CZ", "reg.cz"},
|
||||
{"subreg", "Websupport", "websupport.sk"},
|
||||
{"websupport", "Websupport", "websupport.sk"},
|
||||
{"loopia", "Loopia", "loopia.se"},
|
||||
{"binero", "Binero", "binero.se"},
|
||||
{"one.se", "One.com", "one.com"},
|
||||
{"simply.com", "Simply.com", "simply.com"},
|
||||
{"domeneshop", "Domeneshop", "domeneshop.no"},
|
||||
{"syse", "Syse", "syse.no"},
|
||||
{"registrar.eu", "Openprovider", "openprovider.com"},
|
||||
{"mondo", "Mondo International", "mondo.com"},
|
||||
{"dansk.net", "Dansk.net", "dansk.net"},
|
||||
{"internet invest", "Imena.ua", "imena.ua"},
|
||||
{"netearth", "NetEarth One", "netearthone.com"},
|
||||
{"publicdomainregistry", "PublicDomainRegistry", "publicdomainregistry.com"},
|
||||
{"pdr ltd", "PublicDomainRegistry", "publicdomainregistry.com"},
|
||||
{"webnames", "Webnames", "webnames.ca"},
|
||||
{"rebel.ca", "Rebel", "rebel.ca"},
|
||||
{"easydns", "easyDNS", "easydns.com"},
|
||||
{"namespro", "Namespro", "namespro.ca"},
|
||||
{"sibername", "Sibername", "sibername.com"},
|
||||
{"netfirms", "Netfirms", "netfirms.com"},
|
||||
{"names.co.uk", "names.co.uk", "names.co.uk"},
|
||||
{"namesco", "names.co.uk", "names.co.uk"},
|
||||
{"fasthosts", "Fasthosts", "fasthosts.co.uk"},
|
||||
{"heart internet", "Heart Internet", "heartinternet.uk"},
|
||||
{"dreamhost", "DreamHost", "dreamhost.com"},
|
||||
{"pair domains", "pair Domains", "pairdomains.com"},
|
||||
{"domainthenet", "Domain The Net", "domainthenet.com"},
|
||||
{"domainregistry", "DomainRegistry", "domainregistry.com"},
|
||||
{"moniker", "Moniker", "moniker.com"},
|
||||
{"fabulous", "Fabulous", "fabulous.com"},
|
||||
{"above.com", "Above.com", "above.com"},
|
||||
{"united-internet", "United Internet", "united-internet.de"},
|
||||
{"united internet", "United Internet", "united-internet.de"},
|
||||
{"arsys", "Arsys", "arsys.es"},
|
||||
{"piensa solutions", "Piensa Solutions", "piensasolutions.com"},
|
||||
{"dinahosting", "Dinahosting", "dinahosting.com"},
|
||||
{"cdmon", "CDmon", "cdmon.com"},
|
||||
{"nominalia", "Nominalia", "nominalia.com"},
|
||||
{"register.it", "Register.it", "register.it"},
|
||||
{"ovh sas", "OVHcloud", "ovhcloud.com"},
|
||||
{"gandi sas", "Gandi", "gandi.net"},
|
||||
{"blue razor", "GoDaddy", "godaddy.com"},
|
||||
{"wild west domains", "GoDaddy", "godaddy.com"},
|
||||
{"starfield", "GoDaddy", "godaddy.com"},
|
||||
{"nameking", "Namecheap", "namecheap.com"},
|
||||
{"enom", "eNom", "enom.com"},
|
||||
{"realtime register", "Realtime Register", "realtimeregister.com"},
|
||||
{"rrpproxy", "RRPproxy", "rrpproxy.net"},
|
||||
{"key-systems gmbh", "Key-Systems", "key-systems.net"},
|
||||
{"internet domains", "Internet Domains", "internetdomains.ch"},
|
||||
{"corehub", "CORE", "corehub.net"},
|
||||
{"don dominio", "DonDominio", "dondominio.com"},
|
||||
{"arsys internet", "Arsys", "arsys.es"},
|
||||
{"key-systems", "Key-Systems", "key-systems.net"},
|
||||
{"centralnic", "CentralNic", "centralnic.com"},
|
||||
{"internet.bs corp", "Internet.bs", "internetbs.net"},
|
||||
{"gkg.net", "GKG.NET", "gkg.net"},
|
||||
{"namecheap inc", "Namecheap", "namecheap.com"},
|
||||
{"namecheap, inc", "Namecheap", "namecheap.com"},
|
||||
{"google llc", "Google Domains", "domains.google"},
|
||||
{"squarespace domains", "Squarespace", "squarespace.com"},
|
||||
{"turncommerce, inc.", "DropCatch", "dropcatch.com"},
|
||||
{"amazon", "Amazon Registrar", "aws.amazon.com"},
|
||||
{"name.com, inc", "Name.com", "name.com"},
|
||||
{"godaddy.com, llc", "GoDaddy", "godaddy.com"},
|
||||
{"tucows domains", "Tucows", "tucows.com"},
|
||||
{"porkbun llc", "Porkbun", "porkbun.com"},
|
||||
{"cloudflare, inc", "Cloudflare", "cloudflare.com"},
|
||||
}
|
||||
|
||||
// caProviders: issuer CN/org → certificate authority vendor.
|
||||
var caProviders = []provMatch{
|
||||
{"let's encrypt", "Let's Encrypt", "letsencrypt.org"},
|
||||
{"letsencrypt", "Let's Encrypt", "letsencrypt.org"},
|
||||
{"digicert", "DigiCert", "digicert.com"},
|
||||
{"sectigo", "Sectigo", "sectigo.com"},
|
||||
{"comodoca", "Sectigo", "sectigo.com"},
|
||||
{"comodo", "Sectigo", "sectigo.com"},
|
||||
{"globalsign", "GlobalSign", "globalsign.com"},
|
||||
{"godaddy", "GoDaddy", "godaddy.com"},
|
||||
{"starfield", "GoDaddy", "godaddy.com"},
|
||||
{"entrust", "Entrust", "entrust.com"},
|
||||
{"google trust", "Google Trust Services", "pki.goog"},
|
||||
{"pki.goog", "Google Trust Services", "pki.goog"},
|
||||
{"amazon", "Amazon Trust Services", "amazontrust.com"},
|
||||
{"zerossl", "ZeroSSL", "zerossl.com"},
|
||||
{"buypass", "Buypass", "buypass.com"},
|
||||
{"ssl.com", "SSL.com", "ssl.com"},
|
||||
{"actalis", "Actalis", "actalis.com"},
|
||||
{"certum", "Certum", "certum.pl"},
|
||||
{"harica", "HARICA", "harica.gr"},
|
||||
{"swisssign", "SwissSign", "swisssign.com"},
|
||||
{"cloudflare", "Cloudflare", "cloudflare.com"},
|
||||
{"microsoft", "Microsoft", "microsoft.com"},
|
||||
{"geotrust", "DigiCert", "digicert.com"},
|
||||
{"thawte", "DigiCert", "digicert.com"},
|
||||
{"rapidssl", "DigiCert", "digicert.com"},
|
||||
{"usertrust", "Sectigo", "sectigo.com"},
|
||||
{"certainly", "Certainly", "certainly.io"},
|
||||
{"isrg", "Let's Encrypt", "letsencrypt.org"},
|
||||
{"e1", "Let's Encrypt", "letsencrypt.org"},
|
||||
{"r3", "Let's Encrypt", "letsencrypt.org"},
|
||||
{"r10", "Let's Encrypt", "letsencrypt.org"},
|
||||
{"r11", "Let's Encrypt", "letsencrypt.org"},
|
||||
{"wotrus", "WoTrus", "wotrus.com"},
|
||||
{"cameo", "Cameo", "cameo.com"},
|
||||
}
|
||||
|
||||
// detectHostProvider picks the hosting vendor: CNAME target first (most
|
||||
// precise), then response headers, then the ASN/org of the primary IPv4.
|
||||
func detectHostProvider(r *Result) *Provider {
|
||||
if r.CNAME != "" {
|
||||
if p := matchProvider(r.CNAME, hostTargets); p != nil {
|
||||
return p
|
||||
}
|
||||
}
|
||||
h := r.Headers
|
||||
server := strings.ToLower(h["server"] + " " + h["x-powered-by"] + " " + h["via"])
|
||||
if _, ok := h["cf-ray"]; ok || strings.Contains(server, "cloudflare") {
|
||||
return "Cloudflare"
|
||||
switch {
|
||||
case h["cf-ray"] != "" || strings.Contains(server, "cloudflare"):
|
||||
return prov("Cloudflare", "cloudflare.com")
|
||||
case h["x-vercel-id"] != "" || strings.Contains(server, "vercel"):
|
||||
return prov("Vercel", "vercel.com")
|
||||
case h["x-nf-request-id"] != "" || strings.Contains(server, "netlify"):
|
||||
return prov("Netlify", "netlify.com")
|
||||
case h["x-amz-cf-id"] != "" || strings.Contains(server, "cloudfront"):
|
||||
return prov("AWS CloudFront", "aws.amazon.com")
|
||||
case h["x-github-request-id"] != "" || strings.Contains(server, "github"):
|
||||
return prov("GitHub Pages", "github.com")
|
||||
case strings.Contains(server, "fly.io") || h["fly-request-id"] != "":
|
||||
return prov("Fly.io", "fly.io")
|
||||
case strings.Contains(server, "render"):
|
||||
return prov("Render", "render.com")
|
||||
case strings.Contains(server, "shopify"):
|
||||
return prov("Shopify", "shopify.com")
|
||||
case strings.Contains(server, "squarespace"):
|
||||
return prov("Squarespace", "squarespace.com")
|
||||
case strings.Contains(server, "wix"):
|
||||
return prov("Wix", "wix.com")
|
||||
}
|
||||
if _, ok := h["x-vercel-id"]; ok || strings.Contains(server, "vercel") {
|
||||
return "Vercel"
|
||||
webServers := []provMatch{
|
||||
{"awselb", "AWS ELB", "aws.amazon.com"},
|
||||
{"gws", "Google", "google.com"},
|
||||
{"kestrel", "Kestrel/.NET", "microsoft.com"},
|
||||
{"cowboy", "Cowboy/Erlang", "heroku.com"},
|
||||
}
|
||||
if _, ok := h["x-nf-request-id"]; ok || strings.Contains(server, "netlify") {
|
||||
return "Netlify"
|
||||
if p := matchProvider(server, webServers); p != nil {
|
||||
return p
|
||||
}
|
||||
if _, ok := h["x-amz-cf-id"]; ok || strings.Contains(server, "cloudfront") {
|
||||
return "AWS CloudFront"
|
||||
if p := matchProvider(r.HostOrg+" "+r.HostAS+" "+r.HostISP, asnProviders); p != nil {
|
||||
return p
|
||||
}
|
||||
if _, ok := h["x-github-request-id"]; ok || strings.Contains(server, "github") {
|
||||
return "GitHub Pages"
|
||||
if p := matchProvider(server, []provMatch{
|
||||
{"nginx", "nginx", "nginx.org"},
|
||||
{"apache", "Apache", "apache.org"},
|
||||
{"caddy", "Caddy", "caddyserver.com"},
|
||||
{"litespeed", "LiteSpeed", "litespeedtech.com"},
|
||||
{"openresty", "OpenResty", "openresty.org"},
|
||||
{"envoy", "Envoy", "envoyproxy.io"},
|
||||
}); p != nil {
|
||||
return p
|
||||
}
|
||||
if strings.Contains(server, "fly.io") || h["fly-request-id"] != "" {
|
||||
return "Fly.io"
|
||||
}
|
||||
if strings.Contains(server, "render") {
|
||||
return "Render"
|
||||
}
|
||||
table := []struct{ sub, name string }{
|
||||
{"nginx", "nginx"}, {"apache", "Apache"}, {"caddy", "Caddy"},
|
||||
{"litespeed", "LiteSpeed"}, {"openresty", "OpenResty"},
|
||||
{"envoy", "Envoy"}, {"awselb", "AWS ELB"}, {"gws", "Google"},
|
||||
{"kestrel", "Kestrel/.NET"}, {"cowboy", "Cowboy/Erlang"},
|
||||
}
|
||||
for _, t := range table {
|
||||
if strings.Contains(server, t.sub) {
|
||||
return t.name
|
||||
}
|
||||
}
|
||||
return ""
|
||||
return nil
|
||||
}
|
||||
|
||||
func detectCA(issuer string) string {
|
||||
i := strings.ToLower(issuer)
|
||||
table := []struct{ sub, name string }{
|
||||
{"let's encrypt", "Let's Encrypt"}, {"letsencrypt", "Let's Encrypt"},
|
||||
{"digicert", "DigiCert"}, {"sectigo", "Sectigo"}, {"comodoca", "Sectigo"},
|
||||
{"globalsign", "GlobalSign"}, {"godaddy", "GoDaddy"}, {"starfield", "GoDaddy"},
|
||||
{"entrust", "Entrust"}, {"google trust", "Google Trust Services"},
|
||||
{"amazon", "Amazon"}, {"zerossl", "ZeroSSL"}, {"buypass", "Buypass"},
|
||||
{"ssl.com", "SSL.com"}, {"actalis", "Actalis"}, {"certum", "Certum"},
|
||||
{"harica", "HARICA"}, {"swisssign", "SwissSign"}, {"cloudflare", "Cloudflare"},
|
||||
{"pki.goog", "Google Trust Services"}, {"microsoft", "Microsoft"},
|
||||
}
|
||||
for _, t := range table {
|
||||
if strings.Contains(i, t.sub) {
|
||||
return t.name
|
||||
}
|
||||
}
|
||||
return ""
|
||||
// txtProviders: SPF includes and domain-verification strings → vendor.
|
||||
var txtProviders = []provMatch{
|
||||
{"v=spf1 include:_spf.google.com", "Google Workspace", "google.com"},
|
||||
{"include:spf.protonmail.ch", "Proton Mail", "proton.me"},
|
||||
{"include:mail.protonmail.ch", "Proton Mail", "proton.me"},
|
||||
{"include:_spf.mx.cloudflare.net", "Cloudflare", "cloudflare.com"},
|
||||
{"include:servers.mcsv.net", "Mailchimp", "mailchimp.com"},
|
||||
{"include:amazonses.com", "Amazon SES", "aws.amazon.com"},
|
||||
{"include:mailgun.org", "Mailgun", "mailgun.com"},
|
||||
{"include:sendgrid.net", "SendGrid", "sendgrid.com"},
|
||||
{"include:spf.protection.outlook.com", "Microsoft 365", "microsoft.com"},
|
||||
{"include:spf.purelymail.com", "Purelymail", "purelymail.com"},
|
||||
{"_spf.purelymail.com", "Purelymail", "purelymail.com"},
|
||||
{"purelymail_ownership_proof", "Purelymail", "purelymail.com"},
|
||||
{"openai-domain-verification", "OpenAI", "openai.com"},
|
||||
{"include:_spf.migadu.com", "Migadu", "migadu.com"},
|
||||
{"include:mx.zohomail.com", "Zoho Mail", "zoho.com"},
|
||||
{"include:_spf.fastmail.com", "Fastmail", "fastmail.com"},
|
||||
{"include:spf.mtasv.net", "Postmark", "postmarkapp.com"},
|
||||
{"include:_spf.salesforce.com", "Salesforce", "salesforce.com"},
|
||||
{"include:spf.improvmx.com", "ImprovMX", "improvmx.com"},
|
||||
{"include:mail.zendesk.com", "Zendesk", "zendesk.com"},
|
||||
{"include:spf.mandrillapp.com", "Mailchimp", "mailchimp.com"},
|
||||
{"include:mailspike.net", "Mailspike", "mailspike.net"},
|
||||
{"include:spf.messagelabs.com", "Broadcom", "broadcom.com"},
|
||||
{"include:_spf.mx.yandex", "Yandex", "yandex.com"},
|
||||
{"include:spf.tuta.io", "Tuta", "tuta.com"},
|
||||
{"google-site-verification", "Google", "google.com"},
|
||||
{"ms=", "Microsoft 365", "microsoft.com"},
|
||||
{"facebook-domain-verification", "Meta", "meta.com"},
|
||||
{"apple-domain-verification", "Apple", "apple.com"},
|
||||
{"keybase-site-verification", "Keybase", "keybase.io"},
|
||||
{"keybase", "Keybase", "keybase.io"},
|
||||
{"protonmail-verification", "Proton", "proton.me"},
|
||||
{"have-i-been-pwned-verification", "Have I Been Pwned", "haveibeenpwned.com"},
|
||||
{"atlassian-domain-verification", "Atlassian", "atlassian.com"},
|
||||
{"docker-verification", "Docker", "docker.com"},
|
||||
{"docusign", "DocuSign", "docusign.com"},
|
||||
{"yandex-verification", "Yandex", "yandex.com"},
|
||||
{"stripe-verification", "Stripe", "stripe.com"},
|
||||
{"dropbox-domain-verification", "Dropbox", "dropbox.com"},
|
||||
{"brave-ledger-verification", "Brave", "brave.com"},
|
||||
{"detectify-verification", "Detectify", "detectify.com"},
|
||||
{"adobe-idp-site-verification", "Adobe", "adobe.com"},
|
||||
{"webexdomainverification", "Cisco Webex", "webex.com"},
|
||||
{"zoho-verification", "Zoho", "zoho.com"},
|
||||
{"mailru-verification", "Mail.ru", "mail.ru"},
|
||||
{"baidu-site-verification", "Baidu", "baidu.com"},
|
||||
{"google-gws-recovery-domain-verification", "Google", "google.com"},
|
||||
{"status-page-domain-verification", "Atlassian", "atlassian.com"},
|
||||
{"citrix-verification", "Citrix", "citrix.com"},
|
||||
{"tiktok-domain-verification", "TikTok", "tiktok.com"},
|
||||
{"netlify-verification", "Netlify", "netlify.com"},
|
||||
{"vercel-domain-verification", "Vercel", "vercel.com"},
|
||||
{"workplace-domain-verification", "Meta", "meta.com"},
|
||||
{"v=dmarc1", "DMARC", "dmarc.org"},
|
||||
{"v=dkim1", "DKIM", "dkim.org"},
|
||||
}
|
||||
|
||||
func matchTXTProvider(txt string) *Provider {
|
||||
return matchProvider(txt, txtProviders)
|
||||
}
|
||||
@@ -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,82 +156,103 @@ 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) => (
|
||||
<button
|
||||
key={r}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={board.renderer === r}
|
||||
onClick={() => void board.setSetting("renderer", r)}
|
||||
className={cn(
|
||||
"rounded-[8px] border px-3 py-2.5 text-left transition-colors",
|
||||
board.renderer === r
|
||||
? "border-border-strong bg-surface-hover"
|
||||
: "border-border hover:border-border-strong hover:bg-surface-hover",
|
||||
)}
|
||||
<div className="flex max-h-[72vh] flex-col gap-5 overflow-y-auto pr-1">
|
||||
<Group icon={LayoutGrid} title={t("settings.renderer")}>
|
||||
<div className="grid grid-cols-2 gap-2" role="radiogroup" aria-label={t("settings.renderer")}>
|
||||
{rendererList.map((r) => {
|
||||
const Icon = rendererIcons[r];
|
||||
const active = board.renderer === r;
|
||||
return (
|
||||
<button
|
||||
key={r}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={active}
|
||||
onClick={() => void board.setSetting("renderer", r)}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-[9px] border px-3 py-2.5 text-left transition-colors",
|
||||
active
|
||||
? "border-accent bg-accent/10"
|
||||
: "border-border hover:border-border-strong hover:bg-surface-hover",
|
||||
)}
|
||||
>
|
||||
<span className={cn(
|
||||
"flex size-8 shrink-0 items-center justify-center rounded-[7px] border",
|
||||
active ? "border-accent/40 bg-accent/15 text-accent" : "border-border bg-bg text-text-faint",
|
||||
)}>
|
||||
<Icon size={15} />
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-[12.5px] font-medium">{rendererLabels[r]}</span>
|
||||
<span className="block truncate font-mono text-[10px] text-text-faint">{rendererDescriptions[r]}</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</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.language")}</Label>
|
||||
<Select
|
||||
value={board.locale || "en"}
|
||||
onValueChange={(v) => void board.setSetting("locale", v)}
|
||||
>
|
||||
<span className="block text-[12.5px] font-medium">{rendererLabels[r]}</span>
|
||||
<span className="block font-mono text-[10px] text-text-faint">{rendererDescriptions[r]}</span>
|
||||
</button>
|
||||
))}
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(LOCALES).map(([k, name]) => (
|
||||
<SelectItem key={k} value={k}>{name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</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
|
||||
value={board.locale || "en"}
|
||||
onValueChange={(v) => void board.setSetting("locale", v)}
|
||||
>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(LOCALES).map(([k, name]) => (
|
||||
<SelectItem key={k} value={k}>{name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="accent">{t("settings.accent")}</Label>
|
||||
<div className="flex 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"
|
||||
/>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => void board.setSetting("accent", "")}>
|
||||
{t("common.reset")}
|
||||
</Button>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="accent">{t("settings.accent")}</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
id="accent" type="color" aria-label={t("settings.accent")}
|
||||
value={/^#[0-9a-fA-F]{6}$/.test(board.accent) ? board.accent : "#3b82f6"}
|
||||
onChange={(e) => void board.setSetting("accent", e.target.value)}
|
||||
className="h-8 w-12 cursor-pointer rounded-[6px] border border-border bg-surface p-0.5"
|
||||
/>
|
||||
<span className="font-mono text-[11px] text-text-faint">{board.accent || "—"}</span>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => void board.setSetting("accent", "")}>
|
||||
{t("common.reset")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</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,29 +334,28 @@ 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">
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => void exportJson()}>
|
||||
<Download size={12} /> {t("settings.exportJson")}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => fileRef.current?.click()}>
|
||||
<Upload size={12} /> {t("settings.import")}
|
||||
</Button>
|
||||
<input
|
||||
ref={fileRef} type="file" accept=".json,.yml,.yaml" className="hidden"
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f) void importJson(f);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[11px] text-text-faint">{t("settings.importHint")}</p>
|
||||
{msg ? <p className="text-[12px] text-destructive">{msg}</p> : null}
|
||||
<Group icon={HardDrive} title={t("settings.backup")}>
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => void exportJson()}>
|
||||
<Download size={12} /> {t("settings.exportJson")}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => fileRef.current?.click()}>
|
||||
<Upload size={12} /> {t("settings.import")}
|
||||
</Button>
|
||||
<input
|
||||
ref={fileRef} type="file" accept=".json,.yml,.yaml" className="hidden"
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f) void importJson(f);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[11px] text-text-faint">{t("settings.importHint")}</p>
|
||||
{msg ? <p className="text-[12px] text-destructive">{msg}</p> : null}
|
||||
</Group>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,31 +93,30 @@ 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="flex gap-1">
|
||||
{[24, 168, 720].map((h) => (
|
||||
<button key={h} type="button" onClick={() => setHours(h)}
|
||||
className={cn("rounded-[6px] px-2 py-1 font-mono text-[10.5px]",
|
||||
hours === h ? "bg-surface-hover text-text" : "text-text-faint hover:text-text")}>
|
||||
{h === 24 ? "24h" : h === 168 ? "7d" : "30d"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-6 h-56 rounded-[10px] border border-border bg-surface p-3">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={chart} margin={{ top: 4, right: 4, bottom: 0, left: -18 }}>
|
||||
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" vertical={false} />
|
||||
<XAxis dataKey="t" tick={{ fontSize: 10, fill: "var(--text-faint)" }} tickLine={false} axisLine={false} minTickGap={40} />
|
||||
<YAxis tick={{ fontSize: 10, fill: "var(--text-faint)" }} tickLine={false} axisLine={false} unit=" ms" width={62} />
|
||||
<Tooltip
|
||||
contentStyle={{ background: "var(--surface)", border: "1px solid var(--border)", borderRadius: 8, fontSize: 11 }}
|
||||
labelStyle={{ color: "var(--text-faint)" }}
|
||||
/>
|
||||
<Area type="monotone" dataKey="ping" stroke="var(--up)" fill="var(--up)" fillOpacity={0.12} strokeWidth={1.5} dot={false} isAnimationActive={false} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="mb-6">
|
||||
<ChartCard
|
||||
title={t("monitors.responseTime")}
|
||||
height={208}
|
||||
series={[{ key: "ping", label: "ms", color: chartColor(0) }]}
|
||||
actions={
|
||||
<div className="flex gap-1">
|
||||
{[24, 168, 720].map((h) => (
|
||||
<button key={h} type="button" onClick={() => setHours(h)}
|
||||
className={cn("rounded-[6px] px-2 py-1 font-mono text-[10.5px]",
|
||||
hours === h ? "bg-surface-hover text-text" : "text-text-faint hover:text-text")}>
|
||||
{h === 24 ? "24h" : h === 168 ? "7d" : "30d"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<MetricChart
|
||||
rows={rows}
|
||||
series={[{ key: "ping", label: "ms", color: chartColor(0) }]}
|
||||
fmt={(v) => `${Math.round(v)} ms`}
|
||||
spanMs={hours * 3600_000}
|
||||
/>
|
||||
</ChartCard>
|
||||
</div>
|
||||
|
||||
<h2 className="mb-2 font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">{t("monitors.recentChecks")}</h2>
|
||||
|
||||
@@ -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" }),
|
||||
cpu: s.cpu,
|
||||
memPct: s.memTotal ? ((s.memUsed ?? 0) / s.memTotal) * 100 : undefined,
|
||||
rx: (s.netRx ?? 0) / 1024,
|
||||
tx: (s.netTx ?? 0) / 1024,
|
||||
load: s.load1,
|
||||
}));
|
||||
const axisTick = { fontSize: 10, fill: "var(--text-faint)" };
|
||||
const tipStyle = { background: "var(--surface)", border: "1px solid var(--border)", borderRadius: 8, fontSize: 11 };
|
||||
const spanMs = hours * 3600_000;
|
||||
|
||||
// Rows carry fixed keys plus dynamic `c:`/`m:` (containers), `s:` (sensors),
|
||||
// `g:`/`v:` (GPU util/VRAM) keys; series pick them up by name.
|
||||
const rows: ChartRow[] = stats.map((s) => {
|
||||
const r: ChartRow = {
|
||||
t: new Date(s.ts ?? "").getTime(),
|
||||
cpu: s.cpu,
|
||||
mem: s.memUsed,
|
||||
swap: s.swapUsed,
|
||||
disk: s.diskUsed,
|
||||
rx: s.netRx,
|
||||
tx: s.netTx,
|
||||
l1: s.load1,
|
||||
l5: s.load5,
|
||||
l15: s.load15,
|
||||
};
|
||||
for (const c of s.containers ?? []) {
|
||||
if (c.name) {
|
||||
r[`c:${c.name}`] = c.cpu ?? 0;
|
||||
r[`m:${c.name}`] = c.memUsed ?? 0;
|
||||
}
|
||||
}
|
||||
for (const [k, v] of Object.entries(s.temps ?? {})) r[`s:${k}`] = v;
|
||||
for (const g of s.gpu ?? []) {
|
||||
if (g.name) {
|
||||
r[`g:${g.name}`] = g.utilPct;
|
||||
r[`v:${g.name}`] = g.memUsed;
|
||||
}
|
||||
}
|
||||
return r;
|
||||
});
|
||||
|
||||
const names = (ns: (string | undefined)[]) =>
|
||||
[...new Set(ns.filter((n): n is string => n !== undefined))];
|
||||
const containerNames = names(stats.flatMap((s) => (s.containers ?? []).map((c) => c.name)));
|
||||
const tempNames = names(stats.flatMap((s) => Object.keys(s.temps ?? {})));
|
||||
const gpuNames = names(stats.flatMap((s) => (s.gpu ?? []).map((g) => g.name)));
|
||||
// Zero-fill absent containers so stacked areas stay flush over restarts.
|
||||
for (const r of rows) {
|
||||
for (const n of containerNames) {
|
||||
r[`c:${n}`] ??= 0;
|
||||
r[`m:${n}`] ??= 0;
|
||||
}
|
||||
}
|
||||
|
||||
const pct = (v: number) => `${v.toFixed(1)}%`;
|
||||
const cpuSeries: Series[] = [{ key: "cpu", label: "cpu", color: chartColor(0) }];
|
||||
const dockerCpuSeries: Series[] = containerNames.map((n, i) => ({ key: `c:${n}`, label: n, color: chartColor(i), stack: "a" }));
|
||||
const memSeries: Series[] = [
|
||||
{ key: "mem", label: t("systems.chartUsed"), color: chartColor(1) },
|
||||
{ key: "swap", label: "swap", color: chartColor(3) },
|
||||
];
|
||||
const dockerMemSeries: Series[] = containerNames.map((n, i) => ({ key: `m:${n}`, label: n, color: chartColor(i), stack: "a" }));
|
||||
const diskSeries: Series[] = [{ key: "disk", label: t("systems.chartUsed"), color: chartColor(2) }];
|
||||
const netSeries: Series[] = [
|
||||
{ key: "rx", label: t("systems.chartRx"), color: chartColor(4) },
|
||||
{ key: "tx", label: t("systems.chartTx"), color: chartColor(5) },
|
||||
];
|
||||
const loadSeries: Series[] = [
|
||||
{ key: "l1", label: "1 min", color: chartColor(0) },
|
||||
{ key: "l5", label: "5 min", color: chartColor(3) },
|
||||
{ key: "l15", label: "15 min", color: chartColor(5) },
|
||||
];
|
||||
const tempSeries: Series[] = tempNames.map((n, i) => ({ key: `s:${n}`, label: n, color: chartColor(i), line: true }));
|
||||
const gpuSeries: Series[] = gpuNames.map((n, i) => ({ key: `g:${n}`, label: n, color: chartColor(i) }));
|
||||
const vramSeries: Series[] = gpuNames.map((n, i) => ({ key: `v:${n}`, label: n, color: chartColor(i) }));
|
||||
|
||||
return (
|
||||
<main className="mx-auto w-full max-w-5xl px-7 py-8">
|
||||
@@ -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">
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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"];
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<rect width="512" height="512" rx="104" fill="#0a0a0a"/>
|
||||
<g fill="#3b82f6">
|
||||
<rect x="120" y="120" width="124" height="124" rx="28"/>
|
||||
<rect x="268" y="120" width="124" height="124" rx="28" fill="#6b9ef8"/>
|
||||
<rect x="120" y="268" width="124" height="124" rx="28" fill="#1d4fd7"/>
|
||||
<rect x="268" y="268" width="124" height="124" rx="28"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 431 B |
@@ -1,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
|
||||
@@ -1,11 +1,11 @@
|
||||
# v1.0.0 launch post — draft
|
||||
# v2.0.0 launch post — draft
|
||||
|
||||
For r/selfhosted, HN Show, and similar. Edit freely before posting.
|
||||
|
||||
---
|
||||
|
||||
**Title:** Dash — a self-hosted homelab dashboard with monitoring built in.
|
||||
Single Go binary, no YAML, no auth.
|
||||
Single Go binary, no YAML, optional auth.
|
||||
|
||||
**Body:**
|
||||
|
||||
@@ -19,13 +19,17 @@ What it does:
|
||||
upload. Imports your existing Homepage (`services.yml`), Homarr, or Dashy
|
||||
config in one click.
|
||||
- **Monitors** — HTTP/TCP/ping/DNS/keyword/JSON-path checks plus push monitors
|
||||
for cron jobs. Webhook alerts (Slack/Discord/ntfy). Uptime history.
|
||||
for cron jobs. Uptime graphs, per-monitor alert rules, and notifications via
|
||||
webhook, Slack, Discord, Telegram, Gotify, ntfy, or SMTP.
|
||||
- **Domains** — WHOIS/RDAP, DNS, TLS certificate expiry, provider detection.
|
||||
Inspired by Domain Locker, minus the paid APIs.
|
||||
- **Systems** — a ~4 MB static agent pushes CPU/mem/disk/net/temps/Docker
|
||||
container states. Inspired by Beszel, push-JSON instead of SSH.
|
||||
- **Systems** — a ~4 MB static agent pushes CPU/mem/disk/net/temps, per-
|
||||
container stats, and SMART/ZFS/GPU when present. Inspired by Beszel,
|
||||
push-JSON instead of SSH.
|
||||
- **Ops** — incidents, maintenance windows, public status pages, SVG badges,
|
||||
Prometheus `/api/metrics`.
|
||||
- **Extras** — multiple boards, iframe/JSON-path widgets, CT-log subdomain
|
||||
discovery, EN/CZ locales, installable PWA, custom CSS/wallpaper.
|
||||
|
||||
Deployment is one container:
|
||||
|
||||
@@ -33,8 +37,9 @@ Deployment is one container:
|
||||
docker run -d -v ./data:/data -p 3000:3000 ghcr.io/dvorinka/dash:latest
|
||||
```
|
||||
|
||||
SQLite inside, no database to babysit, no accounts. Deliberately no auth —
|
||||
it expects a trusted LAN or your own proxy in front.
|
||||
SQLite inside, no database to babysit. Auth is opt-in: local
|
||||
username/password with bcrypt + session cookies, off by default; public
|
||||
status pages stay open either way.
|
||||
|
||||
Stack: Go + Gin + SQLite (single static binary serving an embedded React UI).
|
||||
MIT licensed.
|
||||
@@ -53,5 +58,5 @@ Show HN: Dash – self-hosted homelab dashboard with monitoring, one Go binary
|
||||
|
||||
Dash combines a service board, uptime monitors, domain/SSL watching, system
|
||||
metrics via a tiny push agent, and public status pages. Single container or
|
||||
single binary, SQLite storage, no YAML, no auth (LAN-trust model). MIT.
|
||||
single binary, SQLite storage, no YAML. Optional local auth. MIT.
|
||||
https://github.com/Dvorinka/Dash
|
||||
@@ -21,12 +21,16 @@ snapshot; `/api/import/csv` bulk-loads monitors and domains from CSV.
|
||||
|
||||
## Notifications
|
||||
|
||||
**Settings → Notifications** holds one generic webhook URL
|
||||
(`notify_webhook`). On monitor up/down transitions and domain events, Dash
|
||||
POSTs a JSON payload with `event`, `name`, `text`, `content`, `message`, and
|
||||
`at` fields — shaped so Slack, Discord, and ntfy all render it sensibly.
|
||||
**Test** sends a `test` event. Delivery is async and non-fatal; failures are
|
||||
logged.
|
||||
**Settings → Notifications** configures any number of transports — generic
|
||||
webhook, Slack, Discord, Telegram, Gotify, ntfy, and SMTP — each stored as a
|
||||
`notify_<name>` settings object. Monitor up/down transitions and domain
|
||||
events fan out to every configured transport; the payload carries `event`,
|
||||
`name`, `text`, `content`, `message`, and `at` fields. **Test** sends a `test`
|
||||
event. Delivery is async and non-fatal; failures are logged.
|
||||
|
||||
Per-monitor alert rules (consecutive failures before down, latency-warn ms,
|
||||
mute) live on each monitor; domains get a cert-days threshold and mute.
|
||||
Maintenance windows still take precedence.
|
||||
|
||||
## Monitors
|
||||
|
||||
@@ -47,6 +51,27 @@ Bearer-token push ingest at `POST /api/systems/ingest`. A system is marked
|
||||
offline when no sample arrives within a few intervals. Stats are pruned during
|
||||
the hourly sweep.
|
||||
|
||||
## Auth
|
||||
|
||||
Off by default — existing installs see no gate. **Settings → Authentication →
|
||||
Enable** flips to a first-user setup screen (username/email + password,
|
||||
bcrypt). Sessions are SQLite-backed 7-day HttpOnly cookies with rotation;
|
||||
login is rate-limited per IP. Disable only works from an authenticated
|
||||
session. Public status pages, badges, `/api/metrics`, `/api/push/*`, and
|
||||
agent ingest stay open under auth.
|
||||
|
||||
## Boards
|
||||
|
||||
The header switcher manages multiple boards — each gets a slug and a
|
||||
`/b/:slug` route with its own sections/items. Monitors, domains, systems, and
|
||||
status pages stay global. Exports carry all boards.
|
||||
|
||||
## Appearance & language
|
||||
|
||||
Theme (dark/light), accent color, wallpaper URL, and custom CSS in Settings;
|
||||
the language picker switches the UI between English and Czech. A PWA manifest
|
||||
and service worker make the app installable.
|
||||
|
||||
## Operations
|
||||
|
||||
- **Incidents** — open/ack/resolve/close, with per-incident update timeline.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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).
|
||||
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 65 KiB |
|
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
|
||||
@@ -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
|
||||
@@ -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:
|
||||
@@ -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
|
||||
@@ -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.)
|
||||
@@ -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
|
||||
@@ -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)"
|
||||
@@ -0,0 +1,4 @@
|
||||
.vercel
|
||||
.env*
|
||||
node_modules/
|
||||
.next/
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<rect width="512" height="512" rx="104" fill="#0a0a0a"/>
|
||||
<g fill="#3b82f6">
|
||||
<rect x="120" y="120" width="124" height="124" rx="28"/>
|
||||
<rect x="268" y="120" width="124" height="124" rx="28" fill="#6b9ef8"/>
|
||||
<rect x="120" y="268" width="124" height="124" rx="28" fill="#1d4fd7"/>
|
||||
<rect x="268" y="268" width="124" height="124" rx="28"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 431 B |
@@ -0,0 +1,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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
declare module "*.css";
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
const config = {
|
||||
plugins: ["@tailwindcss/postcss"],
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<rect width="512" height="512" rx="104" fill="#0a0a0a"/>
|
||||
<g fill="#3b82f6">
|
||||
<rect x="120" y="120" width="124" height="124" rx="28"/>
|
||||
<rect x="268" y="120" width="124" height="124" rx="28" fill="#6b9ef8"/>
|
||||
<rect x="120" y="268" width="124" height="124" rx="28" fill="#1d4fd7"/>
|
||||
<rect x="268" y="268" width="124" height="124" rx="28"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 431 B |
@@ -0,0 +1,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"]
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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/**",
|
||||
],
|
||||
|
||||
@@ -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"
|
||||