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
|
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:
|
on:
|
||||||
push:
|
workflow_run:
|
||||||
tags: ["v*"]
|
workflows: [ci]
|
||||||
|
types: [completed]
|
||||||
|
branches: [main]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
packages: write
|
packages: write
|
||||||
|
|
||||||
|
# Two merges racing would compute the same tag; serialize instead.
|
||||||
|
concurrency:
|
||||||
|
group: release
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
jobs:
|
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:
|
binaries:
|
||||||
|
needs: version
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0 # goreleaser needs full history for tag + changelog
|
fetch-depth: 0 # goreleaser needs full history for tag + changelog
|
||||||
|
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
@@ -40,11 +109,15 @@ jobs:
|
|||||||
args: release --clean
|
args: release --clean
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
GORELEASER_CURRENT_TAG: ${{ needs.version.outputs.version }}
|
||||||
|
|
||||||
docker:
|
docker:
|
||||||
|
needs: version
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
|
||||||
|
|
||||||
- uses: docker/setup-buildx-action@v3
|
- uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
@@ -59,8 +132,8 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
images: ghcr.io/${{ github.repository }}
|
images: ghcr.io/${{ github.repository }}
|
||||||
tags: |
|
tags: |
|
||||||
type=semver,pattern={{version}}
|
type=raw,value=${{ needs.version.outputs.semver }}
|
||||||
type=semver,pattern={{major}}.{{minor}}
|
type=raw,value=${{ needs.version.outputs.major_minor }}
|
||||||
type=raw,value=latest
|
type=raw,value=latest
|
||||||
|
|
||||||
- uses: docker/build-push-action@v6
|
- uses: docker/build-push-action@v6
|
||||||
|
|||||||
@@ -15,12 +15,18 @@ data/*
|
|||||||
# env / secrets
|
# env / secrets
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
|
infra/monitoring/pve.yml
|
||||||
|
|
||||||
# misc
|
# misc
|
||||||
.DS_Store
|
.DS_Store
|
||||||
coverage/
|
coverage/
|
||||||
dist/
|
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
|
# local tooling artifacts
|
||||||
.playwright-mcp/
|
.playwright-mcp/
|
||||||
.playwright-cli/
|
.playwright-cli/
|
||||||
|
.vercel
|
||||||
|
.env*
|
||||||
@@ -1,13 +1,16 @@
|
|||||||
# Dash
|
# Dash
|
||||||
|
|
||||||
Self-hosted homelab dashboard. Single Go binary serving an embedded React UI.
|
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
|
## Layout
|
||||||
|
|
||||||
- `apps/frontend` — React 18 + Vite + TS strict + Tailwind v4 + shadcn/ui
|
- `apps/frontend` — React 18 + Vite + TS strict + Tailwind v4 + shadcn/ui
|
||||||
- `apps/backend` — Go + Gin + zap, CGO-free, SQLite via `modernc.org/sqlite`
|
- `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
|
- `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)
|
- `infra/` — Dockerfile (multi-stage, one image)
|
||||||
- `tools/oxlint/anti-slop` — vendored lint plugin (do not extend casually)
|
- `tools/oxlint/anti-slop` — vendored lint plugin (do not extend casually)
|
||||||
- `data/` — runtime SQLite + uploaded icons (gitignored)
|
- `data/` — runtime SQLite + uploaded icons (gitignored)
|
||||||
|
|||||||
@@ -6,6 +6,67 @@ All notable changes to Dash. Format follows
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
## [0.7.0] — 2026-09-21
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -77,7 +138,9 @@ All notable changes to Dash. Format follows
|
|||||||
- Single Go binary serving the embedded React UI; SQLite via goose migrations;
|
- Single Go binary serving the embedded React UI; SQLite via goose migrations;
|
||||||
Docker image on distroless
|
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.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.6.0]: https://github.com/Dvorinka/Dash/compare/v0.5.0...v0.6.0
|
||||||
[0.5.0]: https://github.com/Dvorinka/Dash/compare/v0.4.0...v0.5.0
|
[0.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`.
|
and `-- +goose Down`.
|
||||||
- Board renderers share state, drag-drop, and popover primitives. Never
|
- Board renderers share state, drag-drop, and popover primitives. Never
|
||||||
reimplement them per renderer.
|
reimplement them per renderer.
|
||||||
- No auth, no YAML-as-source-of-truth, no external database. These are
|
- Auth is opt-in and local-only: no OIDC/SSO, no multi-user RBAC. No
|
||||||
non-goals for v1.
|
YAML-as-source-of-truth, no external database. These are non-goals.
|
||||||
- Standard library before dependencies; existing helpers before new ones.
|
- Standard library before dependencies; existing helpers before new ones.
|
||||||
- User-visible strings go through `t("key")` from `src/i18n` — add the key to
|
- User-visible strings go through `t("key")` from `src/i18n` — add the key to
|
||||||
`en.ts` rather than inlining literals in new UI.
|
`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.
|
<h1 align="center">Dash</h1>
|
||||||
Single container, UI-managed, no YAML. Optional auth.
|
|
||||||
|
|
||||||

|
<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
|
## Features
|
||||||
|
|
||||||
- **Service board** — multiple boards, sections, drag-and-drop, multi-URL
|
- **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.
|
||||||
services, icon upload, four renderers (Bento, Cards, Index, Console),
|
- **Uptime monitors** — HTTP, TCP, ping, DNS, keyword, JSON-path, and push checks on a schedule; heartbeat history, uptime graphs, per-monitor alert rules.
|
||||||
dark/light themes, custom accent/wallpaper/CSS
|
- **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.
|
||||||
- **Uptime monitors** — HTTP, TCP, ping, DNS, keyword, JSON-path, and push
|
- **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.
|
||||||
checks on a schedule, heartbeat history, uptime graphs, per-monitor alert
|
- **Incidents & status pages** — automatic incidents on monitor down, maintenance windows, public `/status/:slug` pages, SVG badges.
|
||||||
rules
|
- **Alerts** — webhook, Slack, Discord, Telegram, Gotify, ntfy, and SMTP transports.
|
||||||
- **Domain watch** — WHOIS/RDAP, DNS records, TLS certificate expiry,
|
- **Observability** — Prometheus `/api/metrics`, JSON export, CSV bulk import, importers for Homepage, Homarr, and Dashy configs.
|
||||||
registrar and provider detection, CT-log subdomain discovery
|
- **Optional auth** — local username/password, bcrypt + SQLite sessions, off by default; public status pages stay open either way.
|
||||||
- **System monitoring** — lightweight `dash-agent` pushes CPU, memory, disk,
|
- **i18n + PWA** — English and Czech UI, installable manifest/service worker.
|
||||||
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
|
|
||||||
|
|
||||||
## 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
|
```sh
|
||||||
docker run -d -v ./data:/data -p 3000:3000 ghcr.io/dvorinka/dash:latest
|
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
|
## Configuration
|
||||||
services:
|
|
||||||
dash:
|
|
||||||
image: ghcr.io/dvorinka/dash:latest
|
|
||||||
ports:
|
|
||||||
- "3000:3000"
|
|
||||||
volumes:
|
|
||||||
- ./data:/data
|
|
||||||
restart: unless-stopped
|
|
||||||
```
|
|
||||||
|
|
||||||
Then open http://localhost:3000. Everything is configured from the UI.
|
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
|
## 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>
|
dash-agent -url http://dash:3000 -token <token>
|
||||||
```
|
```
|
||||||
|
|
||||||
One static binary, no dependencies. See
|
One static binary, no dependencies. systemd unit and container image covered
|
||||||
[docs/installation.md](docs/installation.md) for systemd and Docker setups.
|
in [docs/installation.md](docs/installation.md#dash-agent-system-monitoring).
|
||||||
|
|
||||||
## Screenshots
|
## Documentation
|
||||||
|
|
||||||
| | |
|
- [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`)
|
||||||
## Docs
|
- [ROADMAP.md](ROADMAP.md) — design decisions and release history
|
||||||
|
|
||||||
- [Installation](docs/installation.md)
|
|
||||||
- [Configuration](docs/configuration.md)
|
|
||||||
- [Widget development](docs/widgets.md)
|
|
||||||
- [Roadmap](ROADMAP.md) · [Changelog](CHANGELOG.md) · [Contributing](CONTRIBUTING.md)
|
|
||||||
|
|
||||||
## Develop
|
## Develop
|
||||||
|
|
||||||
@@ -80,21 +128,21 @@ Requires Go 1.24+ and Node 20+.
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
npm install # workspaces: frontend + api-client
|
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
|
npm run dev # vite dev on :5173, proxies /api to :8080
|
||||||
```
|
```
|
||||||
|
|
||||||
In another shell:
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cd apps/backend
|
cd apps/backend
|
||||||
DASH_DEV=1 go run ./cmd/dash # API on :8080 (DASH_DEV skips embedded UI)
|
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 +
|
See [CONTRIBUTING.md](CONTRIBUTING.md) for the development workflow.
|
||||||
Vite + TS strict + Tailwind + shadcn/ui + dnd-kit · OpenAPI-generated client.
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
See [SECURITY.md](SECURITY.md) for reporting vulnerabilities.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -149,27 +149,27 @@ becomes the summary layer over the monitoring pages.
|
|||||||
|
|
||||||
Uptime checking: the backbone everything else hangs off.
|
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)
|
- [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)
|
||||||
- [ ] In-process scheduler: due-monitor sweep, bounded worker pool, heartbeat write, status transitions
|
- [x] 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)
|
- [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)
|
||||||
- [ ] API: `/api/monitors` CRUD, `/:id/heartbeats?range=`, pause/resume, check-now
|
- [x] API: `/api/monitors` CRUD, `/:id/heartbeats?range=`, pause/resume, check-now
|
||||||
- [ ] Monitors page (status, uptime 24h/30d, ping, interval) + detail (response chart, heartbeat log)
|
- [x] Monitors page (status, uptime 24h/30d, ping, interval) + detail (response chart, heartbeat log)
|
||||||
- [ ] Board widget type `monitor` bound to a monitor id
|
- [x] Board widget type `monitor` bound to a monitor id
|
||||||
- [ ] ServiceDialog checkbox: create matching http monitor from a service URL
|
- [x] 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] The fork's ~30 exotic monitor types were TCP stubs — not ported. Real types only.
|
||||||
|
|
||||||
### Phase M2 — Domains → v0.5.0
|
### Phase M2 — Domains → v0.5.0
|
||||||
|
|
||||||
Domain-locker-grade domain intelligence.
|
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)
|
- [x] 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
|
- [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
|
||||||
- [ ] Daily scheduler + manual refresh; field diffs recorded to `domain_history`
|
- [x] 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)
|
- [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)
|
||||||
- [ ] Domains page + detail (expiry countdown, registrar, SSL, DNS, subdomains)
|
- [x] Domains page + detail (expiry countdown, registrar, SSL, DNS, subdomains)
|
||||||
- [ ] Board widget `domain` (expiry countdown)
|
- [x] Board widget `domain` (expiry countdown)
|
||||||
- [ ] Subdomain discovery: port `subdomain_discovery.go`, opt-in per domain
|
- [x] 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] Not ported (v1): whoisxml API, EURid web scraping, SEO/robots parsing, valuation estimates — demand only
|
||||||
|
|
||||||
### Phase M3 — Systems → v0.6.0
|
### Phase M3 — Systems → v0.6.0
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
| latest release | yes |
|
| latest release | yes |
|
||||||
| older releases | no |
|
| older releases | no |
|
||||||
|
|
||||||
Dash is pre-1.0; only the latest release receives fixes.
|
Only the latest release receives fixes.
|
||||||
|
|
||||||
## Reporting a vulnerability
|
## 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,
|
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,
|
host_region, host_city, host_isp, host_org, host_as, host_lat, host_lon,
|
||||||
dns_provider, email_provider, hosting_provider, ca_provider, headers,
|
dns_provider, email_provider, hosting_provider, ca_provider, headers,
|
||||||
|
records, providers,
|
||||||
favicon_url, lookup_error, interval_h, last_checked, position, alerts, created_at`
|
favicon_url, lookup_error, interval_h, last_checked, position, alerts, created_at`
|
||||||
|
|
||||||
// domainRow mirrors the domains table; JSON text columns decode on scan.
|
// domainRow mirrors the domains table; JSON text columns decode on scan.
|
||||||
@@ -82,6 +83,8 @@ type domainRow struct {
|
|||||||
HostingProvider string `json:"hostingProvider"`
|
HostingProvider string `json:"hostingProvider"`
|
||||||
CAProvider string `json:"caProvider"`
|
CAProvider string `json:"caProvider"`
|
||||||
Headers map[string]string `json:"headers"`
|
Headers map[string]string `json:"headers"`
|
||||||
|
Records []domain.DNSRecord `json:"records"`
|
||||||
|
Providers domain.Providers `json:"providers"`
|
||||||
FaviconURL string `json:"faviconUrl"`
|
FaviconURL string `json:"faviconUrl"`
|
||||||
LookupError string `json:"lookupError"`
|
LookupError string `json:"lookupError"`
|
||||||
IntervalH int `json:"intervalH"`
|
IntervalH int `json:"intervalH"`
|
||||||
@@ -93,7 +96,7 @@ type domainRow struct {
|
|||||||
|
|
||||||
func scanDomain(row interface{ Scan(...any) error }) (*domainRow, error) {
|
func scanDomain(row interface{ Scan(...any) error }) (*domainRow, error) {
|
||||||
var d domainRow
|
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
|
var active, autoRenew, privacy, lock int
|
||||||
err := row.Scan(&d.ID, &d.Name, &d.TLD, &active, &autoRenew, &d.AlertDaysBefore,
|
err := row.Scan(&d.ID, &d.Name, &d.TLD, &active, &autoRenew, &d.AlertDaysBefore,
|
||||||
&tags, &d.Notes, &d.ExpiryDate, &d.CreationDate, &d.UpdatedDate,
|
&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.SSLKeySize, &d.SSLSigAlgo, &altNames, &d.HostCountry, &d.HostCountryCode,
|
||||||
&d.HostRegion, &d.HostCity, &d.HostISP, &d.HostOrg, &d.HostAS, &d.HostLat,
|
&d.HostRegion, &d.HostCity, &d.HostISP, &d.HostOrg, &d.HostAS, &d.HostLat,
|
||||||
&d.HostLon, &d.DNSProvider, &d.EmailProvider, &d.HostingProvider, &d.CAProvider,
|
&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)
|
&d.Position, &alerts, &d.CreatedAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -124,6 +127,9 @@ func scanDomain(row interface{ Scan(...any) error }) (*domainRow, error) {
|
|||||||
d.SSLAltNames = decodeList(altNames)
|
d.SSLAltNames = decodeList(altNames)
|
||||||
d.Headers = map[string]string{}
|
d.Headers = map[string]string{}
|
||||||
_ = json.Unmarshal([]byte(headers), &d.Headers)
|
_ = 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
|
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)
|
expiry, created, updated = fmtT(r.ExpiryDate), fmtT(r.CreationDate), fmtT(r.UpdatedDate)
|
||||||
sslFrom, sslTo = fmtT(r.SSLValidFrom), fmtT(r.SSLValidTo)
|
sslFrom, sslTo = fmtT(r.SSLValidFrom), fmtT(r.SSLValidTo)
|
||||||
hdr, _ := json.Marshal(r.Headers)
|
hdr, _ := json.Marshal(r.Headers)
|
||||||
|
recs, _ := json.Marshal(r.Records)
|
||||||
|
provs, _ := json.Marshal(r.Providers)
|
||||||
|
|
||||||
_, err := s.db.Exec(`UPDATE domains SET
|
_, err := s.db.Exec(`UPDATE domains SET
|
||||||
expiry_date=?, creation_date=?, updated_date=?, registrar_name=?,
|
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=?,
|
ssl_key_size=?, ssl_sig_algo=?, ssl_alt_names=?, host_country=?,
|
||||||
host_country_code=?, host_region=?, host_city=?, host_isp=?, host_org=?,
|
host_country_code=?, host_region=?, host_city=?, host_isp=?, host_org=?,
|
||||||
host_as=?, host_lat=?, host_lon=?, dns_provider=?, email_provider=?,
|
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')
|
lookup_error=?, last_checked=strftime('%Y-%m-%dT%H:%M:%fZ','now')
|
||||||
WHERE id=?`,
|
WHERE id=?`,
|
||||||
expiry, created, updated, r.RegistrarName, r.RegistrarID, r.RegistrarURL,
|
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,
|
enc(r.SSLAltNames), r.HostCountry, r.HostCountryCode, r.HostRegion,
|
||||||
r.HostCity, r.HostISP, r.HostOrg, r.HostAS, r.HostLat, r.HostLon,
|
r.HostCity, r.HostISP, r.HostOrg, r.HostAS, r.HostLat, r.HostLon,
|
||||||
r.DNSProvider, r.EmailProvider, r.HostingProvider, r.CAProvider,
|
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 {
|
if err != nil {
|
||||||
s.log.Error("domain apply", zap.Error(err))
|
s.log.Error("domain apply", zap.Error(err))
|
||||||
return
|
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"`
|
HostingProvider string `json:"hostingProvider"`
|
||||||
CAProvider string `json:"caProvider"`
|
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"`
|
Headers map[string]string `json:"headers"`
|
||||||
FaviconURL string `json:"faviconUrl"`
|
FaviconURL string `json:"faviconUrl"`
|
||||||
Error string `json:"error"` // non-fatal partial failure note
|
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.
|
// 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{},
|
IPv4: []string{},
|
||||||
IPv6: []string{},
|
IPv6: []string{},
|
||||||
SSLAltNames: []string{},
|
SSLAltNames: []string{},
|
||||||
|
Records: []DNSRecord{},
|
||||||
Headers: map[string]string{},
|
Headers: map[string]string{},
|
||||||
FaviconURL: fmt.Sprintf("https://www.google.com/s2/favicons?domain=%s&sz=128", name),
|
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.host(r.IPv4[0], r)
|
||||||
}
|
}
|
||||||
l.headers(ctx, name, r)
|
l.headers(ctx, name, r)
|
||||||
|
l.identify(r)
|
||||||
r.DNSProvider = detectDNSProvider(r.NameServers)
|
|
||||||
r.EmailProvider = detectEmailProvider(r.MXRecords)
|
|
||||||
r.HostingProvider = detectHostingProvider(r.Headers)
|
|
||||||
r.CAProvider = detectCA(r.SSLIssuer)
|
|
||||||
return 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 --------------------------------------------------------------------
|
// --- DNS --------------------------------------------------------------------
|
||||||
|
|
||||||
func (l *Lookup) dns(ctx context.Context, name string, r *Result) {
|
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 {
|
if mx, err := res.LookupMX(ctx, name); err == nil {
|
||||||
for _, m := range mx {
|
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 {
|
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) -----------
|
// --- 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 {
|
type provMatch struct{ sub, name, dom string }
|
||||||
table := []struct{ sub, name string }{
|
|
||||||
{"cloudflare", "Cloudflare"}, {"awsdns", "Amazon Route 53"},
|
func prov(name, dom string) *Provider {
|
||||||
{"googledomains", "Google Domains"}, {"google.com", "Google Domains"},
|
return &Provider{Name: name, Domain: dom,
|
||||||
{"namecheap", "Namecheap"}, {"godaddy", "GoDaddy"}, {"domaincontrol", "GoDaddy"},
|
Icon: fmt.Sprintf("https://www.google.com/s2/favicons?domain=%s&sz=64", dom)}
|
||||||
{"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)
|
func matchProvider(haystack string, table []provMatch) *Provider {
|
||||||
|
h := strings.ToLower(haystack)
|
||||||
for _, t := range table {
|
for _, t := range table {
|
||||||
if strings.Contains(n, t.sub) {
|
if strings.Contains(h, t.sub) {
|
||||||
return t.name
|
return prov(t.name, t.dom)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
return nil
|
||||||
return ""
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func detectEmailProvider(mx []string) string {
|
var nsProviders = []provMatch{
|
||||||
table := []struct{ sub, name string }{
|
{"cloudflare", "Cloudflare", "cloudflare.com"},
|
||||||
{"google.com", "Google Workspace"}, {"aspmx", "Google Workspace"},
|
{"awsdns", "Amazon Route 53", "aws.amazon.com"},
|
||||||
{"outlook.com", "Microsoft 365"}, {"protection.outlook", "Microsoft 365"},
|
{"googledomains", "Google Domains", "domains.google"},
|
||||||
{"mail.protection", "Microsoft 365"}, {"protonmail", "Proton Mail"},
|
{"dns.google", "Google Cloud DNS", "cloud.google.com"},
|
||||||
{"proton.me", "Proton Mail"}, {"zoho", "Zoho Mail"},
|
{"azure-dns", "Microsoft Azure", "azure.microsoft.com"},
|
||||||
{"fastmail", "Fastmail"}, {"mimecast", "Mimecast"},
|
{"domaincontrol", "GoDaddy", "godaddy.com"},
|
||||||
{"proofpoint", "Proofpoint"}, {"barracuda", "Barracuda"},
|
{"godaddy", "GoDaddy", "godaddy.com"},
|
||||||
{"mailgun", "Mailgun"}, {"sendgrid", "SendGrid"},
|
{"registrar-servers", "Namecheap", "namecheap.com"},
|
||||||
{"amazonses", "Amazon SES"}, {"yandex", "Yandex"},
|
{"namecheap", "Namecheap", "namecheap.com"},
|
||||||
{"tuta", "Tuta"}, {"mailbox.org", "mailbox.org"},
|
{"nsone.net", "NS1", "ns1.com"},
|
||||||
}
|
{"digitalocean", "DigitalOcean", "digitalocean.com"},
|
||||||
for _, m := range mx {
|
{"linode", "Akamai", "akamai.com"},
|
||||||
m = strings.ToLower(m)
|
{"akamaitech", "Akamai", "akamai.com"},
|
||||||
for _, t := range table {
|
{"vultr", "Vultr", "vultr.com"},
|
||||||
if strings.Contains(m, t.sub) {
|
{"he.net", "Hurricane Electric", "he.net"},
|
||||||
return t.name
|
{"dynect", "Dyn (Oracle)", "dyn.com"},
|
||||||
}
|
{"ultradns", "UltraDNS", "verisign.com"},
|
||||||
}
|
{"dnsimple", "DNSimple", "dnsimple.com"},
|
||||||
}
|
{"hover", "Hover", "hover.com"},
|
||||||
return ""
|
{"enom", "eNom", "enom.com"},
|
||||||
|
{"worldnic", "Network Solutions", "networksolutions.com"},
|
||||||
|
{"zoneedit", "ZoneEdit", "zoneedit.com"},
|
||||||
|
{"easydns", "EasyDNS", "easydns.com"},
|
||||||
|
{"gandi", "Gandi", "gandi.net"},
|
||||||
|
{"ovh", "OVHcloud", "ovhcloud.com"},
|
||||||
|
{"hetzner", "Hetzner", "hetzner.com"},
|
||||||
|
{"your-server.de", "Hetzner", "hetzner.com"},
|
||||||
|
{"porkbun", "Porkbun", "porkbun.com"},
|
||||||
|
{"cloudns", "ClouDNS", "cloudns.net"},
|
||||||
|
{"desec", "deSEC", "desec.io"},
|
||||||
|
{"nic.cz", "CZ.NIC", "nic.cz"},
|
||||||
|
{"dnsmadeeasy", "DNS Made Easy", "dnsmadeeasy.com"},
|
||||||
|
{"ui-dns", "IONOS", "ionos.com"},
|
||||||
|
{"squarespacedns", "Squarespace", "squarespace.com"},
|
||||||
|
{"wordpress.com", "WordPress.com", "wordpress.com"},
|
||||||
|
{"dns-parking.com", "Hostinger", "hostinger.com"},
|
||||||
|
{"hostinger", "Hostinger", "hostinger.com"},
|
||||||
|
{"siteground", "SiteGround", "siteground.com"},
|
||||||
|
{"dreamhost", "DreamHost", "dreamhost.com"},
|
||||||
|
{"inmotionhosting", "InMotion", "inmotionhosting.com"},
|
||||||
|
{"bluehost", "Bluehost", "bluehost.com"},
|
||||||
|
{"hostgator", "HostGator", "hostgator.com"},
|
||||||
|
{"kinstadns", "Kinsta", "kinsta.com"},
|
||||||
|
{"kasserver", "ALL-INKL.COM", "all-inkl.com"},
|
||||||
|
{"all-inkl", "ALL-INKL.COM", "all-inkl.com"},
|
||||||
|
{"strato", "STRATO", "strato.de"},
|
||||||
|
{"udagdns", "United Domains", "uniteddomains.com"},
|
||||||
|
{"udag.de", "United Domains", "uniteddomains.com"},
|
||||||
|
{"transip", "TransIP", "transip.nl"},
|
||||||
|
{"infomaniak", "Infomaniak", "infomaniak.com"},
|
||||||
|
{"one.com", "One.com", "one.com"},
|
||||||
|
{"aruba", "Aruba", "aruba.it"},
|
||||||
|
{"technorail", "Aruba", "aruba.it"},
|
||||||
|
{"inwx", "INWX", "inwx.com"},
|
||||||
|
{"kasserver.com", "ALL-INKL.COM", "all-inkl.com"},
|
||||||
|
{"contabo", "Contabo", "contabo.com"},
|
||||||
|
{"netcup", "netcup", "netcup.eu"},
|
||||||
|
{"glesys", "GleSYS", "glesys.se"},
|
||||||
|
{"scaleway", "Scaleway", "scaleway.com"},
|
||||||
|
{"exoscale", "Exoscale", "exoscale.com"},
|
||||||
|
{"dnspod", "DNSPod", "dnspod.com"},
|
||||||
|
{"hicloud", "Huawei Cloud", "huaweicloud.com"},
|
||||||
|
{"alidns", "Alibaba Cloud", "alibabacloud.com"},
|
||||||
|
{"purelymail", "Purelymail", "purelymail.com"},
|
||||||
|
{"migadu", "Migadu", "migadu.com"},
|
||||||
|
{"improvmx", "ImprovMX", "improvmx.com"},
|
||||||
|
{"bunny", "Bunny", "bunny.net"},
|
||||||
|
{"edgekey", "Akamai", "akamai.com"},
|
||||||
|
{"edgesuite", "Akamai", "akamai.com"},
|
||||||
|
{"fastly", "Fastly", "fastly.com"},
|
||||||
|
{"cdn77", "CDN77", "cdn77.com"},
|
||||||
|
{"stackpath", "StackPath", "stackpath.com"},
|
||||||
|
{"ns.cloudflare", "Cloudflare", "cloudflare.com"},
|
||||||
|
{"vercel-dns", "Vercel", "vercel.com"},
|
||||||
}
|
}
|
||||||
|
|
||||||
func detectHostingProvider(h map[string]string) string {
|
var mailProviders = []provMatch{
|
||||||
|
{"aspmx", "Google Workspace", "google.com"},
|
||||||
|
{"googlemail", "Google Workspace", "google.com"},
|
||||||
|
{"google.com", "Google Workspace", "google.com"},
|
||||||
|
{"outlook.com", "Microsoft 365", "microsoft.com"},
|
||||||
|
{"protection.outlook", "Microsoft 365", "microsoft.com"},
|
||||||
|
{"mail.protection", "Microsoft 365", "microsoft.com"},
|
||||||
|
{"protonmail", "Proton Mail", "proton.me"},
|
||||||
|
{"proton.me", "Proton Mail", "proton.me"},
|
||||||
|
{"zoho", "Zoho Mail", "zoho.com"},
|
||||||
|
{"fastmail", "Fastmail", "fastmail.com"},
|
||||||
|
{"messagingengine", "Fastmail", "fastmail.com"},
|
||||||
|
{"mimecast", "Mimecast", "mimecast.com"},
|
||||||
|
{"proofpoint", "Proofpoint", "proofpoint.com"},
|
||||||
|
{"barracuda", "Barracuda", "barracuda.com"},
|
||||||
|
{"mailgun", "Mailgun", "mailgun.com"},
|
||||||
|
{"sendgrid", "SendGrid", "sendgrid.com"},
|
||||||
|
{"amazonses", "Amazon SES", "aws.amazon.com"},
|
||||||
|
{"yandex", "Yandex", "yandex.com"},
|
||||||
|
{"tuta", "Tuta", "tuta.com"},
|
||||||
|
{"mailbox.org", "mailbox.org", "mailbox.org"},
|
||||||
|
{"purelymail", "Purelymail", "purelymail.com"},
|
||||||
|
{"migadu", "Migadu", "migadu.com"},
|
||||||
|
{"mxroute", "MXroute", "mxroute.com"},
|
||||||
|
{"mymxroute", "MXroute", "mxroute.com"},
|
||||||
|
{"icloud.com", "iCloud Mail", "apple.com"},
|
||||||
|
{"infomaniak", "Infomaniak", "infomaniak.com"},
|
||||||
|
{"mailpod", "One.com", "one.com"},
|
||||||
|
{"one.com", "One.com", "one.com"},
|
||||||
|
{"gmx", "GMX", "gmx.net"},
|
||||||
|
{"forwardemail", "Forward Email", "forwardemail.net"},
|
||||||
|
{"posteo", "Posteo", "posteo.de"},
|
||||||
|
{"mailo", "Mailo", "mailo.com"},
|
||||||
|
{"hey.com", "HEY", "hey.com"},
|
||||||
|
{"privateemail", "Namecheap Private Email", "namecheap.com"},
|
||||||
|
{"registrar-servers", "Namecheap", "namecheap.com"},
|
||||||
|
{"ovh.net", "OVHcloud Mail", "ovhcloud.com"},
|
||||||
|
{"perfora.net", "IONOS Mail", "ionos.com"},
|
||||||
|
{"ui-dns", "IONOS", "ionos.com"},
|
||||||
|
{"improvmx", "ImprovMX", "improvmx.com"},
|
||||||
|
{"porkbun", "Porkbun Mail", "porkbun.com"},
|
||||||
|
{"kundenserver", "IONOS Mail", "ionos.com"},
|
||||||
|
{"t-online", "T-Online", "telekom.de"},
|
||||||
|
{"web.de", "WEB.DE", "web.de"},
|
||||||
|
{"mail.ru", "Mail.ru", "mail.ru"},
|
||||||
|
{"qq.com", "QQ Mail", "qq.com"},
|
||||||
|
{"163.com", "NetEase Mail", "163.com"},
|
||||||
|
{"exim.rs", "Exim", "exim.org"},
|
||||||
|
{"mx.cloudflare", "Cloudflare Email Routing", "cloudflare.com"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// hostTargets: CNAME targets that reveal the hosting platform.
|
||||||
|
var hostTargets = []provMatch{
|
||||||
|
{"github.io", "GitHub Pages", "github.com"},
|
||||||
|
{"vercel-dns", "Vercel", "vercel.com"},
|
||||||
|
{"vercel.app", "Vercel", "vercel.com"},
|
||||||
|
{"netlify", "Netlify", "netlify.com"},
|
||||||
|
{"cloudfront.net", "AWS CloudFront", "aws.amazon.com"},
|
||||||
|
{"azurefd.net", "Azure Front Door", "azure.microsoft.com"},
|
||||||
|
{"azureedge.net", "Azure CDN", "azure.microsoft.com"},
|
||||||
|
{"cloudapp.net", "Microsoft Azure", "azure.microsoft.com"},
|
||||||
|
{"trafficmanager.net", "Microsoft Azure", "azure.microsoft.com"},
|
||||||
|
{"herokuapp.com", "Heroku", "heroku.com"},
|
||||||
|
{"herokudns.com", "Heroku", "heroku.com"},
|
||||||
|
{"workers.dev", "Cloudflare Workers", "cloudflare.com"},
|
||||||
|
{"pages.dev", "Cloudflare Pages", "cloudflare.com"},
|
||||||
|
{"cdn.cloudflare", "Cloudflare", "cloudflare.com"},
|
||||||
|
{"fly.dev", "Fly.io", "fly.io"},
|
||||||
|
{"onrender.com", "Render", "render.com"},
|
||||||
|
{"railway.app", "Railway", "railway.app"},
|
||||||
|
{"surge.sh", "Surge", "surge.sh"},
|
||||||
|
{"neocities.org", "Neocities", "neocities.org"},
|
||||||
|
{"readthedocs", "Read the Docs", "readthedocs.io"},
|
||||||
|
{"webflow.io", "Webflow", "webflow.com"},
|
||||||
|
{"squarespace.com", "Squarespace", "squarespace.com"},
|
||||||
|
{"wixdns.net", "Wix", "wix.com"},
|
||||||
|
{"wixsite.com", "Wix", "wix.com"},
|
||||||
|
{"editorx.com", "Wix Studio", "wix.com"},
|
||||||
|
{"myshopify.com", "Shopify", "shopify.com"},
|
||||||
|
{"wpengine", "WP Engine", "wpengine.com"},
|
||||||
|
{"wordpress.com", "WordPress.com", "wordpress.com"},
|
||||||
|
{"googlehosted.com", "Google", "google.com"},
|
||||||
|
{"googleapis.com", "Google Cloud", "cloud.google.com"},
|
||||||
|
{"fastly.net", "Fastly", "fastly.com"},
|
||||||
|
{"fastlylb.net", "Fastly", "fastly.com"},
|
||||||
|
{"akamaiedge.net", "Akamai", "akamai.com"},
|
||||||
|
{"edgekey.net", "Akamai", "akamai.com"},
|
||||||
|
{"edgesuite.net", "Akamai", "akamai.com"},
|
||||||
|
{"akamai.net", "Akamai", "akamai.com"},
|
||||||
|
{"b-cdn.net", "Bunny", "bunny.net"},
|
||||||
|
{"bunnycdn", "Bunny", "bunny.net"},
|
||||||
|
{"elb.amazonaws.com", "AWS ELB", "aws.amazon.com"},
|
||||||
|
{"amazonaws.com", "Amazon AWS", "aws.amazon.com"},
|
||||||
|
{"stackpathdns", "StackPath", "stackpath.com"},
|
||||||
|
{"kinsta.cloud", "Kinsta", "kinsta.com"},
|
||||||
|
{"pantheonsite.io", "Pantheon", "pantheon.io"},
|
||||||
|
{"pantheon.io", "Pantheon", "pantheon.io"},
|
||||||
|
{"ghost.io", "Ghost", "ghost.org"},
|
||||||
|
{"hubspot", "HubSpot", "hubspot.com"},
|
||||||
|
{"dnsimple.com", "DNSimple", "dnsimple.com"},
|
||||||
|
{"pressdns.com", "Pressable", "pressable.com"},
|
||||||
|
{"kinsta", "Kinsta", "kinsta.com"},
|
||||||
|
{"cdn77", "CDN77", "cdn77.com"},
|
||||||
|
{"incapsula", "Imperva", "imperva.com"},
|
||||||
|
{"sucuri", "Sucuri", "sucuri.net"},
|
||||||
|
{"flywheel", "Flywheel", "getflywheel.com"},
|
||||||
|
{"gridscale", "gridscale", "gridscale.io"},
|
||||||
|
{"webflow", "Webflow", "webflow.com"},
|
||||||
|
{"framer.app", "Framer", "framer.com"},
|
||||||
|
{"carrd.co", "Carrd", "carrd.co"},
|
||||||
|
{"super.so", "Super", "super.so"},
|
||||||
|
{"typedream.app", "Typedream", "typedream.com"},
|
||||||
|
{"cargo.site", "Cargo", "cargo.site"},
|
||||||
|
{"gitlab.io", "GitLab Pages", "gitlab.com"},
|
||||||
|
{"bitbucket.io", "Bitbucket", "bitbucket.org"},
|
||||||
|
{"codeberg.page", "Codeberg Pages", "codeberg.org"},
|
||||||
|
{"glitch.me", "Glitch", "glitch.com"},
|
||||||
|
{"repl.co", "Replit", "replit.com"},
|
||||||
|
{"replit.app", "Replit", "replit.com"},
|
||||||
|
{"deno.dev", "Deno Deploy", "deno.com"},
|
||||||
|
{"supabase.co", "Supabase", "supabase.com"},
|
||||||
|
{"firebaseapp.com", "Firebase", "firebase.google.com"},
|
||||||
|
{"web.app", "Firebase", "firebase.google.com"},
|
||||||
|
{"appspot.com", "Google App Engine", "cloud.google.com"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// asnProviders: ip-api org/AS strings → hosting vendor.
|
||||||
|
var asnProviders = []provMatch{
|
||||||
|
{"cloudflare", "Cloudflare", "cloudflare.com"},
|
||||||
|
{"akamai", "Akamai", "akamai.com"},
|
||||||
|
{"linode", "Akamai (Linode)", "linode.com"},
|
||||||
|
{"amazon", "Amazon AWS", "aws.amazon.com"},
|
||||||
|
{"aws", "Amazon AWS", "aws.amazon.com"},
|
||||||
|
{"google", "Google Cloud", "cloud.google.com"},
|
||||||
|
{"microsoft", "Microsoft Azure", "azure.microsoft.com"},
|
||||||
|
{"azure", "Microsoft Azure", "azure.microsoft.com"},
|
||||||
|
{"digitalocean", "DigitalOcean", "digitalocean.com"},
|
||||||
|
{"hetzner", "Hetzner", "hetzner.com"},
|
||||||
|
{"ovh", "OVHcloud", "ovhcloud.com"},
|
||||||
|
{"kimsufi", "OVHcloud", "ovhcloud.com"},
|
||||||
|
{"soyoustart", "OVHcloud", "ovhcloud.com"},
|
||||||
|
{"vultr", "Vultr", "vultr.com"},
|
||||||
|
{"choopa", "Vultr", "vultr.com"},
|
||||||
|
{"fastly", "Fastly", "fastly.com"},
|
||||||
|
{"vercel", "Vercel", "vercel.com"},
|
||||||
|
{"netlify", "Netlify", "netlify.com"},
|
||||||
|
{"github", "GitHub", "github.com"},
|
||||||
|
{"scaleway", "Scaleway", "scaleway.com"},
|
||||||
|
{"oracle", "Oracle Cloud", "oracle.com"},
|
||||||
|
{"alibaba", "Alibaba Cloud", "alibabacloud.com"},
|
||||||
|
{"tencent", "Tencent Cloud", "cloud.tencent.com"},
|
||||||
|
{"contabo", "Contabo", "contabo.com"},
|
||||||
|
{"ionos", "IONOS", "ionos.com"},
|
||||||
|
{"1&1", "IONOS", "ionos.com"},
|
||||||
|
{"shopify", "Shopify", "shopify.com"},
|
||||||
|
{"netcup", "netcup", "netcup.eu"},
|
||||||
|
{"upcloud", "UpCloud", "upcloud.com"},
|
||||||
|
{"kamatera", "Kamatera", "kamatera.com"},
|
||||||
|
{"fly.io", "Fly.io", "fly.io"},
|
||||||
|
{"render", "Render", "render.com"},
|
||||||
|
{"railway", "Railway", "railway.app"},
|
||||||
|
{"bunny", "Bunny", "bunny.net"},
|
||||||
|
{"hostinger", "Hostinger", "hostinger.com"},
|
||||||
|
{"siteground", "SiteGround", "siteground.com"},
|
||||||
|
{"godaddy", "GoDaddy", "godaddy.com"},
|
||||||
|
{"automattic", "WordPress.com", "wordpress.com"},
|
||||||
|
{"squarespace", "Squarespace", "squarespace.com"},
|
||||||
|
{"wix", "Wix", "wix.com"},
|
||||||
|
{"infomaniak", "Infomaniak", "infomaniak.com"},
|
||||||
|
{"dreamhost", "DreamHost", "dreamhost.com"},
|
||||||
|
{"liquid web", "Liquid Web", "liquidweb.com"},
|
||||||
|
{"liquidweb", "Liquid Web", "liquidweb.com"},
|
||||||
|
{"inmotion", "InMotion", "inmotionhosting.com"},
|
||||||
|
{"unified layer", "Bluehost", "bluehost.com"},
|
||||||
|
{"hostgator", "HostGator", "hostgator.com"},
|
||||||
|
{"glesys", "GleSYS", "glesys.se"},
|
||||||
|
{"exoscale", "Exoscale", "exoscale.com"},
|
||||||
|
{"incapsula", "Imperva", "imperva.com"},
|
||||||
|
{"sucuri", "Sucuri", "sucuri.net"},
|
||||||
|
{"ddos-guard", "DDoS-Guard", "ddos-guard.net"},
|
||||||
|
{"huawei", "Huawei Cloud", "huaweicloud.com"},
|
||||||
|
{"leaseweb", "Leaseweb", "leaseweb.com"},
|
||||||
|
{"aruba", "Aruba", "aruba.it"},
|
||||||
|
{"krystal", "Krystal", "krystal.uk"},
|
||||||
|
{"namecheap", "Namecheap", "namecheap.com"},
|
||||||
|
{"web-hosting.com", "Namecheap", "namecheap.com"},
|
||||||
|
{"zendesk", "Zendesk", "zendesk.com"},
|
||||||
|
{"kinsta", "Kinsta", "kinsta.com"},
|
||||||
|
{"flywheel", "Flywheel", "getflywheel.com"},
|
||||||
|
{"wp engine", "WP Engine", "wpengine.com"},
|
||||||
|
{"pantheon", "Pantheon", "pantheon.io"},
|
||||||
|
{"meta", "Meta", "meta.com"},
|
||||||
|
{"gitlab", "GitLab", "gitlab.com"},
|
||||||
|
{"stackpath", "StackPath", "stackpath.com"},
|
||||||
|
{"cdn77", "CDN77", "cdn77.com"},
|
||||||
|
{"imperva", "Imperva", "imperva.com"},
|
||||||
|
{"framer", "Framer", "framer.com"},
|
||||||
|
{"webflow", "Webflow", "webflow.com"},
|
||||||
|
{"readthedocs", "Read the Docs", "readthedocs.io"},
|
||||||
|
{"transip", "TransIP", "transip.nl"},
|
||||||
|
{"strato", "STRATO", "strato.de"},
|
||||||
|
{"all-inkl", "ALL-INKL.COM", "all-inkl.com"},
|
||||||
|
{"one.com", "One.com", "one.com"},
|
||||||
|
{"netlify", "Netlify", "netlify.com"},
|
||||||
|
{"neocities", "Neocities", "neocities.org"},
|
||||||
|
}
|
||||||
|
|
||||||
|
var registrarProviders = []provMatch{
|
||||||
|
{"markmonitor", "MarkMonitor", "markmonitor.com"},
|
||||||
|
{"namecheap", "Namecheap", "namecheap.com"},
|
||||||
|
{"cloudflare", "Cloudflare", "cloudflare.com"},
|
||||||
|
{"godaddy", "GoDaddy", "godaddy.com"},
|
||||||
|
{"gandi", "Gandi", "gandi.net"},
|
||||||
|
{"porkbun", "Porkbun", "porkbun.com"},
|
||||||
|
{"ovh", "OVHcloud", "ovhcloud.com"},
|
||||||
|
{"name.com", "Name.com", "name.com"},
|
||||||
|
{"ionos", "IONOS", "ionos.com"},
|
||||||
|
{"1&1", "IONOS", "ionos.com"},
|
||||||
|
{"1und1", "IONOS", "ionos.com"},
|
||||||
|
{"tucows", "Tucows", "tucows.com"},
|
||||||
|
{"squarespace", "Squarespace", "squarespace.com"},
|
||||||
|
{"google", "Google Domains", "domains.google"},
|
||||||
|
{"dynadot", "Dynadot", "dynadot.com"},
|
||||||
|
{"namesilo", "NameSilo", "namesilo.com"},
|
||||||
|
{"epik", "Epik", "epik.com"},
|
||||||
|
{"hexonet", "HEXONET", "hexonet.net"},
|
||||||
|
{"1api", "HEXONET", "hexonet.net"},
|
||||||
|
{"openprovider", "Openprovider", "openprovider.com"},
|
||||||
|
{"internetx", "InterNetX", "internetx.com"},
|
||||||
|
{"nic.cz", "CZ.NIC", "nic.cz"},
|
||||||
|
{"hostinger", "Hostinger", "hostinger.com"},
|
||||||
|
{"com laude", "Com Laude", "comlaude.com"},
|
||||||
|
{"csc", "CSC", "cscdbs.com"},
|
||||||
|
{"safenames", "Safenames", "safenames.net"},
|
||||||
|
{"xinnet", "Xin Net", "xinnet.com"},
|
||||||
|
{"alibaba", "Alibaba Cloud", "alibabacloud.com"},
|
||||||
|
{"dnspod", "DNSPod", "dnspod.com"},
|
||||||
|
{"tencent", "Tencent Cloud", "cloud.tencent.com"},
|
||||||
|
{"gabia", "Gabia", "gabia.com"},
|
||||||
|
{"reg.ru", "REG.RU", "reg.ru"},
|
||||||
|
{"nic.ru", "RU-CENTER", "nic.ru"},
|
||||||
|
{"nordname", "NordName", "nordname.com"},
|
||||||
|
{"internet.bs", "Internet.bs", "internetbs.net"},
|
||||||
|
{"key-systems", "Key-Systems", "key-systems.net"},
|
||||||
|
{"united domains", "United Domains", "uniteddomains.com"},
|
||||||
|
{"united-domains", "United Domains", "uniteddomains.com"},
|
||||||
|
{"scaleway", "Scaleway", "scaleway.com"},
|
||||||
|
{"hetzner", "Hetzner", "hetzner.com"},
|
||||||
|
{"infomaniak", "Infomaniak", "infomaniak.com"},
|
||||||
|
{"one.com", "One.com", "one.com"},
|
||||||
|
{"netim", "Netim", "netim.com"},
|
||||||
|
{"inwx", "INWX", "inwx.com"},
|
||||||
|
{"internetworx", "INWX", "inwx.com"},
|
||||||
|
{"checkdomain", "checkdomain", "checkdomain.de"},
|
||||||
|
{"domaindiscount24", "domaindiscount24", "domaindiscount24.com"},
|
||||||
|
{"easyname", "easyname", "easyname.com"},
|
||||||
|
{"world4you", "World4You", "world4you.com"},
|
||||||
|
{"all-inkl", "ALL-INKL.COM", "all-inkl.com"},
|
||||||
|
{"transip", "TransIP", "transip.nl"},
|
||||||
|
{"strato", "STRATO", "strato.de"},
|
||||||
|
{"hover", "Hover", "hover.com"},
|
||||||
|
{"enom", "eNom", "enom.com"},
|
||||||
|
{"namebright", "NameBright", "namebright.com"},
|
||||||
|
{"sav.com", "Sav.com", "sav.com"},
|
||||||
|
{"turncommerce", "DropCatch", "dropcatch.com"},
|
||||||
|
{"eurodns", "EuroDNS", "eurodns.com"},
|
||||||
|
{"gransy", "Gransy", "regtons.com"},
|
||||||
|
{"101domain", "101domain", "101domain.com"},
|
||||||
|
{"iwantmyname", "iwantmyname", "iwantmyname.com"},
|
||||||
|
{"rebel", "Rebel", "rebel.ca"},
|
||||||
|
{"blacknight", "Blacknight", "blacknight.com"},
|
||||||
|
{"gname", "GNAME", "gname.com"},
|
||||||
|
{"west.cn", "West.cn", "west.cn"},
|
||||||
|
{"west263", "West.cn", "west.cn"},
|
||||||
|
{"imena.ua", "Imena.ua", "imena.ua"},
|
||||||
|
{"ukraine.com.ua", "Hosting Ukraine", "ukraine.com.ua"},
|
||||||
|
{"beget", "Beget", "beget.com"},
|
||||||
|
{"selectel", "Selectel", "selectel.ru"},
|
||||||
|
{"timeweb", "Timeweb", "timeweb.ru"},
|
||||||
|
{"papaki", "Papaki", "papaki.com"},
|
||||||
|
{"dotname", "Dotname", "dotname.co.kr"},
|
||||||
|
{"gabia", "Gabia", "gabia.com"},
|
||||||
|
{"registar-se", "Namecheap", "namecheap.com"},
|
||||||
|
{"registrar-servers", "Namecheap", "namecheap.com"},
|
||||||
|
{"namebay", "Namebay", "namebay.com"},
|
||||||
|
{"domainpeople", "DomainPeople", "domainpeople.com"},
|
||||||
|
{"networksolutions", "Network Solutions", "networksolutions.com"},
|
||||||
|
{"network solutions", "Network Solutions", "networksolutions.com"},
|
||||||
|
{"register.com", "Register.com", "register.com"},
|
||||||
|
{"webcentral", "Webcentral", "webcentral.com.au"},
|
||||||
|
{"melbourne it", "Melbourne IT", "melbourneit.com.au"},
|
||||||
|
{"corporatedomains", "CSC", "cscdbs.com"},
|
||||||
|
{"amazon registrar", "Amazon Registrar", "aws.amazon.com"},
|
||||||
|
{"nominet", "Nominet", "nominet.uk"},
|
||||||
|
{"123-reg", "123 Reg", "123-reg.co.uk"},
|
||||||
|
{"domain.com", "Domain.com", "domain.com"},
|
||||||
|
{"dotster", "Dotster", "dotster.com"},
|
||||||
|
{"joker", "Joker.com", "joker.com"},
|
||||||
|
{"variomedia", "Variomedia", "variomedia.de"},
|
||||||
|
{"goneo", "goneo", "goneo.de"},
|
||||||
|
{"manitu", "manitu", "manitu.de"},
|
||||||
|
{"hosteurope", "Host Europe", "hosteurope.de"},
|
||||||
|
{"keyweb", "Keyweb", "keyweb.de"},
|
||||||
|
{"wedos", "WEDOS", "wedos.com"},
|
||||||
|
{"active24", "Active 24", "active24.com"},
|
||||||
|
{"forpsi", "FORPSI", "forpsi.com"},
|
||||||
|
{"ignum", "IGNUM", "ignum.cz"},
|
||||||
|
{"oneprovider", "OneProvider", "oneprovider.com"},
|
||||||
|
{"reg.cz", "REG.CZ", "reg.cz"},
|
||||||
|
{"subreg", "Websupport", "websupport.sk"},
|
||||||
|
{"websupport", "Websupport", "websupport.sk"},
|
||||||
|
{"loopia", "Loopia", "loopia.se"},
|
||||||
|
{"binero", "Binero", "binero.se"},
|
||||||
|
{"one.se", "One.com", "one.com"},
|
||||||
|
{"simply.com", "Simply.com", "simply.com"},
|
||||||
|
{"domeneshop", "Domeneshop", "domeneshop.no"},
|
||||||
|
{"syse", "Syse", "syse.no"},
|
||||||
|
{"registrar.eu", "Openprovider", "openprovider.com"},
|
||||||
|
{"mondo", "Mondo International", "mondo.com"},
|
||||||
|
{"dansk.net", "Dansk.net", "dansk.net"},
|
||||||
|
{"internet invest", "Imena.ua", "imena.ua"},
|
||||||
|
{"netearth", "NetEarth One", "netearthone.com"},
|
||||||
|
{"publicdomainregistry", "PublicDomainRegistry", "publicdomainregistry.com"},
|
||||||
|
{"pdr ltd", "PublicDomainRegistry", "publicdomainregistry.com"},
|
||||||
|
{"webnames", "Webnames", "webnames.ca"},
|
||||||
|
{"rebel.ca", "Rebel", "rebel.ca"},
|
||||||
|
{"easydns", "easyDNS", "easydns.com"},
|
||||||
|
{"namespro", "Namespro", "namespro.ca"},
|
||||||
|
{"sibername", "Sibername", "sibername.com"},
|
||||||
|
{"netfirms", "Netfirms", "netfirms.com"},
|
||||||
|
{"names.co.uk", "names.co.uk", "names.co.uk"},
|
||||||
|
{"namesco", "names.co.uk", "names.co.uk"},
|
||||||
|
{"fasthosts", "Fasthosts", "fasthosts.co.uk"},
|
||||||
|
{"heart internet", "Heart Internet", "heartinternet.uk"},
|
||||||
|
{"dreamhost", "DreamHost", "dreamhost.com"},
|
||||||
|
{"pair domains", "pair Domains", "pairdomains.com"},
|
||||||
|
{"domainthenet", "Domain The Net", "domainthenet.com"},
|
||||||
|
{"domainregistry", "DomainRegistry", "domainregistry.com"},
|
||||||
|
{"moniker", "Moniker", "moniker.com"},
|
||||||
|
{"fabulous", "Fabulous", "fabulous.com"},
|
||||||
|
{"above.com", "Above.com", "above.com"},
|
||||||
|
{"united-internet", "United Internet", "united-internet.de"},
|
||||||
|
{"united internet", "United Internet", "united-internet.de"},
|
||||||
|
{"arsys", "Arsys", "arsys.es"},
|
||||||
|
{"piensa solutions", "Piensa Solutions", "piensasolutions.com"},
|
||||||
|
{"dinahosting", "Dinahosting", "dinahosting.com"},
|
||||||
|
{"cdmon", "CDmon", "cdmon.com"},
|
||||||
|
{"nominalia", "Nominalia", "nominalia.com"},
|
||||||
|
{"register.it", "Register.it", "register.it"},
|
||||||
|
{"ovh sas", "OVHcloud", "ovhcloud.com"},
|
||||||
|
{"gandi sas", "Gandi", "gandi.net"},
|
||||||
|
{"blue razor", "GoDaddy", "godaddy.com"},
|
||||||
|
{"wild west domains", "GoDaddy", "godaddy.com"},
|
||||||
|
{"starfield", "GoDaddy", "godaddy.com"},
|
||||||
|
{"nameking", "Namecheap", "namecheap.com"},
|
||||||
|
{"enom", "eNom", "enom.com"},
|
||||||
|
{"realtime register", "Realtime Register", "realtimeregister.com"},
|
||||||
|
{"rrpproxy", "RRPproxy", "rrpproxy.net"},
|
||||||
|
{"key-systems gmbh", "Key-Systems", "key-systems.net"},
|
||||||
|
{"internet domains", "Internet Domains", "internetdomains.ch"},
|
||||||
|
{"corehub", "CORE", "corehub.net"},
|
||||||
|
{"don dominio", "DonDominio", "dondominio.com"},
|
||||||
|
{"arsys internet", "Arsys", "arsys.es"},
|
||||||
|
{"key-systems", "Key-Systems", "key-systems.net"},
|
||||||
|
{"centralnic", "CentralNic", "centralnic.com"},
|
||||||
|
{"internet.bs corp", "Internet.bs", "internetbs.net"},
|
||||||
|
{"gkg.net", "GKG.NET", "gkg.net"},
|
||||||
|
{"namecheap inc", "Namecheap", "namecheap.com"},
|
||||||
|
{"namecheap, inc", "Namecheap", "namecheap.com"},
|
||||||
|
{"google llc", "Google Domains", "domains.google"},
|
||||||
|
{"squarespace domains", "Squarespace", "squarespace.com"},
|
||||||
|
{"turncommerce, inc.", "DropCatch", "dropcatch.com"},
|
||||||
|
{"amazon", "Amazon Registrar", "aws.amazon.com"},
|
||||||
|
{"name.com, inc", "Name.com", "name.com"},
|
||||||
|
{"godaddy.com, llc", "GoDaddy", "godaddy.com"},
|
||||||
|
{"tucows domains", "Tucows", "tucows.com"},
|
||||||
|
{"porkbun llc", "Porkbun", "porkbun.com"},
|
||||||
|
{"cloudflare, inc", "Cloudflare", "cloudflare.com"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// caProviders: issuer CN/org → certificate authority vendor.
|
||||||
|
var caProviders = []provMatch{
|
||||||
|
{"let's encrypt", "Let's Encrypt", "letsencrypt.org"},
|
||||||
|
{"letsencrypt", "Let's Encrypt", "letsencrypt.org"},
|
||||||
|
{"digicert", "DigiCert", "digicert.com"},
|
||||||
|
{"sectigo", "Sectigo", "sectigo.com"},
|
||||||
|
{"comodoca", "Sectigo", "sectigo.com"},
|
||||||
|
{"comodo", "Sectigo", "sectigo.com"},
|
||||||
|
{"globalsign", "GlobalSign", "globalsign.com"},
|
||||||
|
{"godaddy", "GoDaddy", "godaddy.com"},
|
||||||
|
{"starfield", "GoDaddy", "godaddy.com"},
|
||||||
|
{"entrust", "Entrust", "entrust.com"},
|
||||||
|
{"google trust", "Google Trust Services", "pki.goog"},
|
||||||
|
{"pki.goog", "Google Trust Services", "pki.goog"},
|
||||||
|
{"amazon", "Amazon Trust Services", "amazontrust.com"},
|
||||||
|
{"zerossl", "ZeroSSL", "zerossl.com"},
|
||||||
|
{"buypass", "Buypass", "buypass.com"},
|
||||||
|
{"ssl.com", "SSL.com", "ssl.com"},
|
||||||
|
{"actalis", "Actalis", "actalis.com"},
|
||||||
|
{"certum", "Certum", "certum.pl"},
|
||||||
|
{"harica", "HARICA", "harica.gr"},
|
||||||
|
{"swisssign", "SwissSign", "swisssign.com"},
|
||||||
|
{"cloudflare", "Cloudflare", "cloudflare.com"},
|
||||||
|
{"microsoft", "Microsoft", "microsoft.com"},
|
||||||
|
{"geotrust", "DigiCert", "digicert.com"},
|
||||||
|
{"thawte", "DigiCert", "digicert.com"},
|
||||||
|
{"rapidssl", "DigiCert", "digicert.com"},
|
||||||
|
{"usertrust", "Sectigo", "sectigo.com"},
|
||||||
|
{"certainly", "Certainly", "certainly.io"},
|
||||||
|
{"isrg", "Let's Encrypt", "letsencrypt.org"},
|
||||||
|
{"e1", "Let's Encrypt", "letsencrypt.org"},
|
||||||
|
{"r3", "Let's Encrypt", "letsencrypt.org"},
|
||||||
|
{"r10", "Let's Encrypt", "letsencrypt.org"},
|
||||||
|
{"r11", "Let's Encrypt", "letsencrypt.org"},
|
||||||
|
{"wotrus", "WoTrus", "wotrus.com"},
|
||||||
|
{"cameo", "Cameo", "cameo.com"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// detectHostProvider picks the hosting vendor: CNAME target first (most
|
||||||
|
// precise), then response headers, then the ASN/org of the primary IPv4.
|
||||||
|
func detectHostProvider(r *Result) *Provider {
|
||||||
|
if r.CNAME != "" {
|
||||||
|
if p := matchProvider(r.CNAME, hostTargets); p != nil {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h := r.Headers
|
||||||
server := strings.ToLower(h["server"] + " " + h["x-powered-by"] + " " + h["via"])
|
server := strings.ToLower(h["server"] + " " + h["x-powered-by"] + " " + h["via"])
|
||||||
if _, ok := h["cf-ray"]; ok || strings.Contains(server, "cloudflare") {
|
switch {
|
||||||
return "Cloudflare"
|
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") {
|
webServers := []provMatch{
|
||||||
return "Vercel"
|
{"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") {
|
if p := matchProvider(server, webServers); p != nil {
|
||||||
return "Netlify"
|
return p
|
||||||
}
|
}
|
||||||
if _, ok := h["x-amz-cf-id"]; ok || strings.Contains(server, "cloudfront") {
|
if p := matchProvider(r.HostOrg+" "+r.HostAS+" "+r.HostISP, asnProviders); p != nil {
|
||||||
return "AWS CloudFront"
|
return p
|
||||||
}
|
}
|
||||||
if _, ok := h["x-github-request-id"]; ok || strings.Contains(server, "github") {
|
if p := matchProvider(server, []provMatch{
|
||||||
return "GitHub Pages"
|
{"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 nil
|
||||||
return "Fly.io"
|
|
||||||
}
|
|
||||||
if strings.Contains(server, "render") {
|
|
||||||
return "Render"
|
|
||||||
}
|
|
||||||
table := []struct{ sub, name string }{
|
|
||||||
{"nginx", "nginx"}, {"apache", "Apache"}, {"caddy", "Caddy"},
|
|
||||||
{"litespeed", "LiteSpeed"}, {"openresty", "OpenResty"},
|
|
||||||
{"envoy", "Envoy"}, {"awselb", "AWS ELB"}, {"gws", "Google"},
|
|
||||||
{"kestrel", "Kestrel/.NET"}, {"cowboy", "Cowboy/Erlang"},
|
|
||||||
}
|
|
||||||
for _, t := range table {
|
|
||||||
if strings.Contains(server, t.sub) {
|
|
||||||
return t.name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func detectCA(issuer string) string {
|
// txtProviders: SPF includes and domain-verification strings → vendor.
|
||||||
i := strings.ToLower(issuer)
|
var txtProviders = []provMatch{
|
||||||
table := []struct{ sub, name string }{
|
{"v=spf1 include:_spf.google.com", "Google Workspace", "google.com"},
|
||||||
{"let's encrypt", "Let's Encrypt"}, {"letsencrypt", "Let's Encrypt"},
|
{"include:spf.protonmail.ch", "Proton Mail", "proton.me"},
|
||||||
{"digicert", "DigiCert"}, {"sectigo", "Sectigo"}, {"comodoca", "Sectigo"},
|
{"include:mail.protonmail.ch", "Proton Mail", "proton.me"},
|
||||||
{"globalsign", "GlobalSign"}, {"godaddy", "GoDaddy"}, {"starfield", "GoDaddy"},
|
{"include:_spf.mx.cloudflare.net", "Cloudflare", "cloudflare.com"},
|
||||||
{"entrust", "Entrust"}, {"google trust", "Google Trust Services"},
|
{"include:servers.mcsv.net", "Mailchimp", "mailchimp.com"},
|
||||||
{"amazon", "Amazon"}, {"zerossl", "ZeroSSL"}, {"buypass", "Buypass"},
|
{"include:amazonses.com", "Amazon SES", "aws.amazon.com"},
|
||||||
{"ssl.com", "SSL.com"}, {"actalis", "Actalis"}, {"certum", "Certum"},
|
{"include:mailgun.org", "Mailgun", "mailgun.com"},
|
||||||
{"harica", "HARICA"}, {"swisssign", "SwissSign"}, {"cloudflare", "Cloudflare"},
|
{"include:sendgrid.net", "SendGrid", "sendgrid.com"},
|
||||||
{"pki.goog", "Google Trust Services"}, {"microsoft", "Microsoft"},
|
{"include:spf.protection.outlook.com", "Microsoft 365", "microsoft.com"},
|
||||||
|
{"include:spf.purelymail.com", "Purelymail", "purelymail.com"},
|
||||||
|
{"_spf.purelymail.com", "Purelymail", "purelymail.com"},
|
||||||
|
{"purelymail_ownership_proof", "Purelymail", "purelymail.com"},
|
||||||
|
{"openai-domain-verification", "OpenAI", "openai.com"},
|
||||||
|
{"include:_spf.migadu.com", "Migadu", "migadu.com"},
|
||||||
|
{"include:mx.zohomail.com", "Zoho Mail", "zoho.com"},
|
||||||
|
{"include:_spf.fastmail.com", "Fastmail", "fastmail.com"},
|
||||||
|
{"include:spf.mtasv.net", "Postmark", "postmarkapp.com"},
|
||||||
|
{"include:_spf.salesforce.com", "Salesforce", "salesforce.com"},
|
||||||
|
{"include:spf.improvmx.com", "ImprovMX", "improvmx.com"},
|
||||||
|
{"include:mail.zendesk.com", "Zendesk", "zendesk.com"},
|
||||||
|
{"include:spf.mandrillapp.com", "Mailchimp", "mailchimp.com"},
|
||||||
|
{"include:mailspike.net", "Mailspike", "mailspike.net"},
|
||||||
|
{"include:spf.messagelabs.com", "Broadcom", "broadcom.com"},
|
||||||
|
{"include:_spf.mx.yandex", "Yandex", "yandex.com"},
|
||||||
|
{"include:spf.tuta.io", "Tuta", "tuta.com"},
|
||||||
|
{"google-site-verification", "Google", "google.com"},
|
||||||
|
{"ms=", "Microsoft 365", "microsoft.com"},
|
||||||
|
{"facebook-domain-verification", "Meta", "meta.com"},
|
||||||
|
{"apple-domain-verification", "Apple", "apple.com"},
|
||||||
|
{"keybase-site-verification", "Keybase", "keybase.io"},
|
||||||
|
{"keybase", "Keybase", "keybase.io"},
|
||||||
|
{"protonmail-verification", "Proton", "proton.me"},
|
||||||
|
{"have-i-been-pwned-verification", "Have I Been Pwned", "haveibeenpwned.com"},
|
||||||
|
{"atlassian-domain-verification", "Atlassian", "atlassian.com"},
|
||||||
|
{"docker-verification", "Docker", "docker.com"},
|
||||||
|
{"docusign", "DocuSign", "docusign.com"},
|
||||||
|
{"yandex-verification", "Yandex", "yandex.com"},
|
||||||
|
{"stripe-verification", "Stripe", "stripe.com"},
|
||||||
|
{"dropbox-domain-verification", "Dropbox", "dropbox.com"},
|
||||||
|
{"brave-ledger-verification", "Brave", "brave.com"},
|
||||||
|
{"detectify-verification", "Detectify", "detectify.com"},
|
||||||
|
{"adobe-idp-site-verification", "Adobe", "adobe.com"},
|
||||||
|
{"webexdomainverification", "Cisco Webex", "webex.com"},
|
||||||
|
{"zoho-verification", "Zoho", "zoho.com"},
|
||||||
|
{"mailru-verification", "Mail.ru", "mail.ru"},
|
||||||
|
{"baidu-site-verification", "Baidu", "baidu.com"},
|
||||||
|
{"google-gws-recovery-domain-verification", "Google", "google.com"},
|
||||||
|
{"status-page-domain-verification", "Atlassian", "atlassian.com"},
|
||||||
|
{"citrix-verification", "Citrix", "citrix.com"},
|
||||||
|
{"tiktok-domain-verification", "TikTok", "tiktok.com"},
|
||||||
|
{"netlify-verification", "Netlify", "netlify.com"},
|
||||||
|
{"vercel-domain-verification", "Vercel", "vercel.com"},
|
||||||
|
{"workplace-domain-verification", "Meta", "meta.com"},
|
||||||
|
{"v=dmarc1", "DMARC", "dmarc.org"},
|
||||||
|
{"v=dkim1", "DKIM", "dkim.org"},
|
||||||
}
|
}
|
||||||
for _, t := range table {
|
|
||||||
if strings.Contains(i, t.sub) {
|
func matchTXTProvider(txt string) *Provider {
|
||||||
return t.name
|
return matchProvider(txt, txtProviders)
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
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 { useBoard } from "@/board/store";
|
||||||
import { api } from "@/api";
|
import { api } from "@/api";
|
||||||
import { rendererDescriptions, rendererLabels, rendererList } from "@/renderers";
|
import { rendererDescriptions, rendererIcons, rendererLabels, rendererList } from "@/renderers";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { LOCALES, t } from "@/i18n";
|
import { LOCALES, t } from "@/i18n";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -44,6 +44,19 @@ const TRANSPORTS = {
|
|||||||
} as const;
|
} as const;
|
||||||
type TransportName = keyof typeof TRANSPORTS;
|
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 }) {
|
export function SettingsDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (o: boolean) => void }) {
|
||||||
const board = useBoard();
|
const board = useBoard();
|
||||||
const fileRef = useRef<HTMLInputElement>(null);
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -143,50 +156,70 @@ export function SettingsDialog({ open, onOpenChange }: { open: boolean; onOpenCh
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="max-w-sm">
|
<DialogContent className="max-w-xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{t("settings.title")}</DialogTitle>
|
<DialogTitle>{t("settings.title")}</DialogTitle>
|
||||||
<DialogDescription>{t("settings.desc")}</DialogDescription>
|
<DialogDescription>{t("settings.desc")}</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="flex max-h-[70vh] flex-col gap-4 overflow-y-auto pr-1">
|
<div className="flex max-h-[72vh] flex-col gap-5 overflow-y-auto pr-1">
|
||||||
<div className="flex flex-col gap-1.5">
|
<Group icon={LayoutGrid} title={t("settings.renderer")}>
|
||||||
<Label>{t("settings.renderer")}</Label>
|
<div className="grid grid-cols-2 gap-2" role="radiogroup" aria-label={t("settings.renderer")}>
|
||||||
<div className="grid grid-cols-2 gap-1.5" role="radiogroup" aria-label={t("settings.renderer")}>
|
{rendererList.map((r) => {
|
||||||
{rendererList.map((r) => (
|
const Icon = rendererIcons[r];
|
||||||
|
const active = board.renderer === r;
|
||||||
|
return (
|
||||||
<button
|
<button
|
||||||
key={r}
|
key={r}
|
||||||
type="button"
|
type="button"
|
||||||
role="radio"
|
role="radio"
|
||||||
aria-checked={board.renderer === r}
|
aria-checked={active}
|
||||||
onClick={() => void board.setSetting("renderer", r)}
|
onClick={() => void board.setSetting("renderer", r)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-[8px] border px-3 py-2.5 text-left transition-colors",
|
"flex items-center gap-3 rounded-[9px] border px-3 py-2.5 text-left transition-colors",
|
||||||
board.renderer === r
|
active
|
||||||
? "border-border-strong bg-surface-hover"
|
? "border-accent bg-accent/10"
|
||||||
: "border-border hover:border-border-strong hover:bg-surface-hover",
|
: "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 text-[12.5px] font-medium">{rendererLabels[r]}</span>
|
||||||
<span className="block font-mono text-[10px] text-text-faint">{rendererDescriptions[r]}</span>
|
<span className="block truncate font-mono text-[10px] text-text-faint">{rendererDescriptions[r]}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Group icon={Palette} title={t("settings.appearance")}>
|
||||||
|
<div className="grid grid-cols-2 gap-3 max-[560px]:grid-cols-1">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>{t("settings.theme")}</Label>
|
||||||
|
<div className="flex overflow-hidden rounded-[7px] border border-border">
|
||||||
|
{(["dark", "light"] as const).map((th) => (
|
||||||
|
<button
|
||||||
|
key={th}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={board.theme === th}
|
||||||
|
onClick={() => void board.setSetting("theme", th)}
|
||||||
|
className={cn(
|
||||||
|
"flex flex-1 items-center justify-center gap-1.5 px-3 py-1.5 text-[12px] transition-colors",
|
||||||
|
board.theme === th ? "bg-surface-hover text-text" : "text-text-faint hover:text-text",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{th === "dark" ? <Moon size={12} /> : <Sun size={12} />}
|
||||||
|
{th === "dark" ? t("settings.dark") : t("settings.light")}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label>{t("settings.language")}</Label>
|
<Label>{t("settings.language")}</Label>
|
||||||
<Select
|
<Select
|
||||||
@@ -201,24 +234,25 @@ export function SettingsDialog({ open, onOpenChange }: { open: boolean; onOpenCh
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label htmlFor="accent">{t("settings.accent")}</Label>
|
<Label htmlFor="accent">{t("settings.accent")}</Label>
|
||||||
<div className="flex gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
id="accent" type="color" aria-label={t("settings.accent")}
|
id="accent" type="color" aria-label={t("settings.accent")}
|
||||||
value={/^#[0-9a-fA-F]{6}$/.test(board.accent) ? board.accent : "#3b82f6"}
|
value={/^#[0-9a-fA-F]{6}$/.test(board.accent) ? board.accent : "#3b82f6"}
|
||||||
onChange={(e) => void board.setSetting("accent", e.target.value)}
|
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"
|
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", "")}>
|
<Button type="button" variant="outline" size="sm" onClick={() => void board.setSetting("accent", "")}>
|
||||||
{t("common.reset")}
|
{t("common.reset")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Group>
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
<Group icon={Image} title={t("settings.wallpaper")}>
|
||||||
<Label htmlFor="wallpaper">{t("settings.wallpaper")}</Label>
|
|
||||||
<Input
|
<Input
|
||||||
id="wallpaper"
|
id="wallpaper"
|
||||||
value={board.wallpaper}
|
value={board.wallpaper}
|
||||||
@@ -226,22 +260,19 @@ export function SettingsDialog({ open, onOpenChange }: { open: boolean; onOpenCh
|
|||||||
placeholder={t("settings.wallpaperPh")}
|
placeholder={t("settings.wallpaperPh")}
|
||||||
className="font-mono text-[12px]"
|
className="font-mono text-[12px]"
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<Label htmlFor="custom-css">{t("settings.customCss")}</Label>
|
|
||||||
<textarea
|
<textarea
|
||||||
id="custom-css"
|
id="custom-css"
|
||||||
|
aria-label={t("settings.customCss")}
|
||||||
value={board.customCss}
|
value={board.customCss}
|
||||||
onChange={(e) => void board.setSetting("customCss", e.target.value)}
|
onChange={(e) => void board.setSetting("customCss", e.target.value)}
|
||||||
placeholder=".item-tile { border-radius: 0; }"
|
placeholder=".item-tile { border-radius: 0; }"
|
||||||
rows={3}
|
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"
|
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">
|
<Group icon={Bell} title={t("settings.notifications")}>
|
||||||
<Label>{t("settings.notifications")}</Label>
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Select value={transport} onValueChange={(v) => {
|
<Select value={transport} onValueChange={(v) => {
|
||||||
// SAFETY: Select only yields TRANSPORTS keys.
|
// SAFETY: Select only yields TRANSPORTS keys.
|
||||||
@@ -281,13 +312,13 @@ export function SettingsDialog({ open, onOpenChange }: { open: boolean; onOpenCh
|
|||||||
{t("settings.notifyFanout")}
|
{t("settings.notifyFanout")}
|
||||||
</p>
|
</p>
|
||||||
{notifyMsg ? <p className="text-[11px] text-text-faint">{notifyMsg}</p> : null}
|
{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">
|
<Group icon={Lock} title={t("auth.section")}>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between rounded-lg border border-border px-3 py-2.5">
|
||||||
<Label className="flex items-center gap-1.5">
|
<p className="text-[11px] text-text-faint">
|
||||||
<Lock size={12} /> {t("auth.section")}
|
{auth.enabled ? `${t("auth.onHint")} ${auth.username ?? ""}` : t("auth.offHint")}
|
||||||
</Label>
|
</p>
|
||||||
{auth.enabled ? (
|
{auth.enabled ? (
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button type="button" variant="outline" size="sm" onClick={() => void signOut()}>
|
<Button type="button" variant="outline" size="sm" onClick={() => void signOut()}>
|
||||||
@@ -303,12 +334,10 @@ export function SettingsDialog({ open, onOpenChange }: { open: boolean; onOpenCh
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[11px] text-text-faint">
|
</Group>
|
||||||
{auth.enabled ? `${t("auth.onHint")} ${auth.username ?? ""}` : t("auth.offHint")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-2 pt-1">
|
<Group icon={HardDrive} title={t("settings.backup")}>
|
||||||
|
<div className="flex gap-2">
|
||||||
<Button type="button" variant="outline" size="sm" onClick={() => void exportJson()}>
|
<Button type="button" variant="outline" size="sm" onClick={() => void exportJson()}>
|
||||||
<Download size={12} /> {t("settings.exportJson")}
|
<Download size={12} /> {t("settings.exportJson")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -326,6 +355,7 @@ export function SettingsDialog({ open, onOpenChange }: { open: boolean; onOpenCh
|
|||||||
</div>
|
</div>
|
||||||
<p className="text-[11px] text-text-faint">{t("settings.importHint")}</p>
|
<p className="text-[11px] text-text-faint">{t("settings.importHint")}</p>
|
||||||
{msg ? <p className="text-[12px] text-destructive">{msg}</p> : null}
|
{msg ? <p className="text-[12px] text-destructive">{msg}</p> : null}
|
||||||
|
</Group>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</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.testNotify": "Odeslat testovací notifikaci",
|
||||||
"settings.wallpaperPh": "https://… — prázdné = žádná",
|
"settings.wallpaperPh": "https://… — prázdné = žádná",
|
||||||
"settings.importFailed": "Import selhal",
|
"settings.importFailed": "Import selhal",
|
||||||
|
"settings.backup": "Záloha a import",
|
||||||
|
|
||||||
"service.editTitle": "Upravit službu",
|
"service.editTitle": "Upravit službu",
|
||||||
"service.newTitle": "Přidat 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.addDesc": "Vytvoří bearer token — spusťte s ním dash-agent.",
|
||||||
"systems.notFound": "Systém nenalezen.",
|
"systems.notFound": "Systém nenalezen.",
|
||||||
"systems.usage": "Využití",
|
"systems.usage": "Využití",
|
||||||
"systems.cpuPct": "CPU %",
|
"systems.chartCpu": "Využití CPU",
|
||||||
"systems.memPct": "Paměť %",
|
"systems.chartCpuHint": "Průměrné využití CPU celého systému",
|
||||||
"systems.netKbs": "Síť KB/s",
|
"systems.chartMem": "Využití paměti",
|
||||||
"systems.load1m": "Load 1m",
|
"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.temperatures": "Teploty",
|
||||||
"systems.drivesSmart": "Disky (SMART)",
|
"systems.drivesSmart": "Disky (SMART)",
|
||||||
"systems.agentCmd": "Příkaz agenta",
|
"systems.agentCmd": "Příkaz agenta",
|
||||||
@@ -335,6 +355,8 @@ export const cs: Partial<Record<Key, string>> = {
|
|||||||
"systems.failing": "selhává",
|
"systems.failing": "selhává",
|
||||||
"systems.uptime": "Doba běhu",
|
"systems.uptime": "Doba běhu",
|
||||||
|
|
||||||
|
"chart.noData": "Zatím žádná data.",
|
||||||
|
|
||||||
"incidents.title": "Incidenty",
|
"incidents.title": "Incidenty",
|
||||||
"incidents.subtitle": "Automaticky otevřené při výpadcích monitorů, nebo vedené ručně.",
|
"incidents.subtitle": "Automaticky otevřené při výpadcích monitorů, nebo vedené ručně.",
|
||||||
"incidents.new": "Nový incident",
|
"incidents.new": "Nový incident",
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ export const en = {
|
|||||||
"settings.testNotify": "Send test notification",
|
"settings.testNotify": "Send test notification",
|
||||||
"settings.wallpaperPh": "https://… — blank for none",
|
"settings.wallpaperPh": "https://… — blank for none",
|
||||||
"settings.importFailed": "Import failed",
|
"settings.importFailed": "Import failed",
|
||||||
|
"settings.backup": "Backup & import",
|
||||||
|
|
||||||
"service.editTitle": "Edit service",
|
"service.editTitle": "Edit service",
|
||||||
"service.newTitle": "Add service",
|
"service.newTitle": "Add service",
|
||||||
@@ -322,10 +323,29 @@ export const en = {
|
|||||||
"systems.addDesc": "Creates a bearer token — run dash-agent with it.",
|
"systems.addDesc": "Creates a bearer token — run dash-agent with it.",
|
||||||
"systems.notFound": "System not found.",
|
"systems.notFound": "System not found.",
|
||||||
"systems.usage": "Usage",
|
"systems.usage": "Usage",
|
||||||
"systems.cpuPct": "CPU %",
|
"systems.chartCpu": "CPU Usage",
|
||||||
"systems.memPct": "Memory %",
|
"systems.chartCpuHint": "Average system-wide CPU utilization",
|
||||||
"systems.netKbs": "Network KB/s",
|
"systems.chartMem": "Memory Usage",
|
||||||
"systems.load1m": "Load 1m",
|
"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.temperatures": "Temperatures",
|
||||||
"systems.drivesSmart": "Drives (SMART)",
|
"systems.drivesSmart": "Drives (SMART)",
|
||||||
"systems.agentCmd": "Agent command",
|
"systems.agentCmd": "Agent command",
|
||||||
@@ -334,6 +354,8 @@ export const en = {
|
|||||||
"systems.failing": "failing",
|
"systems.failing": "failing",
|
||||||
"systems.uptime": "Uptime",
|
"systems.uptime": "Uptime",
|
||||||
|
|
||||||
|
"chart.noData": "No data yet.",
|
||||||
|
|
||||||
"incidents.title": "Incidents",
|
"incidents.title": "Incidents",
|
||||||
"incidents.subtitle": "Auto-opened on monitor outages, or tracked manually.",
|
"incidents.subtitle": "Auto-opened on monitor outages, or tracked manually.",
|
||||||
"incidents.new": "New incident",
|
"incidents.new": "New incident",
|
||||||
|
|||||||
@@ -25,6 +25,16 @@
|
|||||||
--icon-bg: #1c1c1e;
|
--icon-bg: #1c1c1e;
|
||||||
--popover: #141414;
|
--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);
|
--background: var(--bg);
|
||||||
--foreground: var(--text);
|
--foreground: var(--text);
|
||||||
--card: var(--surface);
|
--card: var(--surface);
|
||||||
@@ -57,6 +67,16 @@
|
|||||||
--icon-bg: #f4f4f5;
|
--icon-bg: #f4f4f5;
|
||||||
--popover: #ffffff;
|
--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);
|
--background: var(--bg);
|
||||||
--foreground: var(--text);
|
--foreground: var(--text);
|
||||||
--card: var(--surface);
|
--card: var(--surface);
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { Link } from "wouter";
|
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 { 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 { DomainDialog } from "@/components/DomainDialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { cn } from "@/lib/utils";
|
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" });
|
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 }) {
|
export function DomainDetailPage({ id }: { id: string }) {
|
||||||
const [d, setD] = useState<DomainView | null>(null);
|
const [d, setD] = useState<DomainView | null>(null);
|
||||||
const [checks, setChecks] = useState<DomainCheck[]>([]);
|
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 className="font-mono text-[10px] text-text-faint">{d.caProvider || "—"}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-[10px] border border-border bg-surface px-4 py-3">
|
<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="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.dnsProvider || "—"}</div>
|
<div className="mt-1 truncate text-[13px] font-medium">
|
||||||
<div className="font-mono text-[10px] text-text-faint">{d.hostingProvider || d.emailProvider || "—"}</div>
|
{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>
|
</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")}>
|
<Section title={t("domains.registration")}>
|
||||||
<Row k={t("domains.created")} v={fmtDate(d.creationDate)} />
|
<Row k={t("domains.created")} v={fmtDate(d.creationDate)} />
|
||||||
<Row k={t("domains.updated")} v={fmtDate(d.updatedDate)} />
|
<Row k={t("domains.updated")} v={fmtDate(d.updatedDate)} />
|
||||||
@@ -153,19 +221,17 @@ export function DomainDetailPage({ id }: { id: string }) {
|
|||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Section title={t("domains.dns")}>
|
<Section title={t("domains.dns")}>
|
||||||
{d.nameServers?.map((n) => <Row key={n} k="NS" v={n} mono />)}
|
{[...(d.records ?? [])]
|
||||||
{d.mxRecords?.map((m) => <Row key={m} k="MX" v={m} mono />)}
|
.sort((a, b) => recOrder.indexOf(a.type ?? "") - recOrder.indexOf(b.type ?? ""))
|
||||||
{d.ipv4?.map((a) => <Row key={a} k="A" v={a} mono />)}
|
.map((r, i) => <RecordRow key={i} r={r} />)}
|
||||||
{d.ipv6?.map((a) => <Row key={a} k="AAAA" v={a} mono />)}
|
{(d.records ?? []).length === 0 ? (
|
||||||
{d.cname ? <Row k="CNAME" v={d.cname} mono /> : null}
|
<p className="px-4 py-4 text-center text-[12px] text-text-faint">—</p>
|
||||||
{d.txtRecords?.slice(0, 4).map((t, i) => <Row key={i} k="TXT" v={t.length > 60 ? t.slice(0, 60) + "…" : t} mono />)}
|
) : null}
|
||||||
{d.dnsProvider ? <Row k={t("domains.dnsProvider")} v={d.dnsProvider} /> : null}
|
|
||||||
{d.emailProvider ? <Row k={t("domains.email")} v={d.emailProvider} /> : null}
|
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Section title={t("domains.tlsCert")}>
|
<Section title={t("domains.tlsCert")}>
|
||||||
<Row k={t("domains.issuer")} v={d.sslIssuer} />
|
<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.valid")} v={`${fmtDate(d.sslValidFrom)} → ${fmtDate(d.sslValidTo)}`} mono />
|
||||||
<Row k={t("domains.subject")} v={d.sslSubject} mono />
|
<Row k={t("domains.subject")} v={d.sslSubject} mono />
|
||||||
<Row k={t("domains.key")} v={d.sslKeySize ? `${d.sslKeySize} bit` : ""} mono />
|
<Row k={t("domains.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.isp")} v={d.hostIsp} />
|
||||||
<Row k={t("domains.org")} v={d.hostOrg} />
|
<Row k={t("domains.org")} v={d.hostOrg} />
|
||||||
<Row k="AS" v={d.hostAs} mono />
|
<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 />
|
<Row k={t("domains.checked")} v={d.lastChecked ? new Date(d.lastChecked).toLocaleString() : "—"} mono />
|
||||||
</Section>
|
</Section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { Link } from "wouter";
|
import { Link } from "wouter";
|
||||||
import { ArrowLeft, Pause, Play, RefreshCw, Trash2 } from "lucide-react";
|
import { ArrowLeft, Pause, Play, RefreshCw, Trash2 } from "lucide-react";
|
||||||
import { Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
|
||||||
import { api } from "@/api";
|
import { api } from "@/api";
|
||||||
|
import { ChartCard, MetricChart, chartColor, type ChartRow } from "@/components/chart";
|
||||||
import type { Heartbeat, MonitorView } from "@/types";
|
import type { Heartbeat, MonitorView } from "@/types";
|
||||||
import { MonitorDialog } from "@/components/MonitorDialog";
|
import { MonitorDialog } from "@/components/MonitorDialog";
|
||||||
import { Button } from "@/components/ui/button";
|
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>;
|
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) => ({
|
const rows: ChartRow[] = hbs.map((h) => ({
|
||||||
t: new Date(h.checkedAt ?? "").toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }),
|
t: new Date(h.checkedAt ?? "").getTime(),
|
||||||
ping: h.pingMs,
|
ping: h.pingMs,
|
||||||
up: h.status === "up",
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -94,8 +93,12 @@ export function MonitorDetailPage({ id }: { id: string }) {
|
|||||||
<UptimeBar hbs={hbs} hours={hours} />
|
<UptimeBar hbs={hbs} hours={hours} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mb-2 flex items-center justify-between">
|
<div className="mb-6">
|
||||||
<h2 className="font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">{t("monitors.responseTime")}</h2>
|
<ChartCard
|
||||||
|
title={t("monitors.responseTime")}
|
||||||
|
height={208}
|
||||||
|
series={[{ key: "ping", label: "ms", color: chartColor(0) }]}
|
||||||
|
actions={
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
{[24, 168, 720].map((h) => (
|
{[24, 168, 720].map((h) => (
|
||||||
<button key={h} type="button" onClick={() => setHours(h)}
|
<button key={h} type="button" onClick={() => setHours(h)}
|
||||||
@@ -105,20 +108,15 @@ export function MonitorDetailPage({ id }: { id: string }) {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
}
|
||||||
<div className="mb-6 h-56 rounded-[10px] border border-border bg-surface p-3">
|
>
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<MetricChart
|
||||||
<AreaChart data={chart} margin={{ top: 4, right: 4, bottom: 0, left: -18 }}>
|
rows={rows}
|
||||||
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" vertical={false} />
|
series={[{ key: "ping", label: "ms", color: chartColor(0) }]}
|
||||||
<XAxis dataKey="t" tick={{ fontSize: 10, fill: "var(--text-faint)" }} tickLine={false} axisLine={false} minTickGap={40} />
|
fmt={(v) => `${Math.round(v)} ms`}
|
||||||
<YAxis tick={{ fontSize: 10, fill: "var(--text-faint)" }} tickLine={false} axisLine={false} unit=" ms" width={62} />
|
spanMs={hours * 3600_000}
|
||||||
<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} />
|
</ChartCard>
|
||||||
</AreaChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2 className="mb-2 font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">{t("monitors.recentChecks")}</h2>
|
<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 { useCallback, useEffect, useState } from "react";
|
||||||
import { Link } from "wouter";
|
import { Link } from "wouter";
|
||||||
import { ArrowLeft, Copy, Trash2 } from "lucide-react";
|
import { ArrowLeft, Copy, Trash2 } from "lucide-react";
|
||||||
import { Area, AreaChart, CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
|
||||||
import { api } from "@/api";
|
import { api } from "@/api";
|
||||||
|
import { ChartCard, MetricChart, chartColor, type ChartRow, type Series } from "@/components/chart";
|
||||||
import type { System, SystemStat } from "@/types";
|
import type { System, SystemStat } from "@/types";
|
||||||
import { agentCmd, fmtBytes, fmtUptime } from "@/pages/SystemsPage";
|
import { agentCmd, fmtBytes, fmtUptime } from "@/pages/SystemsPage";
|
||||||
import { Button } from "@/components/ui/button";
|
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>;
|
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 l = sys.latest;
|
||||||
const chart = stats.map((s) => ({
|
const spanMs = hours * 3600_000;
|
||||||
t: new Date(s.ts ?? "").toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }),
|
|
||||||
|
// 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,
|
cpu: s.cpu,
|
||||||
memPct: s.memTotal ? ((s.memUsed ?? 0) / s.memTotal) * 100 : undefined,
|
mem: s.memUsed,
|
||||||
rx: (s.netRx ?? 0) / 1024,
|
swap: s.swapUsed,
|
||||||
tx: (s.netTx ?? 0) / 1024,
|
disk: s.diskUsed,
|
||||||
load: s.load1,
|
rx: s.netRx,
|
||||||
}));
|
tx: s.netTx,
|
||||||
const axisTick = { fontSize: 10, fill: "var(--text-faint)" };
|
l1: s.load1,
|
||||||
const tipStyle = { background: "var(--surface)", border: "1px solid var(--border)", borderRadius: 8, fontSize: 11 };
|
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 (
|
return (
|
||||||
<main className="mx-auto w-full max-w-5xl px-7 py-8">
|
<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>
|
||||||
|
|
||||||
<div className="mb-6 grid grid-cols-2 gap-3 max-[720px]:grid-cols-1">
|
<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">
|
<ChartCard title={t("systems.chartCpu")} hint={t("systems.chartCpuHint")}>
|
||||||
<div className="mb-1 font-mono text-[9.5px] uppercase tracking-[0.1em] text-text-faint">{t("systems.cpuPct")}</div>
|
<MetricChart rows={rows} series={cpuSeries} fmt={pct} domain={[0, 100]} spanMs={spanMs} />
|
||||||
<ResponsiveContainer width="100%" height="85%">
|
</ChartCard>
|
||||||
<AreaChart data={chart} margin={{ top: 4, right: 4, bottom: 0, left: -22 }}>
|
{dockerCpuSeries.length > 0 && (
|
||||||
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" vertical={false} />
|
<ChartCard title={t("systems.chartDockerCpu")} hint={t("systems.chartDockerCpuHint")} series={dockerCpuSeries}>
|
||||||
<XAxis dataKey="t" tick={axisTick} tickLine={false} axisLine={false} minTickGap={40} />
|
<MetricChart rows={rows} series={dockerCpuSeries} fmt={pct} spanMs={spanMs} />
|
||||||
<YAxis tick={axisTick} tickLine={false} axisLine={false} domain={[0, 100]} unit="%" width={48} />
|
</ChartCard>
|
||||||
<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} />
|
<ChartCard title={t("systems.chartMem")} hint={t("systems.chartMemHint")} series={memSeries}>
|
||||||
</AreaChart>
|
<MetricChart rows={rows} series={memSeries} fmt={fmtBytes} spanMs={spanMs} />
|
||||||
</ResponsiveContainer>
|
</ChartCard>
|
||||||
</div>
|
{dockerMemSeries.length > 0 && (
|
||||||
<div className="h-44 rounded-[10px] border border-border bg-surface p-3">
|
<ChartCard title={t("systems.chartDockerMem")} hint={t("systems.chartDockerMemHint")} series={dockerMemSeries}>
|
||||||
<div className="mb-1 font-mono text-[9.5px] uppercase tracking-[0.1em] text-text-faint">{t("systems.memPct")}</div>
|
<MetricChart rows={rows} series={dockerMemSeries} fmt={fmtBytes} spanMs={spanMs} />
|
||||||
<ResponsiveContainer width="100%" height="85%">
|
</ChartCard>
|
||||||
<AreaChart data={chart} margin={{ top: 4, right: 4, bottom: 0, left: -22 }}>
|
)}
|
||||||
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" vertical={false} />
|
<ChartCard title={t("systems.chartDisk")} hint={t("systems.chartDiskHint")}>
|
||||||
<XAxis dataKey="t" tick={axisTick} tickLine={false} axisLine={false} minTickGap={40} />
|
<MetricChart rows={rows} series={diskSeries} fmt={fmtBytes} domain={l?.diskTotal ? [0, l.diskTotal] : undefined} spanMs={spanMs} />
|
||||||
<YAxis tick={axisTick} tickLine={false} axisLine={false} domain={[0, 100]} unit="%" width={48} />
|
</ChartCard>
|
||||||
<Tooltip contentStyle={tipStyle} labelStyle={{ color: "var(--text-faint)" }} />
|
<ChartCard title={t("systems.chartNet")} hint={t("systems.chartNetHint")} series={netSeries}>
|
||||||
<Area type="monotone" dataKey="memPct" stroke="var(--up)" fill="var(--up)" fillOpacity={0.12} strokeWidth={1.5} dot={false} isAnimationActive={false} />
|
<MetricChart rows={rows} series={netSeries} fmt={(v) => `${fmtBytes(v)}/s`} spanMs={spanMs} />
|
||||||
</AreaChart>
|
</ChartCard>
|
||||||
</ResponsiveContainer>
|
<ChartCard title={t("systems.chartLoad")} hint={t("systems.chartLoadHint")} series={loadSeries}>
|
||||||
</div>
|
<MetricChart rows={rows} series={loadSeries} fmt={(v) => v.toFixed(2)} spanMs={spanMs} />
|
||||||
<div className="h-44 rounded-[10px] border border-border bg-surface p-3">
|
</ChartCard>
|
||||||
<div className="mb-1 font-mono text-[9.5px] uppercase tracking-[0.1em] text-text-faint">{t("systems.netKbs")}</div>
|
{tempSeries.length > 0 && (
|
||||||
<ResponsiveContainer width="100%" height="85%">
|
<ChartCard title={t("systems.chartTemp")} hint={t("systems.chartTempHint")} series={tempSeries}>
|
||||||
<LineChart data={chart} margin={{ top: 4, right: 4, bottom: 0, left: -18 }}>
|
<MetricChart rows={rows} series={tempSeries} fmt={(v) => `${v.toFixed(0)}°C`} spanMs={spanMs} />
|
||||||
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" vertical={false} />
|
</ChartCard>
|
||||||
<XAxis dataKey="t" tick={axisTick} tickLine={false} axisLine={false} minTickGap={40} />
|
)}
|
||||||
<YAxis tick={axisTick} tickLine={false} axisLine={false} width={58} />
|
{gpuSeries.length > 0 && (
|
||||||
<Tooltip contentStyle={tipStyle} labelStyle={{ color: "var(--text-faint)" }} />
|
<ChartCard title={t("systems.chartGpu")} hint={t("systems.chartGpuHint")} series={gpuSeries}>
|
||||||
<Line type="monotone" dataKey="rx" name="rx" stroke="var(--up)" strokeWidth={1.5} dot={false} isAnimationActive={false} />
|
<MetricChart rows={rows} series={gpuSeries} fmt={pct} domain={[0, 100]} spanMs={spanMs} />
|
||||||
<Line type="monotone" dataKey="tx" name="tx" stroke="var(--text-faint)" strokeWidth={1.5} dot={false} isAnimationActive={false} />
|
</ChartCard>
|
||||||
</LineChart>
|
)}
|
||||||
</ResponsiveContainer>
|
{vramSeries.length > 0 && (
|
||||||
</div>
|
<ChartCard title={t("systems.chartVram")} hint={t("systems.chartVramHint")} series={vramSeries}>
|
||||||
<div className="h-44 rounded-[10px] border border-border bg-surface p-3">
|
<MetricChart rows={rows} series={vramSeries} fmt={fmtBytes} spanMs={spanMs} />
|
||||||
<div className="mb-1 font-mono text-[9.5px] uppercase tracking-[0.1em] text-text-faint">{t("systems.load1m")}</div>
|
</ChartCard>
|
||||||
<ResponsiveContainer width="100%" height="85%">
|
)}
|
||||||
<AreaChart data={chart} margin={{ top: 4, right: 4, bottom: 0, left: -22 }}>
|
|
||||||
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" vertical={false} />
|
|
||||||
<XAxis dataKey="t" tick={axisTick} tickLine={false} axisLine={false} minTickGap={40} />
|
|
||||||
<YAxis tick={axisTick} tickLine={false} axisLine={false} width={48} />
|
|
||||||
<Tooltip contentStyle={tipStyle} labelStyle={{ color: "var(--text-faint)" }} />
|
|
||||||
<Area type="monotone" dataKey="load" stroke="var(--up)" fill="var(--up)" fillOpacity={0.12} strokeWidth={1.5} dot={false} isAnimationActive={false} />
|
|
||||||
</AreaChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3 max-[720px]:grid-cols-1">
|
<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 TB = 2 ** 40;
|
||||||
const GB = 2 ** 30;
|
const GB = 2 ** 30;
|
||||||
const MB = 2 ** 20;
|
const MB = 2 ** 20;
|
||||||
|
const KB = 2 ** 10;
|
||||||
|
|
||||||
export function fmtBytes(b: number) {
|
export function fmtBytes(b: number) {
|
||||||
if (b >= TB) return `${(b / TB).toFixed(1)} TB`;
|
if (b >= TB) return `${(b / TB).toFixed(1)} TB`;
|
||||||
if (b >= GB) return `${(b / GB).toFixed(1)} GB`;
|
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) {
|
export function fmtUptime(s: number) {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { LayoutGrid, LayoutList, Newspaper, SquareTerminal, type LucideIcon } from "lucide-react";
|
||||||
import { bento } from "./bento";
|
import { bento } from "./bento";
|
||||||
import { cards } from "./cards";
|
import { cards } from "./cards";
|
||||||
import { editorial } from "./editorial";
|
import { editorial } from "./editorial";
|
||||||
@@ -27,3 +28,10 @@ export const rendererDescriptions: Record<RendererName, string> = {
|
|||||||
index: "editorial list · masthead",
|
index: "editorial list · masthead",
|
||||||
console: "terminal panels · mono",
|
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 Domain = components["schemas"]["Domain"];
|
||||||
export type DomainInput = components["schemas"]["DomainInput"];
|
export type DomainInput = components["schemas"]["DomainInput"];
|
||||||
export type DomainView = components["schemas"]["DomainView"];
|
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 DomainCheck = components["schemas"]["DomainCheck"];
|
||||||
export type Subdomain = components["schemas"]["Subdomain"];
|
export type Subdomain = components["schemas"]["Subdomain"];
|
||||||
export type System = components["schemas"]["System"];
|
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:
|
services:
|
||||||
dash:
|
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:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: infra/Dockerfile
|
dockerfile: infra/Dockerfile
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
volumes:
|
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
|
- ./data:/data
|
||||||
restart: unless-stopped
|
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.
|
For r/selfhosted, HN Show, and similar. Edit freely before posting.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Title:** Dash — a self-hosted homelab dashboard with monitoring built in.
|
**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:**
|
**Body:**
|
||||||
|
|
||||||
@@ -19,13 +19,17 @@ What it does:
|
|||||||
upload. Imports your existing Homepage (`services.yml`), Homarr, or Dashy
|
upload. Imports your existing Homepage (`services.yml`), Homarr, or Dashy
|
||||||
config in one click.
|
config in one click.
|
||||||
- **Monitors** — HTTP/TCP/ping/DNS/keyword/JSON-path checks plus push monitors
|
- **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.
|
- **Domains** — WHOIS/RDAP, DNS, TLS certificate expiry, provider detection.
|
||||||
Inspired by Domain Locker, minus the paid APIs.
|
Inspired by Domain Locker, minus the paid APIs.
|
||||||
- **Systems** — a ~4 MB static agent pushes CPU/mem/disk/net/temps/Docker
|
- **Systems** — a ~4 MB static agent pushes CPU/mem/disk/net/temps, per-
|
||||||
container states. Inspired by Beszel, push-JSON instead of SSH.
|
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,
|
- **Ops** — incidents, maintenance windows, public status pages, SVG badges,
|
||||||
Prometheus `/api/metrics`.
|
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:
|
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
|
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 —
|
SQLite inside, no database to babysit. Auth is opt-in: local
|
||||||
it expects a trusted LAN or your own proxy in front.
|
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).
|
Stack: Go + Gin + SQLite (single static binary serving an embedded React UI).
|
||||||
MIT licensed.
|
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
|
Dash combines a service board, uptime monitors, domain/SSL watching, system
|
||||||
metrics via a tiny push agent, and public status pages. Single container or
|
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
|
https://github.com/Dvorinka/Dash
|
||||||
@@ -21,12 +21,16 @@ snapshot; `/api/import/csv` bulk-loads monitors and domains from CSV.
|
|||||||
|
|
||||||
## Notifications
|
## Notifications
|
||||||
|
|
||||||
**Settings → Notifications** holds one generic webhook URL
|
**Settings → Notifications** configures any number of transports — generic
|
||||||
(`notify_webhook`). On monitor up/down transitions and domain events, Dash
|
webhook, Slack, Discord, Telegram, Gotify, ntfy, and SMTP — each stored as a
|
||||||
POSTs a JSON payload with `event`, `name`, `text`, `content`, `message`, and
|
`notify_<name>` settings object. Monitor up/down transitions and domain
|
||||||
`at` fields — shaped so Slack, Discord, and ntfy all render it sensibly.
|
events fan out to every configured transport; the payload carries `event`,
|
||||||
**Test** sends a `test` event. Delivery is async and non-fatal; failures are
|
`name`, `text`, `content`, `message`, and `at` fields. **Test** sends a `test`
|
||||||
logged.
|
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
|
## 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
|
offline when no sample arrives within a few intervals. Stats are pruned during
|
||||||
the hourly sweep.
|
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
|
## Operations
|
||||||
|
|
||||||
- **Incidents** — open/ack/resolve/close, with per-incident update timeline.
|
- **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
|
Dash ships as a single container image or a single static binary. All state
|
||||||
lives in one directory: a SQLite database and uploaded icons.
|
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
|
```sh
|
||||||
docker run -d --name dash \
|
docker run -d --name dash \
|
||||||
@@ -34,6 +43,19 @@ To build the image locally instead of pulling:
|
|||||||
docker compose up --build
|
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
|
## Binary
|
||||||
|
|
||||||
Release archives contain a static `dash` binary (no CGO, no dependencies).
|
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
|
type: string
|
||||||
caProvider:
|
caProvider:
|
||||||
type: string
|
type: string
|
||||||
|
records:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/DNSRecord"
|
||||||
|
providers:
|
||||||
|
$ref: "#/components/schemas/Providers"
|
||||||
headers:
|
headers:
|
||||||
type: object
|
type: object
|
||||||
additionalProperties:
|
additionalProperties:
|
||||||
@@ -1807,6 +1813,51 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
alerts:
|
alerts:
|
||||||
$ref: "#/components/schemas/AlertRules"
|
$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:
|
DomainView:
|
||||||
allOf:
|
allOf:
|
||||||
- $ref: "#/components/schemas/Domain"
|
- $ref: "#/components/schemas/Domain"
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export default defineConfig({
|
|||||||
".claude/**",
|
".claude/**",
|
||||||
"tools/oxlint/anti-slop/**",
|
"tools/oxlint/anti-slop/**",
|
||||||
"packages/api-client/src/schema.d.ts",
|
"packages/api-client/src/schema.d.ts",
|
||||||
|
"landing/next-env.d.ts",
|
||||||
"**/dist/**",
|
"**/dist/**",
|
||||||
"**/node_modules/**",
|
"**/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"
|
||||||