Author SHA1 Message Date
Tomas DvorakandDevin 0612beb37e ci(release): auto-tag and release when ci passes on main
release.yml now triggers on workflow_run(ci, main, success): bumps
semver from conventional commits since the latest tag (feat -> minor,
fix -> patch, BREAKING -> major), pushes the tag, then goreleaser and
the GHCR docker build release it. Merging this PR cuts v2.1.0.

Authored By: TDvorak <[email protected]>

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

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

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

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

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

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

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

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

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

Authored By: TDvorak <[email protected]>

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

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

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

Authored By: TDvorak <[email protected]>

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

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

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

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

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

Authored By: TDvorak <[email protected]>

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

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

Authored By: TDvorak <[email protected]>

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

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

Authored By: TDvorak <[email protected]>

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

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

No files matched your search

+77 -4
View File
@@ -1,20 +1,89 @@
name: release
# Releases are cut automatically from main: after the ci workflow passes on a
# main push, the version job bumps semver from conventional commit subjects
# (BREAKING/! -> major, feat -> minor, otherwise patch), pushes the tag, and
# the build jobs release it. Manual tags are not used; workflow_dispatch
# re-runs the same flow on demand.
on:
push:
tags: ["v*"]
workflow_run:
workflows: [ci]
types: [completed]
branches: [main]
workflow_dispatch:
permissions:
contents: write
packages: write
# Two merges racing would compute the same tag; serialize instead.
concurrency:
group: release
cancel-in-progress: false
jobs:
version:
runs-on: ubuntu-latest
if: github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success'
outputs:
version: ${{ steps.v.outputs.version }}
semver: ${{ steps.v.outputs.semver }}
major_minor: ${{ steps.v.outputs.major_minor }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
- id: v
run: |
set -euo pipefail
latest="$(git tag -l 'v*' --sort=-v:refname | head -n1)"
if [ -z "$latest" ]; then
version="v0.1.0"; bump="minor"
else
subjects="$(git log "$latest"..HEAD --format=%s)"
if grep -qiE 'BREAKING[ -]CHANGE|^[a-z]+(\([^)]*\))?!:' <<< "$subjects"; then
bump="major"
elif grep -qE '^feat(\([^)]*\))?:' <<< "$subjects"; then
bump="minor"
else
bump="patch"
fi
IFS=. read -r major minor patch <<< "${latest#v}"
case "$bump" in
major) major=$((major+1)); minor=0; patch=0 ;;
minor) minor=$((minor+1)); patch=0 ;;
*) patch=$((patch+1)) ;;
esac
version="v${major}.${minor}.${patch}"
fi
semver="${version#v}"
IFS=. read -r mj mn _ <<< "$semver"
{
echo "version=$version"
echo "semver=$semver"
echo "major_minor=$mj.$mn"
} >> "$GITHUB_OUTPUT"
echo "latest=${latest:-none} bump=$bump -> $version"
- name: Push tag
env:
VERSION: ${{ steps.v.outputs.version }}
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git tag "$VERSION"
git push origin "$VERSION"
binaries:
needs: version
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # goreleaser needs full history for tag + changelog
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
- uses: actions/setup-node@v4
with:
@@ -40,11 +109,15 @@ jobs:
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GORELEASER_CURRENT_TAG: ${{ needs.version.outputs.version }}
docker:
needs: version
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
- uses: docker/setup-buildx-action@v3
@@ -59,8 +132,8 @@ jobs:
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=${{ needs.version.outputs.semver }}
type=raw,value=${{ needs.version.outputs.major_minor }}
type=raw,value=latest
- uses: docker/build-push-action@v6
+3
View File
@@ -20,6 +20,9 @@ data/*
.DS_Store
coverage/
dist/
# the blanket dist/ above excludes the embedded-UI dir itself, which would
# make the .keep negation unreachable — re-include the dir, contents stay ignored
!apps/backend/internal/web/dist/
# local tooling artifacts
.playwright-mcp/
+2 -1
View File
@@ -1,7 +1,8 @@
# Dash
Self-hosted homelab dashboard. Single Go binary serving an embedded React UI.
No auth, no YAML. See `ROADMAP.md` (canonical) and `docs/specs/` for design.
Optional auth (off by default), no YAML. See `ROADMAP.md` (canonical) and
`docs/specs/` for design.
## Layout
+52 -1
View File
@@ -6,6 +6,55 @@ All notable changes to Dash. Format follows
## [Unreleased]
## [2.1.0] — 2026-09-23
### Added
- Domain detail now identifies vendors per record: A/AAAA/CNAME/NS/MX/TXT
rows carry provider badges (Cloudflare, Google, Purelymail, …) with favicon
icons, plus a five-role provider strip (registrar, DNS, hosting, email, CA)
above the detail sections. Detection covers ~70 NS providers, ~45 mail
providers, ~60 CNAME targets, ASN/org matching, response-header signals,
and TXT verification/SPF attribution
### Changed
- Settings dialog regrouped into icon-labelled sections (renderer, appearance,
wallpaper/CSS, notifications, auth, backup); renderer tiles now show a
per-style glyph and the theme picker is a segmented control
- Monitoring charts restyled to the Beszel school: titled chart cards with
subtitles, soft area fills on a true time-scaled axis, unit-aware Y ticks,
sorted dotted tooltips, and legend chips on multi-series charts
- System detail gains stacked per-container Docker CPU/memory charts,
per-sensor temperature lines, load 1/5/15, and GPU usage/VRAM charts —
all from data the agent already sends
## [2.0.0] — 2026-09-22
### Added
- Opt-in local auth: first-user setup, bcrypt passwords, SQLite sessions,
rotating 7-day HttpOnly cookie, login rate limit, sign-out/disable in
settings. Off by default; status pages, badges, metrics, push, and agent
ingest stay public
- Multiple boards: named boards with slugs, header switcher, `/b/:slug`
routes, per-board sections, board-aware export
- Full i18n: every UI string via typed `t()` keys with interpolation;
English + Czech locales, language select in settings
- Uptime bar strip + latency chart on monitor detail
- iframe embed widget and generic JSON-path widget (backend fetcher)
- Custom accent color, wallpaper URL, and custom CSS in settings
- PWA: manifest, icons, minimal service worker
- Alert transports: webhook, Slack, Discord, Telegram, Gotify, ntfy, SMTP
(stdlib `net/smtp`); transport picker + test button in settings
- Per-monitor alert rules: consecutive-failure threshold, latency warn, mute;
per-domain cert-days threshold and mute
- Per-container CPU/memory in `dash-agent` (Docker stats when socket present);
optional SMART, ZFS, and GPU collectors (exec-or-sysfs, skipped when absent)
- Subdomain discovery via Certificate Transparency + DNS, daily sweep,
subdomains section on domain detail
## [0.7.0] — 2026-09-21
### Added
@@ -77,7 +126,9 @@ All notable changes to Dash. Format follows
- Single Go binary serving the embedded React UI; SQLite via goose migrations;
Docker image on distroless
[Unreleased]: https://github.com/Dvorinka/Dash/compare/v0.7.0...HEAD
[Unreleased]: https://github.com/Dvorinka/Dash/compare/v2.1.0...HEAD
[2.1.0]: https://github.com/Dvorinka/Dash/compare/v2.0.0...v2.1.0
[2.0.0]: https://github.com/Dvorinka/Dash/compare/v0.7.0...v2.0.0
[0.7.0]: https://github.com/Dvorinka/Dash/compare/v0.6.0...v0.7.0
[0.6.0]: https://github.com/Dvorinka/Dash/compare/v0.5.0...v0.6.0
[0.5.0]: https://github.com/Dvorinka/Dash/compare/v0.4.0...v0.5.0
+2 -2
View File
@@ -28,8 +28,8 @@ DASH_DEV=1 go run ./cmd/dash # API on :8080
and `-- +goose Down`.
- Board renderers share state, drag-drop, and popover primitives. Never
reimplement them per renderer.
- No auth, no YAML-as-source-of-truth, no external database. These are
non-goals for v1.
- Auth is opt-in and local-only: no OIDC/SSO, no multi-user RBAC. No
YAML-as-source-of-truth, no external database. These are non-goals.
- Standard library before dependencies; existing helpers before new ones.
- User-visible strings go through `t("key")` from `src/i18n` — add the key to
`en.ts` rather than inlining literals in new UI.
+104 -57
View File
@@ -1,53 +1,106 @@
# Dash
<p align="center">
<img src="./dash.svg" alt="Dash" width="120">
</p>
A self-hosted homelab dashboard. CasaOS-grade ease of use, Vercel-grade design.
Single container, UI-managed, no YAML. Optional auth.
<h1 align="center">Dash</h1>
![Dash demo](docs/demo.gif)
<p align="center">
Self-hosted homelab dashboard.<br>
Service boards, uptime monitors, domain watch, and system metrics in one container.
</p>
<p align="center">
<a href="#quick-start">Quick Start</a> ·
<a href="docs/installation.md">Documentation</a> ·
<a href="https://github.com/Dvorinka/Dash/releases">Releases</a> ·
<a href="CONTRIBUTING.md">Contributing</a>
</p>
<p align="center">
<a href="https://github.com/Dvorinka/Dash/actions/workflows/ci.yml"><img src="https://github.com/Dvorinka/Dash/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
<a href="https://github.com/Dvorinka/Dash/releases"><img src="https://img.shields.io/github/v/release/Dvorinka/Dash" alt="Release"></a>
<a href="LICENSE"><img src="https://img.shields.io/github/license/Dvorinka/Dash" alt="License"></a>
</p>
## What is Dash?
Dash is a self-hosted homelab dashboard: service boards with drag-and-drop,
uptime monitors, domain intelligence, push-based system metrics, incidents,
and public status pages — one static Go binary serving an embedded React UI.
No YAML, no external database; auth is optional and off by default.
## Screenshots
| Board | Monitors | Systems |
|:-:|:-:|:-:|
| ![Service board](docs/screenshots/board.png) | ![Uptime monitors](docs/screenshots/monitors.png) | ![System metrics](docs/screenshots/systems.png) |
<details>
<summary>More screenshots</summary>
| Domain intel | Domains | Status page |
|:-:|:-:|:-:|
| ![Domain detail with provider detection](docs/screenshots/domain-detail.png) | ![Domain watch](docs/screenshots/domains.png) | ![Public status page](docs/screenshots/status-page.png) |
</details>
## Features
- **Service board** — multiple boards, sections, drag-and-drop, multi-URL
services, icon upload, four renderers (Bento, Cards, Index, Console),
dark/light themes, custom accent/wallpaper/CSS
- **Uptime monitors** — HTTP, TCP, ping, DNS, keyword, JSON-path, and push
checks on a schedule, heartbeat history, uptime graphs, per-monitor alert
rules
- **Domain watch** — WHOIS/RDAP, DNS records, TLS certificate expiry,
registrar and provider detection, CT-log subdomain discovery
- **System monitoring** — lightweight `dash-agent` pushes CPU, memory, disk,
network, load, temperatures, per-container stats, and SMART/ZFS/GPU when
available
- **Incidents & status pages** — manual and automatic incidents, maintenance
windows, public `/status/:slug` pages, SVG badges
- **Alerts** — webhook, Slack, Discord, Telegram, Gotify, ntfy, and SMTP
transports
- **Observability** — Prometheus `/api/metrics`, JSON export, CSV bulk import,
importers for Homepage, Homarr, and Dashy configs
- **Optional auth** — local username/password with bcrypt + session cookies,
off by default; public status pages stay open either way
- **i18n + PWA** — English and Czech UI, installable manifest/service worker
- **Service boards** — multiple boards, sections, drag-and-drop, multi-URL services, icon upload, four renderers (Bento, Cards, Index, Console), dark/light themes, custom accent/wallpaper/CSS.
- **Uptime monitors** — HTTP, TCP, ping, DNS, keyword, JSON-path, and push checks on a schedule; heartbeat history, uptime graphs, per-monitor alert rules.
- **Domain watch** — WHOIS/RDAP, TLS certificate, host geolocation, and CT-log subdomain discovery; every DNS record is attributed to its vendor (Cloudflare, Google Workspace, Purelymail, …) with provider badges across registrar, DNS, hosting, email, and CA.
- **System monitoring** — lightweight `dash-agent` pushes CPU, memory, disk, network, load, temperatures, per-container stats, and SMART/ZFS/GPU when available; rendered as stacked per-container charts, per-sensor temperature lines, and load/bandwidth series.
- **Incidents & status pages** — automatic incidents on monitor down, maintenance windows, public `/status/:slug` pages, SVG badges.
- **Alerts** — webhook, Slack, Discord, Telegram, Gotify, ntfy, and SMTP transports.
- **Observability** — Prometheus `/api/metrics`, JSON export, CSV bulk import, importers for Homepage, Homarr, and Dashy configs.
- **Optional auth** — local username/password, bcrypt + SQLite sessions, off by default; public status pages stay open either way.
- **i18n + PWA** — English and Czech UI, installable manifest/service worker.
## Run
## Architecture
```
Browser ──▶ dash (single static Go binary)
├── Embedded React UI (embed.FS)
├── REST API (Gin) ──▶ SQLite
└── Schedulers ──▶ monitors, domain refresh, alerts, retention
│
▼ outbound
HTTP/TCP/ICMP/DNS, WHOIS/RDAP, TLS, SMTP
dash-agent (one binary per host) ──push JSON──▶ POST /api/systems/ingest
```
## Quick Start
Prerequisites: Docker.
```bash
curl -fsSL https://raw.githubusercontent.com/Dvorinka/Dash/main/install.sh | sh
```
Installs into `./dash` and starts the stack on port 3000. Or from a clone:
```bash
git clone https://github.com/Dvorinka/Dash.git && cd Dash
./scripts/setup.sh # builds the image and starts the stack
```
Or plain `docker run`, no script needed:
```sh
docker run -d -v ./data:/data -p 3000:3000 ghcr.io/dvorinka/dash:latest
```
or with compose:
Then open `http://localhost:3000` — everything is configured from the UI.
For the system agent, binary installs, reverse proxies, and upgrades see
[docs/installation.md](docs/installation.md).
```yaml
services:
dash:
image: ghcr.io/dvorinka/dash:latest
ports:
- "3000:3000"
volumes:
- ./data:/data
restart: unless-stopped
```
## Configuration
Then open http://localhost:3000. Everything is configured from the UI.
Dash is configured from the UI; the server reads three environment
variables (`DASH_PORT`, `DASH_DATA_DIR`, `DASH_DEV`). All state lives in one
directory — a SQLite database plus uploaded icons. See
[docs/configuration.md](docs/configuration.md).
## System agent
@@ -57,22 +110,16 @@ Monitor a Linux host: create a system in the UI, copy the token, then:
dash-agent -url http://dash:3000 -token <token>
```
One static binary, no dependencies. See
[docs/installation.md](docs/installation.md) for systemd and Docker setups.
One static binary, no dependencies. systemd unit and container image covered
in [docs/installation.md](docs/installation.md#dash-agent-system-monitoring).
## Screenshots
## Documentation
| | |
|---|---|
| ![Monitors](docs/screenshots/monitors.png) | ![Domains](docs/screenshots/domains.png) |
| ![Systems](docs/screenshots/systems.png) | ![Status page](docs/screenshots/status-page.png) |
## Docs
- [Installation](docs/installation.md)
- [Configuration](docs/configuration.md)
- [Widget development](docs/widgets.md)
- [Roadmap](ROADMAP.md) · [Changelog](CHANGELOG.md) · [Contributing](CONTRIBUTING.md)
- [docs/installation.md](docs/installation.md) — Docker, binary, agent, reverse proxy, upgrades
- [docs/configuration.md](docs/configuration.md) — env vars, notifications, auth, boards, operations
- [docs/widgets.md](docs/widgets.md) — widget development guide
- `openapi.yaml` — API contract (generated TS client in `packages/api-client`)
- [ROADMAP.md](ROADMAP.md) — design decisions and release history
## Develop
@@ -80,21 +127,21 @@ Requires Go 1.24+ and Node 20+.
```sh
npm install # workspaces: frontend + api-client
npm run generate # regenerate TS client from openapi.yaml
npm run dev # vite dev on :5173, proxies /api to :8080
```
In another shell:
```sh
cd apps/backend
DASH_DEV=1 go run ./cmd/dash # API on :8080 (DASH_DEV skips embedded UI)
```
## Stack
## Contributing
Go + Gin + SQLite (single static binary, embeds the frontend) · React 18 +
Vite + TS strict + Tailwind + shadcn/ui + dnd-kit · OpenAPI-generated client.
See [CONTRIBUTING.md](CONTRIBUTING.md) for the development workflow.
## Security
See [SECURITY.md](SECURITY.md) for reporting vulnerabilities.
## License
+16 -16
View File
@@ -149,27 +149,27 @@ becomes the summary layer over the monitoring pages.
Uptime checking: the backbone everything else hangs off.
- [ ] Schema: `monitors` (name, type, url/hostname/port, method, keyword, json_query, expected_value, interval_s, timeout_s, retries, active, status, tags, notes), `heartbeats` (monitor_id, status, ping_ms, msg, cert_expiry, checked_at — 30-day retention prune)
- [ ] In-process scheduler: due-monitor sweep, bounded worker pool, heartbeat write, status transitions
- [ ] Checkers: `http`/`https`, `tcp`, `ping` (unprivileged ICMP datagram via `x/net/icmp` — distroless has no ping binary), `dns`, `keyword`, `json-query`, `push` (caller-generated ingest URL)
- [ ] API: `/api/monitors` CRUD, `/:id/heartbeats?range=`, pause/resume, check-now
- [ ] Monitors page (status, uptime 24h/30d, ping, interval) + detail (response chart, heartbeat log)
- [ ] Board widget type `monitor` bound to a monitor id
- [ ] ServiceDialog checkbox: create matching http monitor from a service URL
- [ ] The fork's ~30 exotic monitor types were TCP stubs — not ported. Real types only.
- [x] Schema: `monitors` (name, type, url/hostname/port, method, keyword, json_query, expected_value, interval_s, timeout_s, retries, active, status, tags, notes), `heartbeats` (monitor_id, status, ping_ms, msg, cert_expiry, checked_at — 30-day retention prune)
- [x] In-process scheduler: due-monitor sweep, bounded worker pool, heartbeat write, status transitions
- [x] Checkers: `http`/`https`, `tcp`, `ping` (unprivileged ICMP datagram via `x/net/icmp` — distroless has no ping binary), `dns`, `keyword`, `json-query`, `push` (caller-generated ingest URL)
- [x] API: `/api/monitors` CRUD, `/:id/heartbeats?range=`, pause/resume, check-now
- [x] Monitors page (status, uptime 24h/30d, ping, interval) + detail (response chart, heartbeat log)
- [x] Board widget type `monitor` bound to a monitor id
- [x] ServiceDialog checkbox: create matching http monitor from a service URL
- [x] The fork's ~30 exotic monitor types were TCP stubs — not ported. Real types only.
### Phase M2 — Domains → v0.5.0
Domain-locker-grade domain intelligence.
- [ ] Schema: `domains` (core columns + `extra` JSON for the long tail), `domain_history` (domain_id, change_type, field, old, new, at)
- [ ] Lookup pipeline ported from fork `hub/domains/whois/lookup.go`, trimmed: RDAP over HTTPS → native WHOIS TCP:43 + parser; SSL chain via `crypto/tls`; DNS (NS/MX/TXT/A/AAAA) via `net.Resolver`; host geo via ip-api; provider detection via fork `detect/providers.go`; favicon
- [ ] Daily scheduler + manual refresh; field diffs recorded to `domain_history`
- [ ] Alerts: `alert_rules` + `notifications` tables; dispatchers — generic webhook (JSON POST), SMTP via `net/smtp`, Discord/Slack presets. Triggers: domain expiry ≤ N days, SSL expiry ≤ N days, monitor down/recovered (M1), system offline (M3)
- [ ] Domains page + detail (expiry countdown, registrar, SSL, DNS, subdomains)
- [ ] Board widget `domain` (expiry countdown)
- [ ] Subdomain discovery: port `subdomain_discovery.go`, opt-in per domain
- [ ] Not ported (v1): whoisxml API, EURid web scraping, SEO/robots parsing, valuation estimates — demand only
- [x] Schema: `domains` (core columns + `extra` JSON for the long tail), `domain_history` (domain_id, change_type, field, old, new, at)
- [x] Lookup pipeline ported from fork `hub/domains/whois/lookup.go`, trimmed: RDAP over HTTPS → native WHOIS TCP:43 + parser; SSL chain via `crypto/tls`; DNS (NS/MX/TXT/A/AAAA) via `net.Resolver`; host geo via ip-api; provider detection via fork `detect/providers.go`; favicon
- [x] Daily scheduler + manual refresh; field diffs recorded to `domain_history`
- [x] Alerts: `alert_rules` + `notifications` tables; dispatchers — generic webhook (JSON POST), SMTP via `net/smtp`, Discord/Slack presets. Triggers: domain expiry ≤ N days, SSL expiry ≤ N days, monitor down/recovered (M1), system offline (M3)
- [x] Domains page + detail (expiry countdown, registrar, SSL, DNS, subdomains)
- [x] Board widget `domain` (expiry countdown)
- [x] Subdomain discovery: port `subdomain_discovery.go`, opt-in per domain
- [x] Not ported (v1): whoisxml API, EURid web scraping, SEO/robots parsing, valuation estimates — demand only
### Phase M3 — Systems → v0.6.0
+1 -1
View File
@@ -7,7 +7,7 @@
| latest release | yes |
| older releases | no |
Dash is pre-1.0; only the latest release receives fixes.
Only the latest release receives fixes.
## Reporting a vulnerability
+13 -4
View File
@@ -27,6 +27,7 @@ const domainCols = `id, name, tld, active, auto_renew, alert_days_before, tags,
ssl_key_size, ssl_sig_algo, ssl_alt_names, host_country, host_country_code,
host_region, host_city, host_isp, host_org, host_as, host_lat, host_lon,
dns_provider, email_provider, hosting_provider, ca_provider, headers,
records, providers,
favicon_url, lookup_error, interval_h, last_checked, position, alerts, created_at`
// domainRow mirrors the domains table; JSON text columns decode on scan.
@@ -82,6 +83,8 @@ type domainRow struct {
HostingProvider string `json:"hostingProvider"`
CAProvider string `json:"caProvider"`
Headers map[string]string `json:"headers"`
Records []domain.DNSRecord `json:"records"`
Providers domain.Providers `json:"providers"`
FaviconURL string `json:"faviconUrl"`
LookupError string `json:"lookupError"`
IntervalH int `json:"intervalH"`
@@ -93,7 +96,7 @@ type domainRow struct {
func scanDomain(row interface{ Scan(...any) error }) (*domainRow, error) {
var d domainRow
var tags, statuses, ns, mx, txt, v4, v6, altNames, headers, alerts string
var tags, statuses, ns, mx, txt, v4, v6, altNames, headers, records, providers, alerts string
var active, autoRenew, privacy, lock int
err := row.Scan(&d.ID, &d.Name, &d.TLD, &active, &autoRenew, &d.AlertDaysBefore,
&tags, &d.Notes, &d.ExpiryDate, &d.CreationDate, &d.UpdatedDate,
@@ -104,7 +107,7 @@ func scanDomain(row interface{ Scan(...any) error }) (*domainRow, error) {
&d.SSLKeySize, &d.SSLSigAlgo, &altNames, &d.HostCountry, &d.HostCountryCode,
&d.HostRegion, &d.HostCity, &d.HostISP, &d.HostOrg, &d.HostAS, &d.HostLat,
&d.HostLon, &d.DNSProvider, &d.EmailProvider, &d.HostingProvider, &d.CAProvider,
&headers, &d.FaviconURL, &d.LookupError, &d.IntervalH, &d.LastChecked,
&headers, &records, &providers, &d.FaviconURL, &d.LookupError, &d.IntervalH, &d.LastChecked,
&d.Position, &alerts, &d.CreatedAt)
if err != nil {
return nil, err
@@ -124,6 +127,9 @@ func scanDomain(row interface{ Scan(...any) error }) (*domainRow, error) {
d.SSLAltNames = decodeList(altNames)
d.Headers = map[string]string{}
_ = json.Unmarshal([]byte(headers), &d.Headers)
d.Records = []domain.DNSRecord{}
_ = json.Unmarshal([]byte(records), &d.Records)
_ = json.Unmarshal([]byte(providers), &d.Providers)
return &d, nil
}
@@ -417,6 +423,8 @@ func (s *Server) applyResult(id string, r *domain.Result) {
expiry, created, updated = fmtT(r.ExpiryDate), fmtT(r.CreationDate), fmtT(r.UpdatedDate)
sslFrom, sslTo = fmtT(r.SSLValidFrom), fmtT(r.SSLValidTo)
hdr, _ := json.Marshal(r.Headers)
recs, _ := json.Marshal(r.Records)
provs, _ := json.Marshal(r.Providers)
_, err := s.db.Exec(`UPDATE domains SET
expiry_date=?, creation_date=?, updated_date=?, registrar_name=?,
@@ -428,7 +436,8 @@ func (s *Server) applyResult(id string, r *domain.Result) {
ssl_key_size=?, ssl_sig_algo=?, ssl_alt_names=?, host_country=?,
host_country_code=?, host_region=?, host_city=?, host_isp=?, host_org=?,
host_as=?, host_lat=?, host_lon=?, dns_provider=?, email_provider=?,
hosting_provider=?, ca_provider=?, headers=?, favicon_url=?,
hosting_provider=?, ca_provider=?, headers=?, records=?, providers=?,
favicon_url=?,
lookup_error=?, last_checked=strftime('%Y-%m-%dT%H:%M:%fZ','now')
WHERE id=?`,
expiry, created, updated, r.RegistrarName, r.RegistrarID, r.RegistrarURL,
@@ -440,7 +449,7 @@ func (s *Server) applyResult(id string, r *domain.Result) {
enc(r.SSLAltNames), r.HostCountry, r.HostCountryCode, r.HostRegion,
r.HostCity, r.HostISP, r.HostOrg, r.HostAS, r.HostLat, r.HostLon,
r.DNSProvider, r.EmailProvider, r.HostingProvider, r.CAProvider,
string(hdr), r.FaviconURL, truncate(r.Error, 300), id)
string(hdr), string(recs), string(provs), r.FaviconURL, truncate(r.Error, 300), id)
if err != nil {
s.log.Error("domain apply", zap.Error(err))
return
@@ -0,0 +1,9 @@
-- +goose Up
-- Structured DNS records + provider identifications for the domain detail
-- page. JSON blobs — the flat columns stay for compat and check history.
ALTER TABLE domains ADD COLUMN records TEXT NOT NULL DEFAULT '[]';
ALTER TABLE domains ADD COLUMN providers TEXT NOT NULL DEFAULT '{}';
-- +goose Down
ALTER TABLE domains DROP COLUMN providers;
ALTER TABLE domains DROP COLUMN records;
+720 -95
View File
@@ -88,9 +88,46 @@ type Result struct {
HostingProvider string `json:"hostingProvider"`
CAProvider string `json:"caProvider"`
// Structured intel — domainstack-style record list + vendor strip.
Records []DNSRecord `json:"records"`
Providers Providers `json:"providers"`
Headers map[string]string `json:"headers"`
FaviconURL string `json:"faviconUrl"`
Error string `json:"error"` // non-fatal partial failure note
mxPairs []mxPair // structured MX; MXRecords keeps the display string
}
// Provider names the vendor behind a record or role. Domain feeds the
// favicon icon rendered next to the name in the UI.
type Provider struct {
Name string `json:"name"`
Domain string `json:"domain"`
Icon string `json:"icon"`
}
// Providers is the detected-vendor strip: registrar, DNS host, hosting,
// email, and certificate authority.
type Providers struct {
Registrar *Provider `json:"registrar,omitempty"`
DNS *Provider `json:"dns,omitempty"`
Email *Provider `json:"email,omitempty"`
Hosting *Provider `json:"hosting,omitempty"`
CA *Provider `json:"ca,omitempty"`
}
// DNSRecord is one resolved record with the vendor its value points at.
type DNSRecord struct {
Type string `json:"type"` // A AAAA CNAME NS MX TXT
Value string `json:"value"`
Priority *int `json:"priority,omitempty"` // MX preference
Provider *Provider `json:"provider,omitempty"`
}
type mxPair struct {
host string
pref int
}
// Clean normalises user input to a bare registrable hostname.
@@ -125,6 +162,7 @@ func (l *Lookup) Run(ctx context.Context, rawName string) *Result {
IPv4: []string{},
IPv6: []string{},
SSLAltNames: []string{},
Records: []DNSRecord{},
Headers: map[string]string{},
FaviconURL: fmt.Sprintf("https://www.google.com/s2/favicons?domain=%s&sz=128", name),
}
@@ -155,14 +193,68 @@ func (l *Lookup) Run(ctx context.Context, rawName string) *Result {
l.host(r.IPv4[0], r)
}
l.headers(ctx, name, r)
r.DNSProvider = detectDNSProvider(r.NameServers)
r.EmailProvider = detectEmailProvider(r.MXRecords)
r.HostingProvider = detectHostingProvider(r.Headers)
r.CAProvider = detectCA(r.SSLIssuer)
l.identify(r)
return r
}
// identify resolves provider vendors after every probe has run, then builds
// the structured record list the detail page renders.
func (l *Lookup) identify(r *Result) {
r.Providers.Registrar = matchProvider(r.RegistrarName, registrarProviders)
for _, n := range r.NameServers {
if p := matchProvider(n, nsProviders); p != nil {
r.Providers.DNS = p
break
}
}
for _, m := range r.mxPairs {
if p := matchProvider(m.host, mailProviders); p != nil {
r.Providers.Email = p
break
}
}
r.Providers.Hosting = detectHostProvider(r)
r.Providers.CA = matchProvider(r.SSLIssuer, caProviders)
// Legacy flat names mirror the strip for the list page + old clients.
r.DNSProvider = nameOf(r.Providers.DNS)
r.EmailProvider = nameOf(r.Providers.Email)
r.HostingProvider = nameOf(r.Providers.Hosting)
r.CAProvider = nameOf(r.Providers.CA)
recs := make([]DNSRecord, 0,
len(r.IPv4)+len(r.IPv6)+len(r.NameServers)+len(r.mxPairs)+len(r.TXTRecords)+1)
add := func(t, v string, pri *int, p *Provider) {
recs = append(recs, DNSRecord{Type: t, Value: v, Priority: pri, Provider: p})
}
for _, ip := range r.IPv4 {
add("A", ip, nil, r.Providers.Hosting)
}
for _, ip := range r.IPv6 {
add("AAAA", ip, nil, r.Providers.Hosting)
}
if r.CNAME != "" {
add("CNAME", r.CNAME, nil, matchProvider(r.CNAME, hostTargets))
}
for _, n := range r.NameServers {
add("NS", n, nil, matchProvider(n, nsProviders))
}
for _, m := range r.mxPairs {
pri := m.pref
add("MX", m.host, &pri, matchProvider(m.host, mailProviders))
}
for _, t := range r.TXTRecords {
add("TXT", t, nil, matchTXTProvider(t))
}
r.Records = recs
}
func nameOf(p *Provider) string {
if p == nil {
return ""
}
return p.Name
}
// --- DNS --------------------------------------------------------------------
func (l *Lookup) dns(ctx context.Context, name string, r *Result) {
@@ -174,7 +266,9 @@ func (l *Lookup) dns(ctx context.Context, name string, r *Result) {
}
if mx, err := res.LookupMX(ctx, name); err == nil {
for _, m := range mx {
r.MXRecords = append(r.MXRecords, fmt.Sprintf("%s (pri %d)", strings.TrimSuffix(m.Host, "."), m.Pref))
host := strings.TrimSuffix(strings.ToLower(m.Host), ".")
r.mxPairs = append(r.mxPairs, mxPair{host: host, pref: int(m.Pref)})
r.MXRecords = append(r.MXRecords, fmt.Sprintf("%s (pri %d)", host, m.Pref))
}
}
if txt, err := res.LookupTXT(ctx, name); err == nil {
@@ -297,108 +391,639 @@ func (l *Lookup) headers(ctx context.Context, name string, r *Result) {
}
// --- Provider detection (condensed port of beszel's detect package) -----------
//
// Tables map a substring of the record value / org / issuer to a vendor name
// plus the brand domain used for the favicon icon. Order matters — first
// match wins, so specific substrings precede generic ones.
func detectDNSProvider(ns []string) string {
table := []struct{ sub, name string }{
{"cloudflare", "Cloudflare"}, {"awsdns", "Amazon Route 53"},
{"googledomains", "Google Domains"}, {"google.com", "Google Domains"},
{"namecheap", "Namecheap"}, {"godaddy", "GoDaddy"}, {"domaincontrol", "GoDaddy"},
{"nsone.net", "NS1"}, {"digitalocean", "DigitalOcean"}, {"linode", "Linode"},
{"vultr", "Vultr"}, {"he.net", "Hurricane Electric"}, {"dynect", "Dyn (Oracle)"},
{"ultradns", "UltraDNS"}, {"dnsimple", "DNSimple"}, {"hover", "Hover"},
{"enom", "eNom"}, {"worldnic", "Network Solutions"}, {"zoneedit", "ZoneEdit"},
{"easydns", "EasyDNS"}, {"gandi", "Gandi"}, {"ovh", "OVH"},
{"hetzner", "Hetzner"}, {"azure-dns", "Microsoft Azure"},
{"dns.google", "Google Cloud DNS"}, {"porkbun", "Porkbun"},
{"cloudns", "ClouDNS"}, {"desec", "deSEC"}, {"nic.cz", "CZ.NIC"},
}
for _, n := range ns {
n = strings.ToLower(n)
for _, t := range table {
if strings.Contains(n, t.sub) {
return t.name
}
}
}
return ""
type provMatch struct{ sub, name, dom string }
func prov(name, dom string) *Provider {
return &Provider{Name: name, Domain: dom,
Icon: fmt.Sprintf("https://www.google.com/s2/favicons?domain=%s&sz=64", dom)}
}
func detectEmailProvider(mx []string) string {
table := []struct{ sub, name string }{
{"google.com", "Google Workspace"}, {"aspmx", "Google Workspace"},
{"outlook.com", "Microsoft 365"}, {"protection.outlook", "Microsoft 365"},
{"mail.protection", "Microsoft 365"}, {"protonmail", "Proton Mail"},
{"proton.me", "Proton Mail"}, {"zoho", "Zoho Mail"},
{"fastmail", "Fastmail"}, {"mimecast", "Mimecast"},
{"proofpoint", "Proofpoint"}, {"barracuda", "Barracuda"},
{"mailgun", "Mailgun"}, {"sendgrid", "SendGrid"},
{"amazonses", "Amazon SES"}, {"yandex", "Yandex"},
{"tuta", "Tuta"}, {"mailbox.org", "mailbox.org"},
}
for _, m := range mx {
m = strings.ToLower(m)
for _, t := range table {
if strings.Contains(m, t.sub) {
return t.name
}
func matchProvider(haystack string, table []provMatch) *Provider {
h := strings.ToLower(haystack)
for _, t := range table {
if strings.Contains(h, t.sub) {
return prov(t.name, t.dom)
}
}
return ""
return nil
}
func detectHostingProvider(h map[string]string) string {
var nsProviders = []provMatch{
{"cloudflare", "Cloudflare", "cloudflare.com"},
{"awsdns", "Amazon Route 53", "aws.amazon.com"},
{"googledomains", "Google Domains", "domains.google"},
{"dns.google", "Google Cloud DNS", "cloud.google.com"},
{"azure-dns", "Microsoft Azure", "azure.microsoft.com"},
{"domaincontrol", "GoDaddy", "godaddy.com"},
{"godaddy", "GoDaddy", "godaddy.com"},
{"registrar-servers", "Namecheap", "namecheap.com"},
{"namecheap", "Namecheap", "namecheap.com"},
{"nsone.net", "NS1", "ns1.com"},
{"digitalocean", "DigitalOcean", "digitalocean.com"},
{"linode", "Akamai", "akamai.com"},
{"akamaitech", "Akamai", "akamai.com"},
{"vultr", "Vultr", "vultr.com"},
{"he.net", "Hurricane Electric", "he.net"},
{"dynect", "Dyn (Oracle)", "dyn.com"},
{"ultradns", "UltraDNS", "verisign.com"},
{"dnsimple", "DNSimple", "dnsimple.com"},
{"hover", "Hover", "hover.com"},
{"enom", "eNom", "enom.com"},
{"worldnic", "Network Solutions", "networksolutions.com"},
{"zoneedit", "ZoneEdit", "zoneedit.com"},
{"easydns", "EasyDNS", "easydns.com"},
{"gandi", "Gandi", "gandi.net"},
{"ovh", "OVHcloud", "ovhcloud.com"},
{"hetzner", "Hetzner", "hetzner.com"},
{"your-server.de", "Hetzner", "hetzner.com"},
{"porkbun", "Porkbun", "porkbun.com"},
{"cloudns", "ClouDNS", "cloudns.net"},
{"desec", "deSEC", "desec.io"},
{"nic.cz", "CZ.NIC", "nic.cz"},
{"dnsmadeeasy", "DNS Made Easy", "dnsmadeeasy.com"},
{"ui-dns", "IONOS", "ionos.com"},
{"squarespacedns", "Squarespace", "squarespace.com"},
{"wordpress.com", "WordPress.com", "wordpress.com"},
{"dns-parking.com", "Hostinger", "hostinger.com"},
{"hostinger", "Hostinger", "hostinger.com"},
{"siteground", "SiteGround", "siteground.com"},
{"dreamhost", "DreamHost", "dreamhost.com"},
{"inmotionhosting", "InMotion", "inmotionhosting.com"},
{"bluehost", "Bluehost", "bluehost.com"},
{"hostgator", "HostGator", "hostgator.com"},
{"kinstadns", "Kinsta", "kinsta.com"},
{"kasserver", "ALL-INKL.COM", "all-inkl.com"},
{"all-inkl", "ALL-INKL.COM", "all-inkl.com"},
{"strato", "STRATO", "strato.de"},
{"udagdns", "United Domains", "uniteddomains.com"},
{"udag.de", "United Domains", "uniteddomains.com"},
{"transip", "TransIP", "transip.nl"},
{"infomaniak", "Infomaniak", "infomaniak.com"},
{"one.com", "One.com", "one.com"},
{"aruba", "Aruba", "aruba.it"},
{"technorail", "Aruba", "aruba.it"},
{"inwx", "INWX", "inwx.com"},
{"kasserver.com", "ALL-INKL.COM", "all-inkl.com"},
{"contabo", "Contabo", "contabo.com"},
{"netcup", "netcup", "netcup.eu"},
{"glesys", "GleSYS", "glesys.se"},
{"scaleway", "Scaleway", "scaleway.com"},
{"exoscale", "Exoscale", "exoscale.com"},
{"dnspod", "DNSPod", "dnspod.com"},
{"hicloud", "Huawei Cloud", "huaweicloud.com"},
{"alidns", "Alibaba Cloud", "alibabacloud.com"},
{"purelymail", "Purelymail", "purelymail.com"},
{"migadu", "Migadu", "migadu.com"},
{"improvmx", "ImprovMX", "improvmx.com"},
{"bunny", "Bunny", "bunny.net"},
{"edgekey", "Akamai", "akamai.com"},
{"edgesuite", "Akamai", "akamai.com"},
{"fastly", "Fastly", "fastly.com"},
{"cdn77", "CDN77", "cdn77.com"},
{"stackpath", "StackPath", "stackpath.com"},
{"ns.cloudflare", "Cloudflare", "cloudflare.com"},
{"vercel-dns", "Vercel", "vercel.com"},
}
var mailProviders = []provMatch{
{"aspmx", "Google Workspace", "google.com"},
{"googlemail", "Google Workspace", "google.com"},
{"google.com", "Google Workspace", "google.com"},
{"outlook.com", "Microsoft 365", "microsoft.com"},
{"protection.outlook", "Microsoft 365", "microsoft.com"},
{"mail.protection", "Microsoft 365", "microsoft.com"},
{"protonmail", "Proton Mail", "proton.me"},
{"proton.me", "Proton Mail", "proton.me"},
{"zoho", "Zoho Mail", "zoho.com"},
{"fastmail", "Fastmail", "fastmail.com"},
{"messagingengine", "Fastmail", "fastmail.com"},
{"mimecast", "Mimecast", "mimecast.com"},
{"proofpoint", "Proofpoint", "proofpoint.com"},
{"barracuda", "Barracuda", "barracuda.com"},
{"mailgun", "Mailgun", "mailgun.com"},
{"sendgrid", "SendGrid", "sendgrid.com"},
{"amazonses", "Amazon SES", "aws.amazon.com"},
{"yandex", "Yandex", "yandex.com"},
{"tuta", "Tuta", "tuta.com"},
{"mailbox.org", "mailbox.org", "mailbox.org"},
{"purelymail", "Purelymail", "purelymail.com"},
{"migadu", "Migadu", "migadu.com"},
{"mxroute", "MXroute", "mxroute.com"},
{"mymxroute", "MXroute", "mxroute.com"},
{"icloud.com", "iCloud Mail", "apple.com"},
{"infomaniak", "Infomaniak", "infomaniak.com"},
{"mailpod", "One.com", "one.com"},
{"one.com", "One.com", "one.com"},
{"gmx", "GMX", "gmx.net"},
{"forwardemail", "Forward Email", "forwardemail.net"},
{"posteo", "Posteo", "posteo.de"},
{"mailo", "Mailo", "mailo.com"},
{"hey.com", "HEY", "hey.com"},
{"privateemail", "Namecheap Private Email", "namecheap.com"},
{"registrar-servers", "Namecheap", "namecheap.com"},
{"ovh.net", "OVHcloud Mail", "ovhcloud.com"},
{"perfora.net", "IONOS Mail", "ionos.com"},
{"ui-dns", "IONOS", "ionos.com"},
{"improvmx", "ImprovMX", "improvmx.com"},
{"porkbun", "Porkbun Mail", "porkbun.com"},
{"kundenserver", "IONOS Mail", "ionos.com"},
{"t-online", "T-Online", "telekom.de"},
{"web.de", "WEB.DE", "web.de"},
{"mail.ru", "Mail.ru", "mail.ru"},
{"qq.com", "QQ Mail", "qq.com"},
{"163.com", "NetEase Mail", "163.com"},
{"exim.rs", "Exim", "exim.org"},
{"mx.cloudflare", "Cloudflare Email Routing", "cloudflare.com"},
}
// hostTargets: CNAME targets that reveal the hosting platform.
var hostTargets = []provMatch{
{"github.io", "GitHub Pages", "github.com"},
{"vercel-dns", "Vercel", "vercel.com"},
{"vercel.app", "Vercel", "vercel.com"},
{"netlify", "Netlify", "netlify.com"},
{"cloudfront.net", "AWS CloudFront", "aws.amazon.com"},
{"azurefd.net", "Azure Front Door", "azure.microsoft.com"},
{"azureedge.net", "Azure CDN", "azure.microsoft.com"},
{"cloudapp.net", "Microsoft Azure", "azure.microsoft.com"},
{"trafficmanager.net", "Microsoft Azure", "azure.microsoft.com"},
{"herokuapp.com", "Heroku", "heroku.com"},
{"herokudns.com", "Heroku", "heroku.com"},
{"workers.dev", "Cloudflare Workers", "cloudflare.com"},
{"pages.dev", "Cloudflare Pages", "cloudflare.com"},
{"cdn.cloudflare", "Cloudflare", "cloudflare.com"},
{"fly.dev", "Fly.io", "fly.io"},
{"onrender.com", "Render", "render.com"},
{"railway.app", "Railway", "railway.app"},
{"surge.sh", "Surge", "surge.sh"},
{"neocities.org", "Neocities", "neocities.org"},
{"readthedocs", "Read the Docs", "readthedocs.io"},
{"webflow.io", "Webflow", "webflow.com"},
{"squarespace.com", "Squarespace", "squarespace.com"},
{"wixdns.net", "Wix", "wix.com"},
{"wixsite.com", "Wix", "wix.com"},
{"editorx.com", "Wix Studio", "wix.com"},
{"myshopify.com", "Shopify", "shopify.com"},
{"wpengine", "WP Engine", "wpengine.com"},
{"wordpress.com", "WordPress.com", "wordpress.com"},
{"googlehosted.com", "Google", "google.com"},
{"googleapis.com", "Google Cloud", "cloud.google.com"},
{"fastly.net", "Fastly", "fastly.com"},
{"fastlylb.net", "Fastly", "fastly.com"},
{"akamaiedge.net", "Akamai", "akamai.com"},
{"edgekey.net", "Akamai", "akamai.com"},
{"edgesuite.net", "Akamai", "akamai.com"},
{"akamai.net", "Akamai", "akamai.com"},
{"b-cdn.net", "Bunny", "bunny.net"},
{"bunnycdn", "Bunny", "bunny.net"},
{"elb.amazonaws.com", "AWS ELB", "aws.amazon.com"},
{"amazonaws.com", "Amazon AWS", "aws.amazon.com"},
{"stackpathdns", "StackPath", "stackpath.com"},
{"kinsta.cloud", "Kinsta", "kinsta.com"},
{"pantheonsite.io", "Pantheon", "pantheon.io"},
{"pantheon.io", "Pantheon", "pantheon.io"},
{"ghost.io", "Ghost", "ghost.org"},
{"hubspot", "HubSpot", "hubspot.com"},
{"dnsimple.com", "DNSimple", "dnsimple.com"},
{"pressdns.com", "Pressable", "pressable.com"},
{"kinsta", "Kinsta", "kinsta.com"},
{"cdn77", "CDN77", "cdn77.com"},
{"incapsula", "Imperva", "imperva.com"},
{"sucuri", "Sucuri", "sucuri.net"},
{"flywheel", "Flywheel", "getflywheel.com"},
{"gridscale", "gridscale", "gridscale.io"},
{"webflow", "Webflow", "webflow.com"},
{"framer.app", "Framer", "framer.com"},
{"carrd.co", "Carrd", "carrd.co"},
{"super.so", "Super", "super.so"},
{"typedream.app", "Typedream", "typedream.com"},
{"cargo.site", "Cargo", "cargo.site"},
{"gitlab.io", "GitLab Pages", "gitlab.com"},
{"bitbucket.io", "Bitbucket", "bitbucket.org"},
{"codeberg.page", "Codeberg Pages", "codeberg.org"},
{"glitch.me", "Glitch", "glitch.com"},
{"repl.co", "Replit", "replit.com"},
{"replit.app", "Replit", "replit.com"},
{"deno.dev", "Deno Deploy", "deno.com"},
{"supabase.co", "Supabase", "supabase.com"},
{"firebaseapp.com", "Firebase", "firebase.google.com"},
{"web.app", "Firebase", "firebase.google.com"},
{"appspot.com", "Google App Engine", "cloud.google.com"},
}
// asnProviders: ip-api org/AS strings → hosting vendor.
var asnProviders = []provMatch{
{"cloudflare", "Cloudflare", "cloudflare.com"},
{"akamai", "Akamai", "akamai.com"},
{"linode", "Akamai (Linode)", "linode.com"},
{"amazon", "Amazon AWS", "aws.amazon.com"},
{"aws", "Amazon AWS", "aws.amazon.com"},
{"google", "Google Cloud", "cloud.google.com"},
{"microsoft", "Microsoft Azure", "azure.microsoft.com"},
{"azure", "Microsoft Azure", "azure.microsoft.com"},
{"digitalocean", "DigitalOcean", "digitalocean.com"},
{"hetzner", "Hetzner", "hetzner.com"},
{"ovh", "OVHcloud", "ovhcloud.com"},
{"kimsufi", "OVHcloud", "ovhcloud.com"},
{"soyoustart", "OVHcloud", "ovhcloud.com"},
{"vultr", "Vultr", "vultr.com"},
{"choopa", "Vultr", "vultr.com"},
{"fastly", "Fastly", "fastly.com"},
{"vercel", "Vercel", "vercel.com"},
{"netlify", "Netlify", "netlify.com"},
{"github", "GitHub", "github.com"},
{"scaleway", "Scaleway", "scaleway.com"},
{"oracle", "Oracle Cloud", "oracle.com"},
{"alibaba", "Alibaba Cloud", "alibabacloud.com"},
{"tencent", "Tencent Cloud", "cloud.tencent.com"},
{"contabo", "Contabo", "contabo.com"},
{"ionos", "IONOS", "ionos.com"},
{"1&1", "IONOS", "ionos.com"},
{"shopify", "Shopify", "shopify.com"},
{"netcup", "netcup", "netcup.eu"},
{"upcloud", "UpCloud", "upcloud.com"},
{"kamatera", "Kamatera", "kamatera.com"},
{"fly.io", "Fly.io", "fly.io"},
{"render", "Render", "render.com"},
{"railway", "Railway", "railway.app"},
{"bunny", "Bunny", "bunny.net"},
{"hostinger", "Hostinger", "hostinger.com"},
{"siteground", "SiteGround", "siteground.com"},
{"godaddy", "GoDaddy", "godaddy.com"},
{"automattic", "WordPress.com", "wordpress.com"},
{"squarespace", "Squarespace", "squarespace.com"},
{"wix", "Wix", "wix.com"},
{"infomaniak", "Infomaniak", "infomaniak.com"},
{"dreamhost", "DreamHost", "dreamhost.com"},
{"liquid web", "Liquid Web", "liquidweb.com"},
{"liquidweb", "Liquid Web", "liquidweb.com"},
{"inmotion", "InMotion", "inmotionhosting.com"},
{"unified layer", "Bluehost", "bluehost.com"},
{"hostgator", "HostGator", "hostgator.com"},
{"glesys", "GleSYS", "glesys.se"},
{"exoscale", "Exoscale", "exoscale.com"},
{"incapsula", "Imperva", "imperva.com"},
{"sucuri", "Sucuri", "sucuri.net"},
{"ddos-guard", "DDoS-Guard", "ddos-guard.net"},
{"huawei", "Huawei Cloud", "huaweicloud.com"},
{"leaseweb", "Leaseweb", "leaseweb.com"},
{"aruba", "Aruba", "aruba.it"},
{"krystal", "Krystal", "krystal.uk"},
{"namecheap", "Namecheap", "namecheap.com"},
{"web-hosting.com", "Namecheap", "namecheap.com"},
{"zendesk", "Zendesk", "zendesk.com"},
{"kinsta", "Kinsta", "kinsta.com"},
{"flywheel", "Flywheel", "getflywheel.com"},
{"wp engine", "WP Engine", "wpengine.com"},
{"pantheon", "Pantheon", "pantheon.io"},
{"meta", "Meta", "meta.com"},
{"gitlab", "GitLab", "gitlab.com"},
{"stackpath", "StackPath", "stackpath.com"},
{"cdn77", "CDN77", "cdn77.com"},
{"imperva", "Imperva", "imperva.com"},
{"framer", "Framer", "framer.com"},
{"webflow", "Webflow", "webflow.com"},
{"readthedocs", "Read the Docs", "readthedocs.io"},
{"transip", "TransIP", "transip.nl"},
{"strato", "STRATO", "strato.de"},
{"all-inkl", "ALL-INKL.COM", "all-inkl.com"},
{"one.com", "One.com", "one.com"},
{"netlify", "Netlify", "netlify.com"},
{"neocities", "Neocities", "neocities.org"},
}
var registrarProviders = []provMatch{
{"markmonitor", "MarkMonitor", "markmonitor.com"},
{"namecheap", "Namecheap", "namecheap.com"},
{"cloudflare", "Cloudflare", "cloudflare.com"},
{"godaddy", "GoDaddy", "godaddy.com"},
{"gandi", "Gandi", "gandi.net"},
{"porkbun", "Porkbun", "porkbun.com"},
{"ovh", "OVHcloud", "ovhcloud.com"},
{"name.com", "Name.com", "name.com"},
{"ionos", "IONOS", "ionos.com"},
{"1&1", "IONOS", "ionos.com"},
{"1und1", "IONOS", "ionos.com"},
{"tucows", "Tucows", "tucows.com"},
{"squarespace", "Squarespace", "squarespace.com"},
{"google", "Google Domains", "domains.google"},
{"dynadot", "Dynadot", "dynadot.com"},
{"namesilo", "NameSilo", "namesilo.com"},
{"epik", "Epik", "epik.com"},
{"hexonet", "HEXONET", "hexonet.net"},
{"1api", "HEXONET", "hexonet.net"},
{"openprovider", "Openprovider", "openprovider.com"},
{"internetx", "InterNetX", "internetx.com"},
{"nic.cz", "CZ.NIC", "nic.cz"},
{"hostinger", "Hostinger", "hostinger.com"},
{"com laude", "Com Laude", "comlaude.com"},
{"csc", "CSC", "cscdbs.com"},
{"safenames", "Safenames", "safenames.net"},
{"xinnet", "Xin Net", "xinnet.com"},
{"alibaba", "Alibaba Cloud", "alibabacloud.com"},
{"dnspod", "DNSPod", "dnspod.com"},
{"tencent", "Tencent Cloud", "cloud.tencent.com"},
{"gabia", "Gabia", "gabia.com"},
{"reg.ru", "REG.RU", "reg.ru"},
{"nic.ru", "RU-CENTER", "nic.ru"},
{"nordname", "NordName", "nordname.com"},
{"internet.bs", "Internet.bs", "internetbs.net"},
{"key-systems", "Key-Systems", "key-systems.net"},
{"united domains", "United Domains", "uniteddomains.com"},
{"united-domains", "United Domains", "uniteddomains.com"},
{"scaleway", "Scaleway", "scaleway.com"},
{"hetzner", "Hetzner", "hetzner.com"},
{"infomaniak", "Infomaniak", "infomaniak.com"},
{"one.com", "One.com", "one.com"},
{"netim", "Netim", "netim.com"},
{"inwx", "INWX", "inwx.com"},
{"internetworx", "INWX", "inwx.com"},
{"checkdomain", "checkdomain", "checkdomain.de"},
{"domaindiscount24", "domaindiscount24", "domaindiscount24.com"},
{"easyname", "easyname", "easyname.com"},
{"world4you", "World4You", "world4you.com"},
{"all-inkl", "ALL-INKL.COM", "all-inkl.com"},
{"transip", "TransIP", "transip.nl"},
{"strato", "STRATO", "strato.de"},
{"hover", "Hover", "hover.com"},
{"enom", "eNom", "enom.com"},
{"namebright", "NameBright", "namebright.com"},
{"sav.com", "Sav.com", "sav.com"},
{"turncommerce", "DropCatch", "dropcatch.com"},
{"eurodns", "EuroDNS", "eurodns.com"},
{"gransy", "Gransy", "regtons.com"},
{"101domain", "101domain", "101domain.com"},
{"iwantmyname", "iwantmyname", "iwantmyname.com"},
{"rebel", "Rebel", "rebel.ca"},
{"blacknight", "Blacknight", "blacknight.com"},
{"gname", "GNAME", "gname.com"},
{"west.cn", "West.cn", "west.cn"},
{"west263", "West.cn", "west.cn"},
{"imena.ua", "Imena.ua", "imena.ua"},
{"ukraine.com.ua", "Hosting Ukraine", "ukraine.com.ua"},
{"beget", "Beget", "beget.com"},
{"selectel", "Selectel", "selectel.ru"},
{"timeweb", "Timeweb", "timeweb.ru"},
{"papaki", "Papaki", "papaki.com"},
{"dotname", "Dotname", "dotname.co.kr"},
{"gabia", "Gabia", "gabia.com"},
{"registar-se", "Namecheap", "namecheap.com"},
{"registrar-servers", "Namecheap", "namecheap.com"},
{"namebay", "Namebay", "namebay.com"},
{"domainpeople", "DomainPeople", "domainpeople.com"},
{"networksolutions", "Network Solutions", "networksolutions.com"},
{"network solutions", "Network Solutions", "networksolutions.com"},
{"register.com", "Register.com", "register.com"},
{"webcentral", "Webcentral", "webcentral.com.au"},
{"melbourne it", "Melbourne IT", "melbourneit.com.au"},
{"corporatedomains", "CSC", "cscdbs.com"},
{"amazon registrar", "Amazon Registrar", "aws.amazon.com"},
{"nominet", "Nominet", "nominet.uk"},
{"123-reg", "123 Reg", "123-reg.co.uk"},
{"domain.com", "Domain.com", "domain.com"},
{"dotster", "Dotster", "dotster.com"},
{"joker", "Joker.com", "joker.com"},
{"variomedia", "Variomedia", "variomedia.de"},
{"goneo", "goneo", "goneo.de"},
{"manitu", "manitu", "manitu.de"},
{"hosteurope", "Host Europe", "hosteurope.de"},
{"keyweb", "Keyweb", "keyweb.de"},
{"wedos", "WEDOS", "wedos.com"},
{"active24", "Active 24", "active24.com"},
{"forpsi", "FORPSI", "forpsi.com"},
{"ignum", "IGNUM", "ignum.cz"},
{"oneprovider", "OneProvider", "oneprovider.com"},
{"reg.cz", "REG.CZ", "reg.cz"},
{"subreg", "Websupport", "websupport.sk"},
{"websupport", "Websupport", "websupport.sk"},
{"loopia", "Loopia", "loopia.se"},
{"binero", "Binero", "binero.se"},
{"one.se", "One.com", "one.com"},
{"simply.com", "Simply.com", "simply.com"},
{"domeneshop", "Domeneshop", "domeneshop.no"},
{"syse", "Syse", "syse.no"},
{"registrar.eu", "Openprovider", "openprovider.com"},
{"mondo", "Mondo International", "mondo.com"},
{"dansk.net", "Dansk.net", "dansk.net"},
{"internet invest", "Imena.ua", "imena.ua"},
{"netearth", "NetEarth One", "netearthone.com"},
{"publicdomainregistry", "PublicDomainRegistry", "publicdomainregistry.com"},
{"pdr ltd", "PublicDomainRegistry", "publicdomainregistry.com"},
{"webnames", "Webnames", "webnames.ca"},
{"rebel.ca", "Rebel", "rebel.ca"},
{"easydns", "easyDNS", "easydns.com"},
{"namespro", "Namespro", "namespro.ca"},
{"sibername", "Sibername", "sibername.com"},
{"netfirms", "Netfirms", "netfirms.com"},
{"names.co.uk", "names.co.uk", "names.co.uk"},
{"namesco", "names.co.uk", "names.co.uk"},
{"fasthosts", "Fasthosts", "fasthosts.co.uk"},
{"heart internet", "Heart Internet", "heartinternet.uk"},
{"dreamhost", "DreamHost", "dreamhost.com"},
{"pair domains", "pair Domains", "pairdomains.com"},
{"domainthenet", "Domain The Net", "domainthenet.com"},
{"domainregistry", "DomainRegistry", "domainregistry.com"},
{"moniker", "Moniker", "moniker.com"},
{"fabulous", "Fabulous", "fabulous.com"},
{"above.com", "Above.com", "above.com"},
{"united-internet", "United Internet", "united-internet.de"},
{"united internet", "United Internet", "united-internet.de"},
{"arsys", "Arsys", "arsys.es"},
{"piensa solutions", "Piensa Solutions", "piensasolutions.com"},
{"dinahosting", "Dinahosting", "dinahosting.com"},
{"cdmon", "CDmon", "cdmon.com"},
{"nominalia", "Nominalia", "nominalia.com"},
{"register.it", "Register.it", "register.it"},
{"ovh sas", "OVHcloud", "ovhcloud.com"},
{"gandi sas", "Gandi", "gandi.net"},
{"blue razor", "GoDaddy", "godaddy.com"},
{"wild west domains", "GoDaddy", "godaddy.com"},
{"starfield", "GoDaddy", "godaddy.com"},
{"nameking", "Namecheap", "namecheap.com"},
{"enom", "eNom", "enom.com"},
{"realtime register", "Realtime Register", "realtimeregister.com"},
{"rrpproxy", "RRPproxy", "rrpproxy.net"},
{"key-systems gmbh", "Key-Systems", "key-systems.net"},
{"internet domains", "Internet Domains", "internetdomains.ch"},
{"corehub", "CORE", "corehub.net"},
{"don dominio", "DonDominio", "dondominio.com"},
{"arsys internet", "Arsys", "arsys.es"},
{"key-systems", "Key-Systems", "key-systems.net"},
{"centralnic", "CentralNic", "centralnic.com"},
{"internet.bs corp", "Internet.bs", "internetbs.net"},
{"gkg.net", "GKG.NET", "gkg.net"},
{"namecheap inc", "Namecheap", "namecheap.com"},
{"namecheap, inc", "Namecheap", "namecheap.com"},
{"google llc", "Google Domains", "domains.google"},
{"squarespace domains", "Squarespace", "squarespace.com"},
{"turncommerce, inc.", "DropCatch", "dropcatch.com"},
{"amazon", "Amazon Registrar", "aws.amazon.com"},
{"name.com, inc", "Name.com", "name.com"},
{"godaddy.com, llc", "GoDaddy", "godaddy.com"},
{"tucows domains", "Tucows", "tucows.com"},
{"porkbun llc", "Porkbun", "porkbun.com"},
{"cloudflare, inc", "Cloudflare", "cloudflare.com"},
}
// caProviders: issuer CN/org → certificate authority vendor.
var caProviders = []provMatch{
{"let's encrypt", "Let's Encrypt", "letsencrypt.org"},
{"letsencrypt", "Let's Encrypt", "letsencrypt.org"},
{"digicert", "DigiCert", "digicert.com"},
{"sectigo", "Sectigo", "sectigo.com"},
{"comodoca", "Sectigo", "sectigo.com"},
{"comodo", "Sectigo", "sectigo.com"},
{"globalsign", "GlobalSign", "globalsign.com"},
{"godaddy", "GoDaddy", "godaddy.com"},
{"starfield", "GoDaddy", "godaddy.com"},
{"entrust", "Entrust", "entrust.com"},
{"google trust", "Google Trust Services", "pki.goog"},
{"pki.goog", "Google Trust Services", "pki.goog"},
{"amazon", "Amazon Trust Services", "amazontrust.com"},
{"zerossl", "ZeroSSL", "zerossl.com"},
{"buypass", "Buypass", "buypass.com"},
{"ssl.com", "SSL.com", "ssl.com"},
{"actalis", "Actalis", "actalis.com"},
{"certum", "Certum", "certum.pl"},
{"harica", "HARICA", "harica.gr"},
{"swisssign", "SwissSign", "swisssign.com"},
{"cloudflare", "Cloudflare", "cloudflare.com"},
{"microsoft", "Microsoft", "microsoft.com"},
{"geotrust", "DigiCert", "digicert.com"},
{"thawte", "DigiCert", "digicert.com"},
{"rapidssl", "DigiCert", "digicert.com"},
{"usertrust", "Sectigo", "sectigo.com"},
{"certainly", "Certainly", "certainly.io"},
{"isrg", "Let's Encrypt", "letsencrypt.org"},
{"e1", "Let's Encrypt", "letsencrypt.org"},
{"r3", "Let's Encrypt", "letsencrypt.org"},
{"r10", "Let's Encrypt", "letsencrypt.org"},
{"r11", "Let's Encrypt", "letsencrypt.org"},
{"wotrus", "WoTrus", "wotrus.com"},
{"cameo", "Cameo", "cameo.com"},
}
// detectHostProvider picks the hosting vendor: CNAME target first (most
// precise), then response headers, then the ASN/org of the primary IPv4.
func detectHostProvider(r *Result) *Provider {
if r.CNAME != "" {
if p := matchProvider(r.CNAME, hostTargets); p != nil {
return p
}
}
h := r.Headers
server := strings.ToLower(h["server"] + " " + h["x-powered-by"] + " " + h["via"])
if _, ok := h["cf-ray"]; ok || strings.Contains(server, "cloudflare") {
return "Cloudflare"
switch {
case h["cf-ray"] != "" || strings.Contains(server, "cloudflare"):
return prov("Cloudflare", "cloudflare.com")
case h["x-vercel-id"] != "" || strings.Contains(server, "vercel"):
return prov("Vercel", "vercel.com")
case h["x-nf-request-id"] != "" || strings.Contains(server, "netlify"):
return prov("Netlify", "netlify.com")
case h["x-amz-cf-id"] != "" || strings.Contains(server, "cloudfront"):
return prov("AWS CloudFront", "aws.amazon.com")
case h["x-github-request-id"] != "" || strings.Contains(server, "github"):
return prov("GitHub Pages", "github.com")
case strings.Contains(server, "fly.io") || h["fly-request-id"] != "":
return prov("Fly.io", "fly.io")
case strings.Contains(server, "render"):
return prov("Render", "render.com")
case strings.Contains(server, "shopify"):
return prov("Shopify", "shopify.com")
case strings.Contains(server, "squarespace"):
return prov("Squarespace", "squarespace.com")
case strings.Contains(server, "wix"):
return prov("Wix", "wix.com")
}
if _, ok := h["x-vercel-id"]; ok || strings.Contains(server, "vercel") {
return "Vercel"
webServers := []provMatch{
{"awselb", "AWS ELB", "aws.amazon.com"},
{"gws", "Google", "google.com"},
{"kestrel", "Kestrel/.NET", "microsoft.com"},
{"cowboy", "Cowboy/Erlang", "heroku.com"},
}
if _, ok := h["x-nf-request-id"]; ok || strings.Contains(server, "netlify") {
return "Netlify"
if p := matchProvider(server, webServers); p != nil {
return p
}
if _, ok := h["x-amz-cf-id"]; ok || strings.Contains(server, "cloudfront") {
return "AWS CloudFront"
if p := matchProvider(r.HostOrg+" "+r.HostAS+" "+r.HostISP, asnProviders); p != nil {
return p
}
if _, ok := h["x-github-request-id"]; ok || strings.Contains(server, "github") {
return "GitHub Pages"
if p := matchProvider(server, []provMatch{
{"nginx", "nginx", "nginx.org"},
{"apache", "Apache", "apache.org"},
{"caddy", "Caddy", "caddyserver.com"},
{"litespeed", "LiteSpeed", "litespeedtech.com"},
{"openresty", "OpenResty", "openresty.org"},
{"envoy", "Envoy", "envoyproxy.io"},
}); p != nil {
return p
}
if strings.Contains(server, "fly.io") || h["fly-request-id"] != "" {
return "Fly.io"
}
if strings.Contains(server, "render") {
return "Render"
}
table := []struct{ sub, name string }{
{"nginx", "nginx"}, {"apache", "Apache"}, {"caddy", "Caddy"},
{"litespeed", "LiteSpeed"}, {"openresty", "OpenResty"},
{"envoy", "Envoy"}, {"awselb", "AWS ELB"}, {"gws", "Google"},
{"kestrel", "Kestrel/.NET"}, {"cowboy", "Cowboy/Erlang"},
}
for _, t := range table {
if strings.Contains(server, t.sub) {
return t.name
}
}
return ""
return nil
}
func detectCA(issuer string) string {
i := strings.ToLower(issuer)
table := []struct{ sub, name string }{
{"let's encrypt", "Let's Encrypt"}, {"letsencrypt", "Let's Encrypt"},
{"digicert", "DigiCert"}, {"sectigo", "Sectigo"}, {"comodoca", "Sectigo"},
{"globalsign", "GlobalSign"}, {"godaddy", "GoDaddy"}, {"starfield", "GoDaddy"},
{"entrust", "Entrust"}, {"google trust", "Google Trust Services"},
{"amazon", "Amazon"}, {"zerossl", "ZeroSSL"}, {"buypass", "Buypass"},
{"ssl.com", "SSL.com"}, {"actalis", "Actalis"}, {"certum", "Certum"},
{"harica", "HARICA"}, {"swisssign", "SwissSign"}, {"cloudflare", "Cloudflare"},
{"pki.goog", "Google Trust Services"}, {"microsoft", "Microsoft"},
}
for _, t := range table {
if strings.Contains(i, t.sub) {
return t.name
}
}
return ""
// txtProviders: SPF includes and domain-verification strings → vendor.
var txtProviders = []provMatch{
{"v=spf1 include:_spf.google.com", "Google Workspace", "google.com"},
{"include:spf.protonmail.ch", "Proton Mail", "proton.me"},
{"include:mail.protonmail.ch", "Proton Mail", "proton.me"},
{"include:_spf.mx.cloudflare.net", "Cloudflare", "cloudflare.com"},
{"include:servers.mcsv.net", "Mailchimp", "mailchimp.com"},
{"include:amazonses.com", "Amazon SES", "aws.amazon.com"},
{"include:mailgun.org", "Mailgun", "mailgun.com"},
{"include:sendgrid.net", "SendGrid", "sendgrid.com"},
{"include:spf.protection.outlook.com", "Microsoft 365", "microsoft.com"},
{"include:spf.purelymail.com", "Purelymail", "purelymail.com"},
{"_spf.purelymail.com", "Purelymail", "purelymail.com"},
{"purelymail_ownership_proof", "Purelymail", "purelymail.com"},
{"openai-domain-verification", "OpenAI", "openai.com"},
{"include:_spf.migadu.com", "Migadu", "migadu.com"},
{"include:mx.zohomail.com", "Zoho Mail", "zoho.com"},
{"include:_spf.fastmail.com", "Fastmail", "fastmail.com"},
{"include:spf.mtasv.net", "Postmark", "postmarkapp.com"},
{"include:_spf.salesforce.com", "Salesforce", "salesforce.com"},
{"include:spf.improvmx.com", "ImprovMX", "improvmx.com"},
{"include:mail.zendesk.com", "Zendesk", "zendesk.com"},
{"include:spf.mandrillapp.com", "Mailchimp", "mailchimp.com"},
{"include:mailspike.net", "Mailspike", "mailspike.net"},
{"include:spf.messagelabs.com", "Broadcom", "broadcom.com"},
{"include:_spf.mx.yandex", "Yandex", "yandex.com"},
{"include:spf.tuta.io", "Tuta", "tuta.com"},
{"google-site-verification", "Google", "google.com"},
{"ms=", "Microsoft 365", "microsoft.com"},
{"facebook-domain-verification", "Meta", "meta.com"},
{"apple-domain-verification", "Apple", "apple.com"},
{"keybase-site-verification", "Keybase", "keybase.io"},
{"keybase", "Keybase", "keybase.io"},
{"protonmail-verification", "Proton", "proton.me"},
{"have-i-been-pwned-verification", "Have I Been Pwned", "haveibeenpwned.com"},
{"atlassian-domain-verification", "Atlassian", "atlassian.com"},
{"docker-verification", "Docker", "docker.com"},
{"docusign", "DocuSign", "docusign.com"},
{"yandex-verification", "Yandex", "yandex.com"},
{"stripe-verification", "Stripe", "stripe.com"},
{"dropbox-domain-verification", "Dropbox", "dropbox.com"},
{"brave-ledger-verification", "Brave", "brave.com"},
{"detectify-verification", "Detectify", "detectify.com"},
{"adobe-idp-site-verification", "Adobe", "adobe.com"},
{"webexdomainverification", "Cisco Webex", "webex.com"},
{"zoho-verification", "Zoho", "zoho.com"},
{"mailru-verification", "Mail.ru", "mail.ru"},
{"baidu-site-verification", "Baidu", "baidu.com"},
{"google-gws-recovery-domain-verification", "Google", "google.com"},
{"status-page-domain-verification", "Atlassian", "atlassian.com"},
{"citrix-verification", "Citrix", "citrix.com"},
{"tiktok-domain-verification", "TikTok", "tiktok.com"},
{"netlify-verification", "Netlify", "netlify.com"},
{"vercel-domain-verification", "Vercel", "vercel.com"},
{"workplace-domain-verification", "Meta", "meta.com"},
{"v=dmarc1", "DMARC", "dmarc.org"},
{"v=dkim1", "DKIM", "dkim.org"},
}
func matchTXTProvider(txt string) *Provider {
return matchProvider(txt, txtProviders)
}
View File
Whitespace-only changes.
+134 -104
View File
@@ -1,8 +1,8 @@
import { useEffect, useRef, useState } from "react";
import { Bell, Download, Lock, Upload } from "lucide-react";
import { Bell, Download, HardDrive, Image, LayoutGrid, Lock, Moon, Palette, Sun, Upload, type LucideIcon } from "lucide-react";
import { useBoard } from "@/board/store";
import { api } from "@/api";
import { rendererDescriptions, rendererLabels, rendererList } from "@/renderers";
import { rendererDescriptions, rendererIcons, rendererLabels, rendererList } from "@/renderers";
import { cn } from "@/lib/utils";
import { LOCALES, t } from "@/i18n";
import { Button } from "@/components/ui/button";
@@ -44,6 +44,19 @@ const TRANSPORTS = {
} as const;
type TransportName = keyof typeof TRANSPORTS;
// Group — a labelled settings section. Icon + mono header keeps the dialog
// scannable now that it holds five distinct concerns.
function Group({ icon: Icon, title, children }: { icon: LucideIcon; title: string; children: React.ReactNode }) {
return (
<section className="flex flex-col gap-2.5 border-t border-border/60 pt-4 first:border-0 first:pt-0">
<h3 className="flex items-center gap-1.5 font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">
<Icon size={11} /> {title}
</h3>
{children}
</section>
);
}
export function SettingsDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (o: boolean) => void }) {
const board = useBoard();
const fileRef = useRef<HTMLInputElement>(null);
@@ -143,82 +156,103 @@ export function SettingsDialog({ open, onOpenChange }: { open: boolean; onOpenCh
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-sm">
<DialogContent className="max-w-xl">
<DialogHeader>
<DialogTitle>{t("settings.title")}</DialogTitle>
<DialogDescription>{t("settings.desc")}</DialogDescription>
</DialogHeader>
<div className="flex max-h-[70vh] flex-col gap-4 overflow-y-auto pr-1">
<div className="flex flex-col gap-1.5">
<Label>{t("settings.renderer")}</Label>
<div className="grid grid-cols-2 gap-1.5" role="radiogroup" aria-label={t("settings.renderer")}>
{rendererList.map((r) => (
<button
key={r}
type="button"
role="radio"
aria-checked={board.renderer === r}
onClick={() => void board.setSetting("renderer", r)}
className={cn(
"rounded-[8px] border px-3 py-2.5 text-left transition-colors",
board.renderer === r
? "border-border-strong bg-surface-hover"
: "border-border hover:border-border-strong hover:bg-surface-hover",
)}
<div className="flex max-h-[72vh] flex-col gap-5 overflow-y-auto pr-1">
<Group icon={LayoutGrid} title={t("settings.renderer")}>
<div className="grid grid-cols-2 gap-2" role="radiogroup" aria-label={t("settings.renderer")}>
{rendererList.map((r) => {
const Icon = rendererIcons[r];
const active = board.renderer === r;
return (
<button
key={r}
type="button"
role="radio"
aria-checked={active}
onClick={() => void board.setSetting("renderer", r)}
className={cn(
"flex items-center gap-3 rounded-[9px] border px-3 py-2.5 text-left transition-colors",
active
? "border-accent bg-accent/10"
: "border-border hover:border-border-strong hover:bg-surface-hover",
)}
>
<span className={cn(
"flex size-8 shrink-0 items-center justify-center rounded-[7px] border",
active ? "border-accent/40 bg-accent/15 text-accent" : "border-border bg-bg text-text-faint",
)}>
<Icon size={15} />
</span>
<span className="min-w-0">
<span className="block text-[12.5px] font-medium">{rendererLabels[r]}</span>
<span className="block truncate font-mono text-[10px] text-text-faint">{rendererDescriptions[r]}</span>
</span>
</button>
);
})}
</div>
</Group>
<Group icon={Palette} title={t("settings.appearance")}>
<div className="grid grid-cols-2 gap-3 max-[560px]:grid-cols-1">
<div className="flex flex-col gap-1.5">
<Label>{t("settings.theme")}</Label>
<div className="flex overflow-hidden rounded-[7px] border border-border">
{(["dark", "light"] as const).map((th) => (
<button
key={th}
type="button"
aria-pressed={board.theme === th}
onClick={() => void board.setSetting("theme", th)}
className={cn(
"flex flex-1 items-center justify-center gap-1.5 px-3 py-1.5 text-[12px] transition-colors",
board.theme === th ? "bg-surface-hover text-text" : "text-text-faint hover:text-text",
)}
>
{th === "dark" ? <Moon size={12} /> : <Sun size={12} />}
{th === "dark" ? t("settings.dark") : t("settings.light")}
</button>
))}
</div>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t("settings.language")}</Label>
<Select
value={board.locale || "en"}
onValueChange={(v) => void board.setSetting("locale", v)}
>
<span className="block text-[12.5px] font-medium">{rendererLabels[r]}</span>
<span className="block font-mono text-[10px] text-text-faint">{rendererDescriptions[r]}</span>
</button>
))}
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{Object.entries(LOCALES).map(([k, name]) => (
<SelectItem key={k} value={k}>{name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t("settings.theme")}</Label>
<Select value={board.theme} onValueChange={(v) => {
if (v === "dark" || v === "light") void board.setSetting("theme", v);
}}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="dark">{t("settings.dark")}</SelectItem>
<SelectItem value="light">{t("settings.light")}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t("settings.language")}</Label>
<Select
value={board.locale || "en"}
onValueChange={(v) => void board.setSetting("locale", v)}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{Object.entries(LOCALES).map(([k, name]) => (
<SelectItem key={k} value={k}>{name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="accent">{t("settings.accent")}</Label>
<div className="flex gap-2">
<input
id="accent" type="color" aria-label={t("settings.accent")}
value={/^#[0-9a-fA-F]{6}$/.test(board.accent) ? board.accent : "#3b82f6"}
onChange={(e) => void board.setSetting("accent", e.target.value)}
className="h-8 w-12 cursor-pointer rounded-[6px] border border-border bg-surface p-0.5"
/>
<Button type="button" variant="outline" size="sm" onClick={() => void board.setSetting("accent", "")}>
{t("common.reset")}
</Button>
<div className="flex flex-col gap-1.5">
<Label htmlFor="accent">{t("settings.accent")}</Label>
<div className="flex items-center gap-2">
<input
id="accent" type="color" aria-label={t("settings.accent")}
value={/^#[0-9a-fA-F]{6}$/.test(board.accent) ? board.accent : "#3b82f6"}
onChange={(e) => void board.setSetting("accent", e.target.value)}
className="h-8 w-12 cursor-pointer rounded-[6px] border border-border bg-surface p-0.5"
/>
<span className="font-mono text-[11px] text-text-faint">{board.accent || "—"}</span>
<Button type="button" variant="outline" size="sm" onClick={() => void board.setSetting("accent", "")}>
{t("common.reset")}
</Button>
</div>
</div>
</div>
</Group>
<div className="flex flex-col gap-1.5">
<Label htmlFor="wallpaper">{t("settings.wallpaper")}</Label>
<Group icon={Image} title={t("settings.wallpaper")}>
<Input
id="wallpaper"
value={board.wallpaper}
@@ -226,22 +260,19 @@ export function SettingsDialog({ open, onOpenChange }: { open: boolean; onOpenCh
placeholder={t("settings.wallpaperPh")}
className="font-mono text-[12px]"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="custom-css">{t("settings.customCss")}</Label>
<textarea
id="custom-css"
aria-label={t("settings.customCss")}
value={board.customCss}
onChange={(e) => void board.setSetting("customCss", e.target.value)}
placeholder=".item-tile { border-radius: 0; }"
rows={3}
className="w-full resize-y rounded-[7px] border border-border bg-bg px-2.5 py-2 font-mono text-[11.5px] outline-none focus:border-border-strong"
/>
</div>
<p className="text-[11px] text-text-faint">{t("settings.customCssHint")}</p>
</Group>
<div className="flex flex-col gap-1.5">
<Label>{t("settings.notifications")}</Label>
<Group icon={Bell} title={t("settings.notifications")}>
<div className="flex gap-2">
<Select value={transport} onValueChange={(v) => {
// SAFETY: Select only yields TRANSPORTS keys.
@@ -281,13 +312,13 @@ export function SettingsDialog({ open, onOpenChange }: { open: boolean; onOpenCh
{t("settings.notifyFanout")}
</p>
{notifyMsg ? <p className="text-[11px] text-text-faint">{notifyMsg}</p> : null}
</div>
</Group>
<div className="flex flex-col gap-2.5 rounded-lg border border-border p-3">
<div className="flex items-center justify-between">
<Label className="flex items-center gap-1.5">
<Lock size={12} /> {t("auth.section")}
</Label>
<Group icon={Lock} title={t("auth.section")}>
<div className="flex items-center justify-between rounded-lg border border-border px-3 py-2.5">
<p className="text-[11px] text-text-faint">
{auth.enabled ? `${t("auth.onHint")} ${auth.username ?? ""}` : t("auth.offHint")}
</p>
{auth.enabled ? (
<div className="flex gap-2">
<Button type="button" variant="outline" size="sm" onClick={() => void signOut()}>
@@ -303,29 +334,28 @@ export function SettingsDialog({ open, onOpenChange }: { open: boolean; onOpenCh
</Button>
)}
</div>
<p className="text-[11px] text-text-faint">
{auth.enabled ? `${t("auth.onHint")} ${auth.username ?? ""}` : t("auth.offHint")}
</p>
</div>
</Group>
<div className="flex gap-2 pt-1">
<Button type="button" variant="outline" size="sm" onClick={() => void exportJson()}>
<Download size={12} /> {t("settings.exportJson")}
</Button>
<Button type="button" variant="outline" size="sm" onClick={() => fileRef.current?.click()}>
<Upload size={12} /> {t("settings.import")}
</Button>
<input
ref={fileRef} type="file" accept=".json,.yml,.yaml" className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) void importJson(f);
e.target.value = "";
}}
/>
</div>
<p className="text-[11px] text-text-faint">{t("settings.importHint")}</p>
{msg ? <p className="text-[12px] text-destructive">{msg}</p> : null}
<Group icon={HardDrive} title={t("settings.backup")}>
<div className="flex gap-2">
<Button type="button" variant="outline" size="sm" onClick={() => void exportJson()}>
<Download size={12} /> {t("settings.exportJson")}
</Button>
<Button type="button" variant="outline" size="sm" onClick={() => fileRef.current?.click()}>
<Upload size={12} /> {t("settings.import")}
</Button>
<input
ref={fileRef} type="file" accept=".json,.yml,.yaml" className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) void importJson(f);
e.target.value = "";
}}
/>
</div>
<p className="text-[11px] text-text-faint">{t("settings.importHint")}</p>
{msg ? <p className="text-[12px] text-destructive">{msg}</p> : null}
</Group>
</div>
</DialogContent>
</Dialog>
+235
View File
@@ -0,0 +1,235 @@
// Shared time-series chart primitives — the Beszel-grade look on our tokens:
// titled cards with a subtitle, soft area fills over a real time axis,
// unit-aware Y ticks, a dotted tooltip sorted by value, and legend chips.
import type { ReactNode } from "react";
import {
Area,
CartesianGrid,
ComposedChart,
Line,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { t } from "@/i18n";
/** One chart row: epoch ms plus named series values. */
export type ChartRow = { t: number } & Record<string, number | undefined>;
export interface Series {
key: string;
label: string;
color: string;
/** Shared value → stacked areas (container charts). */
stack?: string;
/** Render as a bare line, no fill (temperatures). */
line?: boolean;
}
export const CHART_COLORS = [
"var(--chart-1)",
"var(--chart-2)",
"var(--chart-3)",
"var(--chart-4)",
"var(--chart-5)",
"var(--chart-6)",
"var(--chart-7)",
"var(--chart-8)",
];
export const chartColor = (i: number) => CHART_COLORS[i % CHART_COLORS.length] ?? "var(--accent)";
const axisTick = { fontSize: 10, fill: "var(--text-faint)" };
/** Auto Y-domain with headroom, rounding to a readable ceiling. */
export function niceMax(dataMax: number): number {
if (!Number.isFinite(dataMax) || dataMax <= 0) return 1;
if (dataMax > 10) return Math.ceil(dataMax * 1.05);
if (dataMax > 1) return Math.ceil(dataMax * 10.5) / 10;
return Math.ceil(dataMax * 105) / 100;
}
/** Tick format by range: HH:mm up to 2 days, day+time to 2 weeks, then dates. */
export function xTick(spanMs: number): (v: number) => string {
if (spanMs <= 48 * 3600_000)
return (v) => new Date(v).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
if (spanMs <= 14 * 86400_000)
return (v) =>
new Date(v).toLocaleString([], { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" });
return (v) => new Date(v).toLocaleDateString([], { month: "short", day: "numeric" });
}
const fullTime = (ms: number) =>
new Date(ms).toLocaleString([], {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
interface TipItem {
name?: string;
value?: number | string;
color?: string;
stroke?: string;
dataKey?: string | number;
payload?: ChartRow;
}
/** Dotted tooltip: timestamp header, per-series rows sorted by value desc. */
function ChartTip({
active,
payload,
fmt,
}: {
active?: boolean;
payload?: TipItem[];
fmt: (v: number) => string;
}) {
if (!active || !payload?.length) return null;
const t0 = payload[0]?.payload?.t;
const rows = payload
.filter((p): p is TipItem & { value: number } => typeof p.value === "number")
.sort((a, b) => b.value - a.value);
if (!rows.length) return null;
return (
<div className="min-w-28 rounded-lg border border-border-strong bg-popover px-2.5 py-1.5 text-[11px] shadow-xl">
{t0 !== undefined && <div className="mb-1 font-medium text-text-dim">{fullTime(t0)}</div>}
<div className="grid gap-1">
{rows.map((p) => (
<div key={String(p.dataKey ?? p.name)} className="flex items-center gap-2">
<span
className="size-2 shrink-0 rounded-[2px]"
style={{ background: p.color ?? p.stroke ?? "var(--text-faint)" }}
/>
<span className="flex-1 truncate text-text-faint">{p.name}</span>
<span className="font-mono font-medium">{fmt(p.value)}</span>
</div>
))}
</div>
</div>
);
}
/** Area/line chart on a time-scaled X axis. Empty data renders a hint, not a blank box. */
export function MetricChart({
rows,
series,
fmt,
domain,
spanMs,
}: {
rows: ChartRow[];
series: Series[];
fmt: (v: number) => string;
domain?: [number, number] | undefined;
spanMs: number;
}) {
if (!rows.length) {
return (
<div className="flex h-full items-center justify-center text-[11px] text-text-faint">
{t("chart.noData")}
</div>
);
}
return (
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={rows} margin={{ top: 4, right: 8, bottom: 0, left: 0 }}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" vertical={false} />
<XAxis
dataKey="t"
type="number"
scale="time"
domain={["dataMin", "dataMax"]}
tick={axisTick}
tickLine={false}
axisLine={false}
tickFormatter={xTick(spanMs)}
minTickGap={28}
/>
<YAxis
tick={axisTick}
tickLine={false}
axisLine={false}
width="auto"
domain={domain ?? [0, niceMax]}
tickFormatter={(v) => fmt(Number(v))}
/>
<Tooltip content={<ChartTip fmt={fmt} />} cursor={{ stroke: "var(--border-strong)" }} />
{series.map((s) =>
s.line ? (
<Line
key={s.key}
dataKey={s.key}
name={s.label}
type="monotone"
stroke={s.color}
strokeWidth={1.5}
dot={false}
connectNulls
isAnimationActive={false}
/>
) : (
<Area
key={s.key}
dataKey={s.key}
name={s.label}
type="monotone"
stroke={s.color}
fill={s.color}
fillOpacity={s.stack ? 0.65 : 0.14}
strokeWidth={1.5}
dot={false}
{...(s.stack !== undefined ? { stackId: s.stack } : {})}
isAnimationActive={false}
/>
),
)}
</ComposedChart>
</ResponsiveContainer>
);
}
/** Titled chart card in the Beszel layout: title + subtitle on top, legend chips below. */
export function ChartCard({
title,
hint,
actions,
series,
height = 160,
children,
}: {
title: string;
hint?: string;
actions?: ReactNode;
series?: Series[];
height?: number;
children: ReactNode;
}) {
return (
<div className="rounded-[10px] border border-border bg-surface p-3 pt-3.5">
<div className="mb-2 flex items-start justify-between gap-2 px-1">
<div>
<h3 className="text-[13px] font-medium tracking-tight">{title}</h3>
{hint ? <p className="text-[10.5px] text-text-faint">{hint}</p> : null}
</div>
{actions}
</div>
<div style={{ height }}>{children}</div>
{series && series.length > 1 && (
<div className="mt-2 flex flex-wrap gap-x-3.5 gap-y-1 px-1">
{series.map((s) => (
<span
key={s.key}
className="flex items-center gap-1.5 font-mono text-[10px] text-text-faint"
>
<span className="size-2 rounded-[2px]" style={{ background: s.color }} />
{s.label}
</span>
))}
</div>
)}
</div>
);
}
+26 -4
View File
@@ -109,6 +109,7 @@ export const cs: Partial<Record<Key, string>> = {
"settings.testNotify": "Odeslat testovací notifikaci",
"settings.wallpaperPh": "https://… — prázdné = žádná",
"settings.importFailed": "Import selhal",
"settings.backup": "Záloha a import",
"service.editTitle": "Upravit službu",
"service.newTitle": "Přidat službu",
@@ -323,10 +324,29 @@ export const cs: Partial<Record<Key, string>> = {
"systems.addDesc": "Vytvoří bearer token — spusťte s ním dash-agent.",
"systems.notFound": "Systém nenalezen.",
"systems.usage": "Využití",
"systems.cpuPct": "CPU %",
"systems.memPct": "Paměť %",
"systems.netKbs": "Síť KB/s",
"systems.load1m": "Load 1m",
"systems.chartCpu": "Využití CPU",
"systems.chartCpuHint": "Průměrné využití CPU celého systému",
"systems.chartMem": "Využití paměti",
"systems.chartMemHint": "Přesné využití v zaznamenaný čas",
"systems.chartUsed": "obsazeno",
"systems.chartDisk": "Využití disku",
"systems.chartDiskHint": "Využití kořenového oddílu",
"systems.chartNet": "Šířka pásma",
"systems.chartNetHint": "Síťový provoz veřejných rozhraní",
"systems.chartRx": "přijato",
"systems.chartTx": "odesláno",
"systems.chartLoad": "Průměrné zatížení",
"systems.chartLoadHint": "Zátěž za 1, 5 a 15 minut",
"systems.chartTemp": "Teplota",
"systems.chartTempHint": "Systémové senzory",
"systems.chartDockerCpu": "CPU Dockeru",
"systems.chartDockerCpuHint": "CPU na kontejner, procent jednoho jádra",
"systems.chartDockerMem": "Paměť Dockeru",
"systems.chartDockerMemHint": "Paměť na kontejner",
"systems.chartGpu": "Využití GPU",
"systems.chartGpuHint": "Využití na GPU",
"systems.chartVram": "VRAM GPU",
"systems.chartVramHint": "Paměť na GPU",
"systems.temperatures": "Teploty",
"systems.drivesSmart": "Disky (SMART)",
"systems.agentCmd": "Příkaz agenta",
@@ -335,6 +355,8 @@ export const cs: Partial<Record<Key, string>> = {
"systems.failing": "selhává",
"systems.uptime": "Doba běhu",
"chart.noData": "Zatím žádná data.",
"incidents.title": "Incidenty",
"incidents.subtitle": "Automaticky otevřené při výpadcích monitorů, nebo vedené ručně.",
"incidents.new": "Nový incident",
+26 -4
View File
@@ -108,6 +108,7 @@ export const en = {
"settings.testNotify": "Send test notification",
"settings.wallpaperPh": "https://… — blank for none",
"settings.importFailed": "Import failed",
"settings.backup": "Backup & import",
"service.editTitle": "Edit service",
"service.newTitle": "Add service",
@@ -322,10 +323,29 @@ export const en = {
"systems.addDesc": "Creates a bearer token — run dash-agent with it.",
"systems.notFound": "System not found.",
"systems.usage": "Usage",
"systems.cpuPct": "CPU %",
"systems.memPct": "Memory %",
"systems.netKbs": "Network KB/s",
"systems.load1m": "Load 1m",
"systems.chartCpu": "CPU Usage",
"systems.chartCpuHint": "Average system-wide CPU utilization",
"systems.chartMem": "Memory Usage",
"systems.chartMemHint": "Precise utilization at the recorded time",
"systems.chartUsed": "used",
"systems.chartDisk": "Disk Usage",
"systems.chartDiskHint": "Usage of the root partition",
"systems.chartNet": "Bandwidth",
"systems.chartNetHint": "Network traffic of public interfaces",
"systems.chartRx": "received",
"systems.chartTx": "sent",
"systems.chartLoad": "Load Average",
"systems.chartLoadHint": "System load over 1, 5, and 15 minutes",
"systems.chartTemp": "Temperature",
"systems.chartTempHint": "System sensors",
"systems.chartDockerCpu": "Docker CPU Usage",
"systems.chartDockerCpuHint": "Per-container CPU, percent of one core",
"systems.chartDockerMem": "Docker Memory",
"systems.chartDockerMemHint": "Per-container memory usage",
"systems.chartGpu": "GPU Usage",
"systems.chartGpuHint": "Per-GPU utilization",
"systems.chartVram": "GPU VRAM",
"systems.chartVramHint": "Per-GPU memory usage",
"systems.temperatures": "Temperatures",
"systems.drivesSmart": "Drives (SMART)",
"systems.agentCmd": "Agent command",
@@ -334,6 +354,8 @@ export const en = {
"systems.failing": "failing",
"systems.uptime": "Uptime",
"chart.noData": "No data yet.",
"incidents.title": "Incidents",
"incidents.subtitle": "Auto-opened on monitor outages, or tracked manually.",
"incidents.new": "New incident",
+20
View File
@@ -25,6 +25,16 @@
--icon-bg: #1c1c1e;
--popover: #141414;
/* chart series palette (monitoring graphs) */
--chart-1: #3b82f6;
--chart-2: #10b981;
--chart-3: #8b5cf6;
--chart-4: #f59e0b;
--chart-5: #22d3ee;
--chart-6: #fb7185;
--chart-7: #a3e635;
--chart-8: #c084fc;
--background: var(--bg);
--foreground: var(--text);
--card: var(--surface);
@@ -57,6 +67,16 @@
--icon-bg: #f4f4f5;
--popover: #ffffff;
/* chart series palette (monitoring graphs) — darker for light bg */
--chart-1: #2563eb;
--chart-2: #059669;
--chart-3: #7c3aed;
--chart-4: #d97706;
--chart-5: #0891b2;
--chart-6: #e11d48;
--chart-7: #65a30d;
--chart-8: #9333ea;
--background: var(--bg);
--foreground: var(--text);
--card: var(--surface);
+82 -16
View File
@@ -1,8 +1,8 @@
import { useCallback, useEffect, useState } from "react";
import { Link } from "wouter";
import { ArrowLeft, Pause, Play, RefreshCw, Shield, Trash2 } from "lucide-react";
import { ArrowLeft, Globe, Pause, Play, RefreshCw, Shield, Trash2 } from "lucide-react";
import { api } from "@/api";
import type { DomainCheck, DomainView, Subdomain } from "@/types";
import type { DNSRecord, DomainCheck, DomainView, Provider, Subdomain } from "@/types";
import { DomainDialog } from "@/components/DomainDialog";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
@@ -32,6 +32,61 @@ function fmtDate(iso: string | null | undefined) {
return new Date(iso).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
}
// Vendor chip shown next to records and inside the provider strip.
function ProviderBadge({ p }: { p: Provider | null | undefined }) {
if (!p?.name) return null;
return (
<span className="inline-flex shrink-0 items-center gap-1.5 rounded-full border border-border/60 bg-bg px-2 py-0.5">
{p.icon
? <img src={p.icon} alt="" className="size-3 rounded-[3px]" loading="lazy" />
: <Globe size={11} className="text-text-faint" />}
<span className="whitespace-nowrap text-[10.5px] text-text-dim">{p.name}</span>
</span>
);
}
function ProvTile({ label, p }: { label: string; p: Provider | undefined }) {
return (
<div className="flex min-w-0 items-center gap-2.5 rounded-[9px] border border-border/60 bg-bg px-3 py-2.5">
{p?.icon
? <img src={p.icon} alt="" className="size-5 rounded-[5px]" loading="lazy" />
: <Globe size={17} className="shrink-0 text-text-faint" />}
<div className="min-w-0">
<div className="font-mono text-[9px] uppercase tracking-[0.1em] text-text-faint">{label}</div>
<div className="truncate text-[12px] font-medium text-text">{p?.name || "—"}</div>
</div>
</div>
);
}
const recTypeColor = {
A: "text-[var(--chart-1)]",
AAAA: "text-[var(--chart-1)]",
CNAME: "text-[var(--chart-4)]",
NS: "text-[var(--chart-2)]",
MX: "text-[var(--chart-3)]",
TXT: "text-[var(--chart-5)]",
} satisfies Record<NonNullable<DNSRecord["type"]>, string>;
const recOrder = ["A", "AAAA", "CNAME", "NS", "MX", "TXT"];
function RecordRow({ r }: { r: DNSRecord }) {
return (
<div className="flex items-center gap-3 border-b border-border/50 px-4 py-2 last:border-0">
<span className={cn("w-11 shrink-0 font-mono text-[9.5px] font-semibold uppercase tracking-[0.1em]",
(r.type ? recTypeColor[r.type] : undefined) ?? "text-text-faint")}>
{r.type}
</span>
<span className="min-w-0 flex-1 truncate font-mono text-[11px] text-text" title={r.value ?? ""}>
{r.value}
{r.type === "MX" && r.priority !== null && r.priority !== undefined
? <span className="text-text-faint"> (pri {r.priority})</span>
: null}
</span>
<ProviderBadge p={r.provider} />
</div>
);
}
export function DomainDetailPage({ id }: { id: string }) {
const [d, setD] = useState<DomainView | null>(null);
const [checks, setChecks] = useState<DomainCheck[]>([]);
@@ -133,13 +188,26 @@ export function DomainDetailPage({ id }: { id: string }) {
<div className="font-mono text-[10px] text-text-faint">{d.caProvider || "—"}</div>
</div>
<div className="rounded-[10px] border border-border bg-surface px-4 py-3">
<div className="font-mono text-[10px] uppercase tracking-[0.1em] text-text-faint">{t("domains.providers")}</div>
<div className="mt-1 truncate text-[13px] font-medium">{d.dnsProvider || "—"}</div>
<div className="font-mono text-[10px] text-text-faint">{d.hostingProvider || d.emailProvider || "—"}</div>
<div className="font-mono text-[10px] uppercase tracking-[0.1em] text-text-faint">{t("domains.checked")}</div>
<div className="mt-1 truncate text-[13px] font-medium">
{d.lastChecked ? new Date(d.lastChecked).toLocaleDateString() : "—"}
</div>
<div className="font-mono text-[10px] text-text-faint">every {d.intervalH}h</div>
</div>
</div>
<div className="grid grid-cols-2 gap-3 max-[820px]:grid-cols-1">
{/* Vendor strip — registrar, DNS, hosting, email, CA */}
<Section title={t("domains.providers")}>
<div className="grid grid-cols-5 gap-2 p-2.5 max-[900px]:grid-cols-3 max-[560px]:grid-cols-2">
<ProvTile label={t("domains.registrar")} p={d.providers?.registrar} />
<ProvTile label={t("domains.dns")} p={d.providers?.dns} />
<ProvTile label={t("domains.hosting")} p={d.providers?.hosting} />
<ProvTile label={t("domains.email")} p={d.providers?.email} />
<ProvTile label={t("domains.tlsCert")} p={d.providers?.ca} />
</div>
</Section>
<div className="mt-3 grid grid-cols-2 gap-3 max-[820px]:grid-cols-1">
<Section title={t("domains.registration")}>
<Row k={t("domains.created")} v={fmtDate(d.creationDate)} />
<Row k={t("domains.updated")} v={fmtDate(d.updatedDate)} />
@@ -153,19 +221,17 @@ export function DomainDetailPage({ id }: { id: string }) {
</Section>
<Section title={t("domains.dns")}>
{d.nameServers?.map((n) => <Row key={n} k="NS" v={n} mono />)}
{d.mxRecords?.map((m) => <Row key={m} k="MX" v={m} mono />)}
{d.ipv4?.map((a) => <Row key={a} k="A" v={a} mono />)}
{d.ipv6?.map((a) => <Row key={a} k="AAAA" v={a} mono />)}
{d.cname ? <Row k="CNAME" v={d.cname} mono /> : null}
{d.txtRecords?.slice(0, 4).map((t, i) => <Row key={i} k="TXT" v={t.length > 60 ? t.slice(0, 60) + "…" : t} mono />)}
{d.dnsProvider ? <Row k={t("domains.dnsProvider")} v={d.dnsProvider} /> : null}
{d.emailProvider ? <Row k={t("domains.email")} v={d.emailProvider} /> : null}
{[...(d.records ?? [])]
.sort((a, b) => recOrder.indexOf(a.type ?? "") - recOrder.indexOf(b.type ?? ""))
.map((r, i) => <RecordRow key={i} r={r} />)}
{(d.records ?? []).length === 0 ? (
<p className="px-4 py-4 text-center text-[12px] text-text-faint">—</p>
) : null}
</Section>
<Section title={t("domains.tlsCert")}>
<Row k={t("domains.issuer")} v={d.sslIssuer} />
<Row k="CA" v={d.caProvider} />
<Row k="CA" v={d.providers?.ca ? <ProviderBadge p={d.providers.ca} /> : d.caProvider} />
<Row k={t("domains.valid")} v={`${fmtDate(d.sslValidFrom)} → ${fmtDate(d.sslValidTo)}`} mono />
<Row k={t("domains.subject")} v={d.sslSubject} mono />
<Row k={t("domains.key")} v={d.sslKeySize ? `${d.sslKeySize} bit` : ""} mono />
@@ -181,7 +247,7 @@ export function DomainDetailPage({ id }: { id: string }) {
<Row k={t("domains.isp")} v={d.hostIsp} />
<Row k={t("domains.org")} v={d.hostOrg} />
<Row k="AS" v={d.hostAs} mono />
<Row k={t("domains.hosting")} v={d.hostingProvider} />
<Row k={t("domains.hosting")} v={d.providers?.hosting ? <ProviderBadge p={d.providers.hosting} /> : d.hostingProvider} />
<Row k={t("domains.checked")} v={d.lastChecked ? new Date(d.lastChecked).toLocaleString() : "—"} mono />
</Section>
</div>
+27 -29
View File
@@ -1,8 +1,8 @@
import { useCallback, useEffect, useState } from "react";
import { Link } from "wouter";
import { ArrowLeft, Pause, Play, RefreshCw, Trash2 } from "lucide-react";
import { Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import { api } from "@/api";
import { ChartCard, MetricChart, chartColor, type ChartRow } from "@/components/chart";
import type { Heartbeat, MonitorView } from "@/types";
import { MonitorDialog } from "@/components/MonitorDialog";
import { Button } from "@/components/ui/button";
@@ -32,10 +32,9 @@ export function MonitorDetailPage({ id }: { id: string }) {
if (!m) return <main className="mx-auto max-w-5xl px-7 py-8 text-[13px] text-text-faint">{t("common.loading")}</main>;
const chart = hbs.map((h) => ({
t: new Date(h.checkedAt ?? "").toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }),
const rows: ChartRow[] = hbs.map((h) => ({
t: new Date(h.checkedAt ?? "").getTime(),
ping: h.pingMs,
up: h.status === "up",
}));
return (
@@ -94,31 +93,30 @@ export function MonitorDetailPage({ id }: { id: string }) {
<UptimeBar hbs={hbs} hours={hours} />
</div>
<div className="mb-2 flex items-center justify-between">
<h2 className="font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">{t("monitors.responseTime")}</h2>
<div className="flex gap-1">
{[24, 168, 720].map((h) => (
<button key={h} type="button" onClick={() => setHours(h)}
className={cn("rounded-[6px] px-2 py-1 font-mono text-[10.5px]",
hours === h ? "bg-surface-hover text-text" : "text-text-faint hover:text-text")}>
{h === 24 ? "24h" : h === 168 ? "7d" : "30d"}
</button>
))}
</div>
</div>
<div className="mb-6 h-56 rounded-[10px] border border-border bg-surface p-3">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={chart} margin={{ top: 4, right: 4, bottom: 0, left: -18 }}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="t" tick={{ fontSize: 10, fill: "var(--text-faint)" }} tickLine={false} axisLine={false} minTickGap={40} />
<YAxis tick={{ fontSize: 10, fill: "var(--text-faint)" }} tickLine={false} axisLine={false} unit=" ms" width={62} />
<Tooltip
contentStyle={{ background: "var(--surface)", border: "1px solid var(--border)", borderRadius: 8, fontSize: 11 }}
labelStyle={{ color: "var(--text-faint)" }}
/>
<Area type="monotone" dataKey="ping" stroke="var(--up)" fill="var(--up)" fillOpacity={0.12} strokeWidth={1.5} dot={false} isAnimationActive={false} />
</AreaChart>
</ResponsiveContainer>
<div className="mb-6">
<ChartCard
title={t("monitors.responseTime")}
height={208}
series={[{ key: "ping", label: "ms", color: chartColor(0) }]}
actions={
<div className="flex gap-1">
{[24, 168, 720].map((h) => (
<button key={h} type="button" onClick={() => setHours(h)}
className={cn("rounded-[6px] px-2 py-1 font-mono text-[10.5px]",
hours === h ? "bg-surface-hover text-text" : "text-text-faint hover:text-text")}>
{h === 24 ? "24h" : h === 168 ? "7d" : "30d"}
</button>
))}
</div>
}
>
<MetricChart
rows={rows}
series={[{ key: "ping", label: "ms", color: chartColor(0) }]}
fmt={(v) => `${Math.round(v)} ms`}
spanMs={hours * 3600_000}
/>
</ChartCard>
</div>
<h2 className="mb-2 font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">{t("monitors.recentChecks")}</h2>
+108 -60
View File
@@ -1,8 +1,8 @@
import { useCallback, useEffect, useState } from "react";
import { Link } from "wouter";
import { ArrowLeft, Copy, Trash2 } from "lucide-react";
import { Area, AreaChart, CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import { api } from "@/api";
import { ChartCard, MetricChart, chartColor, type ChartRow, type Series } from "@/components/chart";
import type { System, SystemStat } from "@/types";
import { agentCmd, fmtBytes, fmtUptime } from "@/pages/SystemsPage";
import { Button } from "@/components/ui/button";
@@ -40,16 +40,73 @@ export function SystemDetailPage({ id }: { id: string }) {
if (!sys) return <main className="mx-auto max-w-5xl px-7 py-8 text-[13px] text-text-faint">{t("common.loading")}</main>;
const l = sys.latest;
const chart = stats.map((s) => ({
t: new Date(s.ts ?? "").toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }),
cpu: s.cpu,
memPct: s.memTotal ? ((s.memUsed ?? 0) / s.memTotal) * 100 : undefined,
rx: (s.netRx ?? 0) / 1024,
tx: (s.netTx ?? 0) / 1024,
load: s.load1,
}));
const axisTick = { fontSize: 10, fill: "var(--text-faint)" };
const tipStyle = { background: "var(--surface)", border: "1px solid var(--border)", borderRadius: 8, fontSize: 11 };
const spanMs = hours * 3600_000;
// Rows carry fixed keys plus dynamic `c:`/`m:` (containers), `s:` (sensors),
// `g:`/`v:` (GPU util/VRAM) keys; series pick them up by name.
const rows: ChartRow[] = stats.map((s) => {
const r: ChartRow = {
t: new Date(s.ts ?? "").getTime(),
cpu: s.cpu,
mem: s.memUsed,
swap: s.swapUsed,
disk: s.diskUsed,
rx: s.netRx,
tx: s.netTx,
l1: s.load1,
l5: s.load5,
l15: s.load15,
};
for (const c of s.containers ?? []) {
if (c.name) {
r[`c:${c.name}`] = c.cpu ?? 0;
r[`m:${c.name}`] = c.memUsed ?? 0;
}
}
for (const [k, v] of Object.entries(s.temps ?? {})) r[`s:${k}`] = v;
for (const g of s.gpu ?? []) {
if (g.name) {
r[`g:${g.name}`] = g.utilPct;
r[`v:${g.name}`] = g.memUsed;
}
}
return r;
});
const names = (ns: (string | undefined)[]) =>
[...new Set(ns.filter((n): n is string => n !== undefined))];
const containerNames = names(stats.flatMap((s) => (s.containers ?? []).map((c) => c.name)));
const tempNames = names(stats.flatMap((s) => Object.keys(s.temps ?? {})));
const gpuNames = names(stats.flatMap((s) => (s.gpu ?? []).map((g) => g.name)));
// Zero-fill absent containers so stacked areas stay flush over restarts.
for (const r of rows) {
for (const n of containerNames) {
r[`c:${n}`] ??= 0;
r[`m:${n}`] ??= 0;
}
}
const pct = (v: number) => `${v.toFixed(1)}%`;
const cpuSeries: Series[] = [{ key: "cpu", label: "cpu", color: chartColor(0) }];
const dockerCpuSeries: Series[] = containerNames.map((n, i) => ({ key: `c:${n}`, label: n, color: chartColor(i), stack: "a" }));
const memSeries: Series[] = [
{ key: "mem", label: t("systems.chartUsed"), color: chartColor(1) },
{ key: "swap", label: "swap", color: chartColor(3) },
];
const dockerMemSeries: Series[] = containerNames.map((n, i) => ({ key: `m:${n}`, label: n, color: chartColor(i), stack: "a" }));
const diskSeries: Series[] = [{ key: "disk", label: t("systems.chartUsed"), color: chartColor(2) }];
const netSeries: Series[] = [
{ key: "rx", label: t("systems.chartRx"), color: chartColor(4) },
{ key: "tx", label: t("systems.chartTx"), color: chartColor(5) },
];
const loadSeries: Series[] = [
{ key: "l1", label: "1 min", color: chartColor(0) },
{ key: "l5", label: "5 min", color: chartColor(3) },
{ key: "l15", label: "15 min", color: chartColor(5) },
];
const tempSeries: Series[] = tempNames.map((n, i) => ({ key: `s:${n}`, label: n, color: chartColor(i), line: true }));
const gpuSeries: Series[] = gpuNames.map((n, i) => ({ key: `g:${n}`, label: n, color: chartColor(i) }));
const vramSeries: Series[] = gpuNames.map((n, i) => ({ key: `v:${n}`, label: n, color: chartColor(i) }));
return (
<main className="mx-auto w-full max-w-5xl px-7 py-8">
@@ -111,55 +168,46 @@ export function SystemDetailPage({ id }: { id: string }) {
</div>
<div className="mb-6 grid grid-cols-2 gap-3 max-[720px]:grid-cols-1">
<div className="h-44 rounded-[10px] border border-border bg-surface p-3">
<div className="mb-1 font-mono text-[9.5px] uppercase tracking-[0.1em] text-text-faint">{t("systems.cpuPct")}</div>
<ResponsiveContainer width="100%" height="85%">
<AreaChart data={chart} margin={{ top: 4, right: 4, bottom: 0, left: -22 }}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="t" tick={axisTick} tickLine={false} axisLine={false} minTickGap={40} />
<YAxis tick={axisTick} tickLine={false} axisLine={false} domain={[0, 100]} unit="%" width={48} />
<Tooltip contentStyle={tipStyle} labelStyle={{ color: "var(--text-faint)" }} />
<Area type="monotone" dataKey="cpu" stroke="var(--up)" fill="var(--up)" fillOpacity={0.12} strokeWidth={1.5} dot={false} isAnimationActive={false} />
</AreaChart>
</ResponsiveContainer>
</div>
<div className="h-44 rounded-[10px] border border-border bg-surface p-3">
<div className="mb-1 font-mono text-[9.5px] uppercase tracking-[0.1em] text-text-faint">{t("systems.memPct")}</div>
<ResponsiveContainer width="100%" height="85%">
<AreaChart data={chart} margin={{ top: 4, right: 4, bottom: 0, left: -22 }}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="t" tick={axisTick} tickLine={false} axisLine={false} minTickGap={40} />
<YAxis tick={axisTick} tickLine={false} axisLine={false} domain={[0, 100]} unit="%" width={48} />
<Tooltip contentStyle={tipStyle} labelStyle={{ color: "var(--text-faint)" }} />
<Area type="monotone" dataKey="memPct" stroke="var(--up)" fill="var(--up)" fillOpacity={0.12} strokeWidth={1.5} dot={false} isAnimationActive={false} />
</AreaChart>
</ResponsiveContainer>
</div>
<div className="h-44 rounded-[10px] border border-border bg-surface p-3">
<div className="mb-1 font-mono text-[9.5px] uppercase tracking-[0.1em] text-text-faint">{t("systems.netKbs")}</div>
<ResponsiveContainer width="100%" height="85%">
<LineChart data={chart} margin={{ top: 4, right: 4, bottom: 0, left: -18 }}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="t" tick={axisTick} tickLine={false} axisLine={false} minTickGap={40} />
<YAxis tick={axisTick} tickLine={false} axisLine={false} width={58} />
<Tooltip contentStyle={tipStyle} labelStyle={{ color: "var(--text-faint)" }} />
<Line type="monotone" dataKey="rx" name="rx" stroke="var(--up)" strokeWidth={1.5} dot={false} isAnimationActive={false} />
<Line type="monotone" dataKey="tx" name="tx" stroke="var(--text-faint)" strokeWidth={1.5} dot={false} isAnimationActive={false} />
</LineChart>
</ResponsiveContainer>
</div>
<div className="h-44 rounded-[10px] border border-border bg-surface p-3">
<div className="mb-1 font-mono text-[9.5px] uppercase tracking-[0.1em] text-text-faint">{t("systems.load1m")}</div>
<ResponsiveContainer width="100%" height="85%">
<AreaChart data={chart} margin={{ top: 4, right: 4, bottom: 0, left: -22 }}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="t" tick={axisTick} tickLine={false} axisLine={false} minTickGap={40} />
<YAxis tick={axisTick} tickLine={false} axisLine={false} width={48} />
<Tooltip contentStyle={tipStyle} labelStyle={{ color: "var(--text-faint)" }} />
<Area type="monotone" dataKey="load" stroke="var(--up)" fill="var(--up)" fillOpacity={0.12} strokeWidth={1.5} dot={false} isAnimationActive={false} />
</AreaChart>
</ResponsiveContainer>
</div>
<ChartCard title={t("systems.chartCpu")} hint={t("systems.chartCpuHint")}>
<MetricChart rows={rows} series={cpuSeries} fmt={pct} domain={[0, 100]} spanMs={spanMs} />
</ChartCard>
{dockerCpuSeries.length > 0 && (
<ChartCard title={t("systems.chartDockerCpu")} hint={t("systems.chartDockerCpuHint")} series={dockerCpuSeries}>
<MetricChart rows={rows} series={dockerCpuSeries} fmt={pct} spanMs={spanMs} />
</ChartCard>
)}
<ChartCard title={t("systems.chartMem")} hint={t("systems.chartMemHint")} series={memSeries}>
<MetricChart rows={rows} series={memSeries} fmt={fmtBytes} spanMs={spanMs} />
</ChartCard>
{dockerMemSeries.length > 0 && (
<ChartCard title={t("systems.chartDockerMem")} hint={t("systems.chartDockerMemHint")} series={dockerMemSeries}>
<MetricChart rows={rows} series={dockerMemSeries} fmt={fmtBytes} spanMs={spanMs} />
</ChartCard>
)}
<ChartCard title={t("systems.chartDisk")} hint={t("systems.chartDiskHint")}>
<MetricChart rows={rows} series={diskSeries} fmt={fmtBytes} domain={l?.diskTotal ? [0, l.diskTotal] : undefined} spanMs={spanMs} />
</ChartCard>
<ChartCard title={t("systems.chartNet")} hint={t("systems.chartNetHint")} series={netSeries}>
<MetricChart rows={rows} series={netSeries} fmt={(v) => `${fmtBytes(v)}/s`} spanMs={spanMs} />
</ChartCard>
<ChartCard title={t("systems.chartLoad")} hint={t("systems.chartLoadHint")} series={loadSeries}>
<MetricChart rows={rows} series={loadSeries} fmt={(v) => v.toFixed(2)} spanMs={spanMs} />
</ChartCard>
{tempSeries.length > 0 && (
<ChartCard title={t("systems.chartTemp")} hint={t("systems.chartTempHint")} series={tempSeries}>
<MetricChart rows={rows} series={tempSeries} fmt={(v) => `${v.toFixed(0)}°C`} spanMs={spanMs} />
</ChartCard>
)}
{gpuSeries.length > 0 && (
<ChartCard title={t("systems.chartGpu")} hint={t("systems.chartGpuHint")} series={gpuSeries}>
<MetricChart rows={rows} series={gpuSeries} fmt={pct} domain={[0, 100]} spanMs={spanMs} />
</ChartCard>
)}
{vramSeries.length > 0 && (
<ChartCard title={t("systems.chartVram")} hint={t("systems.chartVramHint")} series={vramSeries}>
<MetricChart rows={rows} series={vramSeries} fmt={fmtBytes} spanMs={spanMs} />
</ChartCard>
)}
</div>
<div className="grid grid-cols-2 gap-3 max-[720px]:grid-cols-1">
+4 -1
View File
@@ -15,11 +15,14 @@ import { t } from "@/i18n";
const TB = 2 ** 40;
const GB = 2 ** 30;
const MB = 2 ** 20;
const KB = 2 ** 10;
export function fmtBytes(b: number) {
if (b >= TB) return `${(b / TB).toFixed(1)} TB`;
if (b >= GB) return `${(b / GB).toFixed(1)} GB`;
return `${(b / MB).toFixed(0)} MB`;
if (b >= MB) return `${(b / MB).toFixed(1)} MB`;
if (b >= KB) return `${(b / KB).toFixed(0)} KB`;
return `${Math.round(b)} B`;
}
export function fmtUptime(s: number) {
+8
View File
@@ -1,3 +1,4 @@
import { LayoutGrid, LayoutList, Newspaper, SquareTerminal, type LucideIcon } from "lucide-react";
import { bento } from "./bento";
import { cards } from "./cards";
import { editorial } from "./editorial";
@@ -27,3 +28,10 @@ export const rendererDescriptions: Record<RendererName, string> = {
index: "editorial list · masthead",
console: "terminal panels · mono",
};
export const rendererIcons: Record<RendererName, LucideIcon> = {
bento: LayoutGrid,
cards: LayoutList,
index: Newspaper,
console: SquareTerminal,
};
+3
View File
@@ -20,6 +20,9 @@ export type Heartbeat = components["schemas"]["Heartbeat"];
export type Domain = components["schemas"]["Domain"];
export type DomainInput = components["schemas"]["DomainInput"];
export type DomainView = components["schemas"]["DomainView"];
export type DNSRecord = components["schemas"]["DNSRecord"];
export type Provider = components["schemas"]["Provider"];
export type Providers = components["schemas"]["Providers"];
export type DomainCheck = components["schemas"]["DomainCheck"];
export type Subdomain = components["schemas"]["Subdomain"];
export type System = components["schemas"]["System"];
+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" rx="104" fill="#0a0a0a"/>
<g fill="#3b82f6">
<rect x="120" y="120" width="124" height="124" rx="28"/>
<rect x="268" y="120" width="124" height="124" rx="28" fill="#6b9ef8"/>
<rect x="120" y="268" width="124" height="124" rx="28" fill="#1d4fd7"/>
<rect x="268" y="268" width="124" height="124" rx="28"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 431 B

+3
View File
@@ -1,5 +1,8 @@
services:
dash:
# Prebuilt release image — `docker compose pull && docker compose up -d`.
# To build from source instead: `docker compose up -d --build`.
image: ghcr.io/dvorinka/dash:latest
build:
context: .
dockerfile: infra/Dockerfile
+13 -8
View File
@@ -1,11 +1,11 @@
# v1.0.0 launch post — draft
# v2.0.0 launch post — draft
For r/selfhosted, HN Show, and similar. Edit freely before posting.
---
**Title:** Dash — a self-hosted homelab dashboard with monitoring built in.
Single Go binary, no YAML, no auth.
Single Go binary, no YAML, optional auth.
**Body:**
@@ -19,13 +19,17 @@ What it does:
upload. Imports your existing Homepage (`services.yml`), Homarr, or Dashy
config in one click.
- **Monitors** — HTTP/TCP/ping/DNS/keyword/JSON-path checks plus push monitors
for cron jobs. Webhook alerts (Slack/Discord/ntfy). Uptime history.
for cron jobs. Uptime graphs, per-monitor alert rules, and notifications via
webhook, Slack, Discord, Telegram, Gotify, ntfy, or SMTP.
- **Domains** — WHOIS/RDAP, DNS, TLS certificate expiry, provider detection.
Inspired by Domain Locker, minus the paid APIs.
- **Systems** — a ~4 MB static agent pushes CPU/mem/disk/net/temps/Docker
container states. Inspired by Beszel, push-JSON instead of SSH.
- **Systems** — a ~4 MB static agent pushes CPU/mem/disk/net/temps, per-
container stats, and SMART/ZFS/GPU when present. Inspired by Beszel,
push-JSON instead of SSH.
- **Ops** — incidents, maintenance windows, public status pages, SVG badges,
Prometheus `/api/metrics`.
- **Extras** — multiple boards, iframe/JSON-path widgets, CT-log subdomain
discovery, EN/CZ locales, installable PWA, custom CSS/wallpaper.
Deployment is one container:
@@ -33,8 +37,9 @@ Deployment is one container:
docker run -d -v ./data:/data -p 3000:3000 ghcr.io/dvorinka/dash:latest
```
SQLite inside, no database to babysit, no accounts. Deliberately no auth —
it expects a trusted LAN or your own proxy in front.
SQLite inside, no database to babysit. Auth is opt-in: local
username/password with bcrypt + session cookies, off by default; public
status pages stay open either way.
Stack: Go + Gin + SQLite (single static binary serving an embedded React UI).
MIT licensed.
@@ -53,5 +58,5 @@ Show HN: Dash – self-hosted homelab dashboard with monitoring, one Go binary
Dash combines a service board, uptime monitors, domain/SSL watching, system
metrics via a tiny push agent, and public status pages. Single container or
single binary, SQLite storage, no YAML, no auth (LAN-trust model). MIT.
single binary, SQLite storage, no YAML. Optional local auth. MIT.
https://github.com/Dvorinka/Dash
+31 -6
View File
@@ -21,12 +21,16 @@ snapshot; `/api/import/csv` bulk-loads monitors and domains from CSV.
## Notifications
**Settings → Notifications** holds one generic webhook URL
(`notify_webhook`). On monitor up/down transitions and domain events, Dash
POSTs a JSON payload with `event`, `name`, `text`, `content`, `message`, and
`at` fields — shaped so Slack, Discord, and ntfy all render it sensibly.
**Test** sends a `test` event. Delivery is async and non-fatal; failures are
logged.
**Settings → Notifications** configures any number of transports — generic
webhook, Slack, Discord, Telegram, Gotify, ntfy, and SMTP — each stored as a
`notify_<name>` settings object. Monitor up/down transitions and domain
events fan out to every configured transport; the payload carries `event`,
`name`, `text`, `content`, `message`, and `at` fields. **Test** sends a `test`
event. Delivery is async and non-fatal; failures are logged.
Per-monitor alert rules (consecutive failures before down, latency-warn ms,
mute) live on each monitor; domains get a cert-days threshold and mute.
Maintenance windows still take precedence.
## Monitors
@@ -47,6 +51,27 @@ Bearer-token push ingest at `POST /api/systems/ingest`. A system is marked
offline when no sample arrives within a few intervals. Stats are pruned during
the hourly sweep.
## Auth
Off by default — existing installs see no gate. **Settings → Authentication →
Enable** flips to a first-user setup screen (username/email + password,
bcrypt). Sessions are SQLite-backed 7-day HttpOnly cookies with rotation;
login is rate-limited per IP. Disable only works from an authenticated
session. Public status pages, badges, `/api/metrics`, `/api/push/*`, and
agent ingest stay open under auth.
## Boards
The header switcher manages multiple boards — each gets a slug and a
`/b/:slug` route with its own sections/items. Monitors, domains, systems, and
status pages stay global. Exports carry all boards.
## Appearance & language
Theme (dark/light), accent color, wallpaper URL, and custom CSS in Settings;
the language picker switches the UI between English and Czech. A PWA manifest
and service worker make the app installable.
## Operations
- **Incidents** — open/ack/resolve/close, with per-incident update timeline.
+10 -1
View File
@@ -3,7 +3,16 @@
Dash ships as a single container image or a single static binary. All state
lives in one directory: a SQLite database and uploaded icons.
## Docker (recommended)
## One-line install
```sh
curl -fsSL https://raw.githubusercontent.com/Dvorinka/Dash/main/install.sh | sh
```
Creates `./dash`, pulls the image, and starts on port 3000. Override with
`DASH_DIR` / `DASH_PORT` environment variables.
## Docker (manual)
```sh
docker run -d --name dash \
Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 244 KiB

Executable
+80
View File
@@ -0,0 +1,80 @@
#!/bin/sh
# Dash installer — curl -fsSL https://raw.githubusercontent.com/Dvorinka/Dash/main/install.sh | sh
# Creates ./dash, writes a compose file, pulls the image, starts on :3000.
set -eu
DIR="${DASH_DIR:-dash}"
PORT="${DASH_PORT:-3000}"
IMAGE="ghcr.io/dvorinka/dash:latest"
DATA_HINT=""
info() { printf ' %s\n' "$1"; }
fail() { printf 'error: %s\n' "$1" >&2; exit 1; }
command -v docker >/dev/null 2>&1 || fail "docker not found — install Docker first: https://docs.docker.com/engine/install"
docker info >/dev/null 2>&1 || fail "docker daemon not reachable — start it, or run with sudo/root"
mkdir -p "$DIR/data"
cd "$DIR"
# compose.yml with a bind mount (data dir visible next to the file) or, when
# Docker Desktop file sharing forbids it, a named volume.
write_compose() {
if [ "$1" = "named" ]; then
cat > compose.yml <<EOF
# Generated by Dash install.sh - safe to edit.
services:
dash:
image: $IMAGE
ports:
- "$PORT:3000"
volumes:
- dash-data:/data
restart: unless-stopped
volumes:
dash-data:
EOF
else
cat > compose.yml <<EOF
# Generated by Dash install.sh - safe to edit.
services:
dash:
image: $IMAGE
ports:
- "$PORT:3000"
volumes:
- ./data:/data
restart: unless-stopped
EOF
fi
}
if docker compose version >/dev/null 2>&1; then
write_compose bind
docker compose pull
if up_out=$(docker compose up -d --remove-orphans 2>&1); then
DATA_HINT="$(pwd)/data"
elif printf '%s' "$up_out" | grep -qi "not shared\|mounts denied"; then
info "Host path not shareable - retrying with a named volume"
write_compose named
docker compose up -d --remove-orphans
DATA_HINT="the 'dash-data' docker volume (docker volume ls | grep dash-data)"
else
printf '%s\n' "$up_out" >&2
exit 1
fi
else
# No compose plugin — named volume keeps docker run free of host-path rules.
info "Compose plugin absent; using docker run"
docker rm -f dash >/dev/null 2>&1 || true
docker pull "$IMAGE"
docker run -d --name dash --restart unless-stopped \
-p "$PORT:3000" -v dash-data:/data "$IMAGE"
DATA_HINT="docker volume 'dash-data'"
fi
info ""
info "Dash is up: http://localhost:$PORT"
info "Data lives in $DATA_HINT — back it up to back up everything."
info "Upgrade later: docker compose pull && docker compose up -d (or: docker pull $IMAGE && docker restart dash)"
+51
View File
@@ -1765,6 +1765,12 @@ components:
type: string
caProvider:
type: string
records:
type: array
items:
$ref: "#/components/schemas/DNSRecord"
providers:
$ref: "#/components/schemas/Providers"
headers:
type: object
additionalProperties:
@@ -1807,6 +1813,51 @@ components:
type: string
alerts:
$ref: "#/components/schemas/AlertRules"
Provider:
type: object
description: Detected vendor behind a record or role
properties:
name:
type: string
domain:
type: string
description: brand domain the favicon icon was derived from
icon:
type: string
Providers:
type: object
description: Detected vendor strip — registrar, DNS, hosting, email, CA
properties:
registrar:
$ref: "#/components/schemas/Provider"
nullable: true
dns:
$ref: "#/components/schemas/Provider"
nullable: true
email:
$ref: "#/components/schemas/Provider"
nullable: true
hosting:
$ref: "#/components/schemas/Provider"
nullable: true
ca:
$ref: "#/components/schemas/Provider"
nullable: true
DNSRecord:
type: object
properties:
type:
type: string
enum: [A, AAAA, CNAME, NS, MX, TXT]
value:
type: string
priority:
type: integer
nullable: true
description: MX preference
provider:
$ref: "#/components/schemas/Provider"
nullable: true
DomainView:
allOf:
- $ref: "#/components/schemas/Domain"
+19
View File
@@ -0,0 +1,19 @@
#!/bin/sh
# Dash setup from a clone — builds the image locally and starts the stack.
# For the prebuilt image path, use ../install.sh instead.
set -eu
cd "$(dirname "$0")/.."
command -v docker >/dev/null 2>&1 || { echo "error: docker not found" >&2; exit 1; }
docker compose version >/dev/null 2>&1 || { echo "error: docker compose plugin not found" >&2; exit 1; }
docker compose up -d --build
echo ""
echo "Dash is up: http://localhost:3000"
echo "Logs: docker compose logs -f"
echo ""
echo "For development instead:"
echo " npm install && npm run dev # vite on :5173"
echo " cd apps/backend && DASH_DEV=1 go run ./cmd/dash # API on :8080"