Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0612beb37e | ||
|
|
a54ba4c544 | ||
|
|
1bb5441eea | ||
|
|
cfacc4ed7c | ||
|
|
10d9171031 | ||
|
|
8ff13f74f7 | ||
|
|
041c54c18c | ||
|
|
f4ebdf1f33 | ||
|
|
c5339a24b4 | ||
|
|
5473881f93 | ||
|
|
c336408152 | ||
|
|
ddd29aad24 | ||
|
|
35602c2e52 | ||
|
|
eb84939a19 | ||
|
|
afa0c931b9 | ||
|
|
54b5497121 |
No files matched your search
@@ -1,20 +1,89 @@
|
||||
name: release
|
||||
|
||||
# Releases are cut automatically from main: after the ci workflow passes on a
|
||||
# main push, the version job bumps semver from conventional commit subjects
|
||||
# (BREAKING/! -> major, feat -> minor, otherwise patch), pushes the tag, and
|
||||
# the build jobs release it. Manual tags are not used; workflow_dispatch
|
||||
# re-runs the same flow on demand.
|
||||
on:
|
||||
push:
|
||||
tags: ["v*"]
|
||||
workflow_run:
|
||||
workflows: [ci]
|
||||
types: [completed]
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
# Two merges racing would compute the same tag; serialize instead.
|
||||
concurrency:
|
||||
group: release
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
version:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success'
|
||||
outputs:
|
||||
version: ${{ steps.v.outputs.version }}
|
||||
semver: ${{ steps.v.outputs.semver }}
|
||||
major_minor: ${{ steps.v.outputs.major_minor }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
|
||||
|
||||
- id: v
|
||||
run: |
|
||||
set -euo pipefail
|
||||
latest="$(git tag -l 'v*' --sort=-v:refname | head -n1)"
|
||||
if [ -z "$latest" ]; then
|
||||
version="v0.1.0"; bump="minor"
|
||||
else
|
||||
subjects="$(git log "$latest"..HEAD --format=%s)"
|
||||
if grep -qiE 'BREAKING[ -]CHANGE|^[a-z]+(\([^)]*\))?!:' <<< "$subjects"; then
|
||||
bump="major"
|
||||
elif grep -qE '^feat(\([^)]*\))?:' <<< "$subjects"; then
|
||||
bump="minor"
|
||||
else
|
||||
bump="patch"
|
||||
fi
|
||||
IFS=. read -r major minor patch <<< "${latest#v}"
|
||||
case "$bump" in
|
||||
major) major=$((major+1)); minor=0; patch=0 ;;
|
||||
minor) minor=$((minor+1)); patch=0 ;;
|
||||
*) patch=$((patch+1)) ;;
|
||||
esac
|
||||
version="v${major}.${minor}.${patch}"
|
||||
fi
|
||||
semver="${version#v}"
|
||||
IFS=. read -r mj mn _ <<< "$semver"
|
||||
{
|
||||
echo "version=$version"
|
||||
echo "semver=$semver"
|
||||
echo "major_minor=$mj.$mn"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
echo "latest=${latest:-none} bump=$bump -> $version"
|
||||
|
||||
- name: Push tag
|
||||
env:
|
||||
VERSION: ${{ steps.v.outputs.version }}
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git tag "$VERSION"
|
||||
git push origin "$VERSION"
|
||||
|
||||
binaries:
|
||||
needs: version
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # goreleaser needs full history for tag + changelog
|
||||
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
@@ -40,11 +109,15 @@ jobs:
|
||||
args: release --clean
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GORELEASER_CURRENT_TAG: ${{ needs.version.outputs.version }}
|
||||
|
||||
docker:
|
||||
needs: version
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
|
||||
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
@@ -59,8 +132,8 @@ jobs:
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=raw,value=${{ needs.version.outputs.semver }}
|
||||
type=raw,value=${{ needs.version.outputs.major_minor }}
|
||||
type=raw,value=latest
|
||||
|
||||
- uses: docker/build-push-action@v6
|
||||
|
||||
@@ -20,6 +20,9 @@ data/*
|
||||
.DS_Store
|
||||
coverage/
|
||||
dist/
|
||||
# the blanket dist/ above excludes the embedded-UI dir itself, which would
|
||||
# make the .keep negation unreachable — re-include the dir, contents stay ignored
|
||||
!apps/backend/internal/web/dist/
|
||||
|
||||
# local tooling artifacts
|
||||
.playwright-mcp/
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
# Dash
|
||||
|
||||
Self-hosted homelab dashboard. Single Go binary serving an embedded React UI.
|
||||
No auth, no YAML. See `ROADMAP.md` (canonical) and `docs/specs/` for design.
|
||||
Optional auth (off by default), no YAML. See `ROADMAP.md` (canonical) and
|
||||
`docs/specs/` for design.
|
||||
|
||||
## Layout
|
||||
|
||||
|
||||
@@ -6,6 +6,55 @@ All notable changes to Dash. Format follows
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [2.1.0] — 2026-09-23
|
||||
|
||||
### Added
|
||||
|
||||
- Domain detail now identifies vendors per record: A/AAAA/CNAME/NS/MX/TXT
|
||||
rows carry provider badges (Cloudflare, Google, Purelymail, …) with favicon
|
||||
icons, plus a five-role provider strip (registrar, DNS, hosting, email, CA)
|
||||
above the detail sections. Detection covers ~70 NS providers, ~45 mail
|
||||
providers, ~60 CNAME targets, ASN/org matching, response-header signals,
|
||||
and TXT verification/SPF attribution
|
||||
|
||||
### Changed
|
||||
|
||||
- Settings dialog regrouped into icon-labelled sections (renderer, appearance,
|
||||
wallpaper/CSS, notifications, auth, backup); renderer tiles now show a
|
||||
per-style glyph and the theme picker is a segmented control
|
||||
|
||||
- Monitoring charts restyled to the Beszel school: titled chart cards with
|
||||
subtitles, soft area fills on a true time-scaled axis, unit-aware Y ticks,
|
||||
sorted dotted tooltips, and legend chips on multi-series charts
|
||||
- System detail gains stacked per-container Docker CPU/memory charts,
|
||||
per-sensor temperature lines, load 1/5/15, and GPU usage/VRAM charts —
|
||||
all from data the agent already sends
|
||||
|
||||
## [2.0.0] — 2026-09-22
|
||||
|
||||
### Added
|
||||
|
||||
- Opt-in local auth: first-user setup, bcrypt passwords, SQLite sessions,
|
||||
rotating 7-day HttpOnly cookie, login rate limit, sign-out/disable in
|
||||
settings. Off by default; status pages, badges, metrics, push, and agent
|
||||
ingest stay public
|
||||
- Multiple boards: named boards with slugs, header switcher, `/b/:slug`
|
||||
routes, per-board sections, board-aware export
|
||||
- Full i18n: every UI string via typed `t()` keys with interpolation;
|
||||
English + Czech locales, language select in settings
|
||||
- Uptime bar strip + latency chart on monitor detail
|
||||
- iframe embed widget and generic JSON-path widget (backend fetcher)
|
||||
- Custom accent color, wallpaper URL, and custom CSS in settings
|
||||
- PWA: manifest, icons, minimal service worker
|
||||
- Alert transports: webhook, Slack, Discord, Telegram, Gotify, ntfy, SMTP
|
||||
(stdlib `net/smtp`); transport picker + test button in settings
|
||||
- Per-monitor alert rules: consecutive-failure threshold, latency warn, mute;
|
||||
per-domain cert-days threshold and mute
|
||||
- Per-container CPU/memory in `dash-agent` (Docker stats when socket present);
|
||||
optional SMART, ZFS, and GPU collectors (exec-or-sysfs, skipped when absent)
|
||||
- Subdomain discovery via Certificate Transparency + DNS, daily sweep,
|
||||
subdomains section on domain detail
|
||||
|
||||
## [0.7.0] — 2026-09-21
|
||||
|
||||
### Added
|
||||
@@ -77,7 +126,9 @@ All notable changes to Dash. Format follows
|
||||
- Single Go binary serving the embedded React UI; SQLite via goose migrations;
|
||||
Docker image on distroless
|
||||
|
||||
[Unreleased]: https://github.com/Dvorinka/Dash/compare/v0.7.0...HEAD
|
||||
[Unreleased]: https://github.com/Dvorinka/Dash/compare/v2.1.0...HEAD
|
||||
[2.1.0]: https://github.com/Dvorinka/Dash/compare/v2.0.0...v2.1.0
|
||||
[2.0.0]: https://github.com/Dvorinka/Dash/compare/v0.7.0...v2.0.0
|
||||
[0.7.0]: https://github.com/Dvorinka/Dash/compare/v0.6.0...v0.7.0
|
||||
[0.6.0]: https://github.com/Dvorinka/Dash/compare/v0.5.0...v0.6.0
|
||||
[0.5.0]: https://github.com/Dvorinka/Dash/compare/v0.4.0...v0.5.0
|
||||
|
||||
@@ -28,8 +28,8 @@ DASH_DEV=1 go run ./cmd/dash # API on :8080
|
||||
and `-- +goose Down`.
|
||||
- Board renderers share state, drag-drop, and popover primitives. Never
|
||||
reimplement them per renderer.
|
||||
- No auth, no YAML-as-source-of-truth, no external database. These are
|
||||
non-goals for v1.
|
||||
- Auth is opt-in and local-only: no OIDC/SSO, no multi-user RBAC. No
|
||||
YAML-as-source-of-truth, no external database. These are non-goals.
|
||||
- Standard library before dependencies; existing helpers before new ones.
|
||||
- User-visible strings go through `t("key")` from `src/i18n` — add the key to
|
||||
`en.ts` rather than inlining literals in new UI.
|
||||
|
||||
@@ -1,53 +1,106 @@
|
||||
# Dash
|
||||
<p align="center">
|
||||
<img src="./dash.svg" alt="Dash" width="120">
|
||||
</p>
|
||||
|
||||
A self-hosted homelab dashboard. CasaOS-grade ease of use, Vercel-grade design.
|
||||
Single container, UI-managed, no YAML. Optional auth.
|
||||
<h1 align="center">Dash</h1>
|
||||
|
||||

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