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
|
name: release
|
||||||
|
|
||||||
|
# Releases are cut automatically from main: after the ci workflow passes on a
|
||||||
|
# main push, the version job bumps semver from conventional commit subjects
|
||||||
|
# (BREAKING/! -> major, feat -> minor, otherwise patch), pushes the tag, and
|
||||||
|
# the build jobs release it. Manual tags are not used; workflow_dispatch
|
||||||
|
# re-runs the same flow on demand.
|
||||||
on:
|
on:
|
||||||
push:
|
workflow_run:
|
||||||
tags: ["v*"]
|
workflows: [ci]
|
||||||
|
types: [completed]
|
||||||
|
branches: [main]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
packages: write
|
packages: write
|
||||||
|
|
||||||
|
# Two merges racing would compute the same tag; serialize instead.
|
||||||
|
concurrency:
|
||||||
|
group: release
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
version:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success'
|
||||||
|
outputs:
|
||||||
|
version: ${{ steps.v.outputs.version }}
|
||||||
|
semver: ${{ steps.v.outputs.semver }}
|
||||||
|
major_minor: ${{ steps.v.outputs.major_minor }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
|
||||||
|
|
||||||
|
- id: v
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
latest="$(git tag -l 'v*' --sort=-v:refname | head -n1)"
|
||||||
|
if [ -z "$latest" ]; then
|
||||||
|
version="v0.1.0"; bump="minor"
|
||||||
|
else
|
||||||
|
subjects="$(git log "$latest"..HEAD --format=%s)"
|
||||||
|
if grep -qiE 'BREAKING[ -]CHANGE|^[a-z]+(\([^)]*\))?!:' <<< "$subjects"; then
|
||||||
|
bump="major"
|
||||||
|
elif grep -qE '^feat(\([^)]*\))?:' <<< "$subjects"; then
|
||||||
|
bump="minor"
|
||||||
|
else
|
||||||
|
bump="patch"
|
||||||
|
fi
|
||||||
|
IFS=. read -r major minor patch <<< "${latest#v}"
|
||||||
|
case "$bump" in
|
||||||
|
major) major=$((major+1)); minor=0; patch=0 ;;
|
||||||
|
minor) minor=$((minor+1)); patch=0 ;;
|
||||||
|
*) patch=$((patch+1)) ;;
|
||||||
|
esac
|
||||||
|
version="v${major}.${minor}.${patch}"
|
||||||
|
fi
|
||||||
|
semver="${version#v}"
|
||||||
|
IFS=. read -r mj mn _ <<< "$semver"
|
||||||
|
{
|
||||||
|
echo "version=$version"
|
||||||
|
echo "semver=$semver"
|
||||||
|
echo "major_minor=$mj.$mn"
|
||||||
|
} >> "$GITHUB_OUTPUT"
|
||||||
|
echo "latest=${latest:-none} bump=$bump -> $version"
|
||||||
|
|
||||||
|
- name: Push tag
|
||||||
|
env:
|
||||||
|
VERSION: ${{ steps.v.outputs.version }}
|
||||||
|
run: |
|
||||||
|
git config user.name "github-actions[bot]"
|
||||||
|
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||||
|
git tag "$VERSION"
|
||||||
|
git push origin "$VERSION"
|
||||||
|
|
||||||
binaries:
|
binaries:
|
||||||
|
needs: version
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0 # goreleaser needs full history for tag + changelog
|
fetch-depth: 0 # goreleaser needs full history for tag + changelog
|
||||||
|
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
@@ -40,11 +109,15 @@ jobs:
|
|||||||
args: release --clean
|
args: release --clean
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
GORELEASER_CURRENT_TAG: ${{ needs.version.outputs.version }}
|
||||||
|
|
||||||
docker:
|
docker:
|
||||||
|
needs: version
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
|
||||||
|
|
||||||
- uses: docker/setup-buildx-action@v3
|
- uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
@@ -59,8 +132,8 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
images: ghcr.io/${{ github.repository }}
|
images: ghcr.io/${{ github.repository }}
|
||||||
tags: |
|
tags: |
|
||||||
type=semver,pattern={{version}}
|
type=raw,value=${{ needs.version.outputs.semver }}
|
||||||
type=semver,pattern={{major}}.{{minor}}
|
type=raw,value=${{ needs.version.outputs.major_minor }}
|
||||||
type=raw,value=latest
|
type=raw,value=latest
|
||||||
|
|
||||||
- uses: docker/build-push-action@v6
|
- uses: docker/build-push-action@v6
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ data/*
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
coverage/
|
coverage/
|
||||||
dist/
|
dist/
|
||||||
|
# the blanket dist/ above excludes the embedded-UI dir itself, which would
|
||||||
|
# make the .keep negation unreachable — re-include the dir, contents stay ignored
|
||||||
|
!apps/backend/internal/web/dist/
|
||||||
|
|
||||||
# local tooling artifacts
|
# local tooling artifacts
|
||||||
.playwright-mcp/
|
.playwright-mcp/
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
# Dash
|
# Dash
|
||||||
|
|
||||||
Self-hosted homelab dashboard. Single Go binary serving an embedded React UI.
|
Self-hosted homelab dashboard. Single Go binary serving an embedded React UI.
|
||||||
No auth, no YAML. See `ROADMAP.md` (canonical) and `docs/specs/` for design.
|
Optional auth (off by default), no YAML. See `ROADMAP.md` (canonical) and
|
||||||
|
`docs/specs/` for design.
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,55 @@ All notable changes to Dash. Format follows
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
## [0.7.0] — 2026-09-21
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -77,7 +126,9 @@ All notable changes to Dash. Format follows
|
|||||||
- Single Go binary serving the embedded React UI; SQLite via goose migrations;
|
- Single Go binary serving the embedded React UI; SQLite via goose migrations;
|
||||||
Docker image on distroless
|
Docker image on distroless
|
||||||
|
|
||||||
[Unreleased]: https://github.com/Dvorinka/Dash/compare/v0.7.0...HEAD
|
[Unreleased]: https://github.com/Dvorinka/Dash/compare/v2.1.0...HEAD
|
||||||
|
[2.1.0]: https://github.com/Dvorinka/Dash/compare/v2.0.0...v2.1.0
|
||||||
|
[2.0.0]: https://github.com/Dvorinka/Dash/compare/v0.7.0...v2.0.0
|
||||||
[0.7.0]: https://github.com/Dvorinka/Dash/compare/v0.6.0...v0.7.0
|
[0.7.0]: https://github.com/Dvorinka/Dash/compare/v0.6.0...v0.7.0
|
||||||
[0.6.0]: https://github.com/Dvorinka/Dash/compare/v0.5.0...v0.6.0
|
[0.6.0]: https://github.com/Dvorinka/Dash/compare/v0.5.0...v0.6.0
|
||||||
[0.5.0]: https://github.com/Dvorinka/Dash/compare/v0.4.0...v0.5.0
|
[0.5.0]: https://github.com/Dvorinka/Dash/compare/v0.4.0...v0.5.0
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ DASH_DEV=1 go run ./cmd/dash # API on :8080
|
|||||||
and `-- +goose Down`.
|
and `-- +goose Down`.
|
||||||
- Board renderers share state, drag-drop, and popover primitives. Never
|
- Board renderers share state, drag-drop, and popover primitives. Never
|
||||||
reimplement them per renderer.
|
reimplement them per renderer.
|
||||||
- No auth, no YAML-as-source-of-truth, no external database. These are
|
- Auth is opt-in and local-only: no OIDC/SSO, no multi-user RBAC. No
|
||||||
non-goals for v1.
|
YAML-as-source-of-truth, no external database. These are non-goals.
|
||||||
- Standard library before dependencies; existing helpers before new ones.
|
- Standard library before dependencies; existing helpers before new ones.
|
||||||
- User-visible strings go through `t("key")` from `src/i18n` — add the key to
|
- User-visible strings go through `t("key")` from `src/i18n` — add the key to
|
||||||
`en.ts` rather than inlining literals in new UI.
|
`en.ts` rather than inlining literals in new UI.
|
||||||
|
|||||||
@@ -1,53 +1,106 @@
|
|||||||
# Dash
|
<p align="center">
|
||||||
|
<img src="./dash.svg" alt="Dash" width="120">
|
||||||
|
</p>
|
||||||
|
|
||||||
A self-hosted homelab dashboard. CasaOS-grade ease of use, Vercel-grade design.
|
<h1 align="center">Dash</h1>
|
||||||
Single container, UI-managed, no YAML. Optional auth.
|
|
||||||
|
|
||||||

|
<p align="center">
|
||||||
|
Self-hosted homelab dashboard.<br>
|
||||||
|
Service boards, uptime monitors, domain watch, and system metrics in one container.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="#quick-start">Quick Start</a> ·
|
||||||
|
<a href="docs/installation.md">Documentation</a> ·
|
||||||
|
<a href="https://github.com/Dvorinka/Dash/releases">Releases</a> ·
|
||||||
|
<a href="CONTRIBUTING.md">Contributing</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://github.com/Dvorinka/Dash/actions/workflows/ci.yml"><img src="https://github.com/Dvorinka/Dash/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
|
||||||
|
<a href="https://github.com/Dvorinka/Dash/releases"><img src="https://img.shields.io/github/v/release/Dvorinka/Dash" alt="Release"></a>
|
||||||
|
<a href="LICENSE"><img src="https://img.shields.io/github/license/Dvorinka/Dash" alt="License"></a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
## What is Dash?
|
||||||
|
|
||||||
|
Dash is a self-hosted homelab dashboard: service boards with drag-and-drop,
|
||||||
|
uptime monitors, domain intelligence, push-based system metrics, incidents,
|
||||||
|
and public status pages — one static Go binary serving an embedded React UI.
|
||||||
|
No YAML, no external database; auth is optional and off by default.
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|
| Board | Monitors | Systems |
|
||||||
|
|:-:|:-:|:-:|
|
||||||
|
|  |  |  |
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>More screenshots</summary>
|
||||||
|
|
||||||
|
| Domain intel | Domains | Status page |
|
||||||
|
|:-:|:-:|:-:|
|
||||||
|
|  |  |  |
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Service board** — multiple boards, sections, drag-and-drop, multi-URL
|
- **Service boards** — multiple boards, sections, drag-and-drop, multi-URL services, icon upload, four renderers (Bento, Cards, Index, Console), dark/light themes, custom accent/wallpaper/CSS.
|
||||||
services, icon upload, four renderers (Bento, Cards, Index, Console),
|
- **Uptime monitors** — HTTP, TCP, ping, DNS, keyword, JSON-path, and push checks on a schedule; heartbeat history, uptime graphs, per-monitor alert rules.
|
||||||
dark/light themes, custom accent/wallpaper/CSS
|
- **Domain watch** — WHOIS/RDAP, TLS certificate, host geolocation, and CT-log subdomain discovery; every DNS record is attributed to its vendor (Cloudflare, Google Workspace, Purelymail, …) with provider badges across registrar, DNS, hosting, email, and CA.
|
||||||
- **Uptime monitors** — HTTP, TCP, ping, DNS, keyword, JSON-path, and push
|
- **System monitoring** — lightweight `dash-agent` pushes CPU, memory, disk, network, load, temperatures, per-container stats, and SMART/ZFS/GPU when available; rendered as stacked per-container charts, per-sensor temperature lines, and load/bandwidth series.
|
||||||
checks on a schedule, heartbeat history, uptime graphs, per-monitor alert
|
- **Incidents & status pages** — automatic incidents on monitor down, maintenance windows, public `/status/:slug` pages, SVG badges.
|
||||||
rules
|
- **Alerts** — webhook, Slack, Discord, Telegram, Gotify, ntfy, and SMTP transports.
|
||||||
- **Domain watch** — WHOIS/RDAP, DNS records, TLS certificate expiry,
|
- **Observability** — Prometheus `/api/metrics`, JSON export, CSV bulk import, importers for Homepage, Homarr, and Dashy configs.
|
||||||
registrar and provider detection, CT-log subdomain discovery
|
- **Optional auth** — local username/password, bcrypt + SQLite sessions, off by default; public status pages stay open either way.
|
||||||
- **System monitoring** — lightweight `dash-agent` pushes CPU, memory, disk,
|
- **i18n + PWA** — English and Czech UI, installable manifest/service worker.
|
||||||
network, load, temperatures, per-container stats, and SMART/ZFS/GPU when
|
|
||||||
available
|
|
||||||
- **Incidents & status pages** — manual and automatic incidents, maintenance
|
|
||||||
windows, public `/status/:slug` pages, SVG badges
|
|
||||||
- **Alerts** — webhook, Slack, Discord, Telegram, Gotify, ntfy, and SMTP
|
|
||||||
transports
|
|
||||||
- **Observability** — Prometheus `/api/metrics`, JSON export, CSV bulk import,
|
|
||||||
importers for Homepage, Homarr, and Dashy configs
|
|
||||||
- **Optional auth** — local username/password with bcrypt + session cookies,
|
|
||||||
off by default; public status pages stay open either way
|
|
||||||
- **i18n + PWA** — English and Czech UI, installable manifest/service worker
|
|
||||||
|
|
||||||
## Run
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Browser ──▶ dash (single static Go binary)
|
||||||
|
├── Embedded React UI (embed.FS)
|
||||||
|
├── REST API (Gin) ──▶ SQLite
|
||||||
|
└── Schedulers ──▶ monitors, domain refresh, alerts, retention
|
||||||
|
│
|
||||||
|
▼ outbound
|
||||||
|
HTTP/TCP/ICMP/DNS, WHOIS/RDAP, TLS, SMTP
|
||||||
|
|
||||||
|
dash-agent (one binary per host) ──push JSON──▶ POST /api/systems/ingest
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
Prerequisites: Docker.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://raw.githubusercontent.com/Dvorinka/Dash/main/install.sh | sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Installs into `./dash` and starts the stack on port 3000. Or from a clone:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/Dvorinka/Dash.git && cd Dash
|
||||||
|
./scripts/setup.sh # builds the image and starts the stack
|
||||||
|
```
|
||||||
|
|
||||||
|
Or plain `docker run`, no script needed:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
docker run -d -v ./data:/data -p 3000:3000 ghcr.io/dvorinka/dash:latest
|
docker run -d -v ./data:/data -p 3000:3000 ghcr.io/dvorinka/dash:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
or with compose:
|
Then open `http://localhost:3000` — everything is configured from the UI.
|
||||||
|
For the system agent, binary installs, reverse proxies, and upgrades see
|
||||||
|
[docs/installation.md](docs/installation.md).
|
||||||
|
|
||||||
```yaml
|
## Configuration
|
||||||
services:
|
|
||||||
dash:
|
|
||||||
image: ghcr.io/dvorinka/dash:latest
|
|
||||||
ports:
|
|
||||||
- "3000:3000"
|
|
||||||
volumes:
|
|
||||||
- ./data:/data
|
|
||||||
restart: unless-stopped
|
|
||||||
```
|
|
||||||
|
|
||||||
Then open http://localhost:3000. Everything is configured from the UI.
|
Dash is configured from the UI; the server reads three environment
|
||||||
|
variables (`DASH_PORT`, `DASH_DATA_DIR`, `DASH_DEV`). All state lives in one
|
||||||
|
directory — a SQLite database plus uploaded icons. See
|
||||||
|
[docs/configuration.md](docs/configuration.md).
|
||||||
|
|
||||||
## System agent
|
## System agent
|
||||||
|
|
||||||
@@ -57,22 +110,16 @@ Monitor a Linux host: create a system in the UI, copy the token, then:
|
|||||||
dash-agent -url http://dash:3000 -token <token>
|
dash-agent -url http://dash:3000 -token <token>
|
||||||
```
|
```
|
||||||
|
|
||||||
One static binary, no dependencies. See
|
One static binary, no dependencies. systemd unit and container image covered
|
||||||
[docs/installation.md](docs/installation.md) for systemd and Docker setups.
|
in [docs/installation.md](docs/installation.md#dash-agent-system-monitoring).
|
||||||
|
|
||||||
## Screenshots
|
## Documentation
|
||||||
|
|
||||||
| | |
|
- [docs/installation.md](docs/installation.md) — Docker, binary, agent, reverse proxy, upgrades
|
||||||
|---|---|
|
- [docs/configuration.md](docs/configuration.md) — env vars, notifications, auth, boards, operations
|
||||||
|  |  |
|
- [docs/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
|
||||||
## Docs
|
|
||||||
|
|
||||||
- [Installation](docs/installation.md)
|
|
||||||
- [Configuration](docs/configuration.md)
|
|
||||||
- [Widget development](docs/widgets.md)
|
|
||||||
- [Roadmap](ROADMAP.md) · [Changelog](CHANGELOG.md) · [Contributing](CONTRIBUTING.md)
|
|
||||||
|
|
||||||
## Develop
|
## Develop
|
||||||
|
|
||||||
@@ -80,21 +127,21 @@ Requires Go 1.24+ and Node 20+.
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
npm install # workspaces: frontend + api-client
|
npm install # workspaces: frontend + api-client
|
||||||
npm run generate # regenerate TS client from openapi.yaml
|
|
||||||
npm run dev # vite dev on :5173, proxies /api to :8080
|
npm run dev # vite dev on :5173, proxies /api to :8080
|
||||||
```
|
```
|
||||||
|
|
||||||
In another shell:
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cd apps/backend
|
cd apps/backend
|
||||||
DASH_DEV=1 go run ./cmd/dash # API on :8080 (DASH_DEV skips embedded UI)
|
DASH_DEV=1 go run ./cmd/dash # API on :8080 (DASH_DEV skips embedded UI)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Stack
|
## Contributing
|
||||||
|
|
||||||
Go + Gin + SQLite (single static binary, embeds the frontend) · React 18 +
|
See [CONTRIBUTING.md](CONTRIBUTING.md) for the development workflow.
|
||||||
Vite + TS strict + Tailwind + shadcn/ui + dnd-kit · OpenAPI-generated client.
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
See [SECURITY.md](SECURITY.md) for reporting vulnerabilities.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -149,27 +149,27 @@ becomes the summary layer over the monitoring pages.
|
|||||||
|
|
||||||
Uptime checking: the backbone everything else hangs off.
|
Uptime checking: the backbone everything else hangs off.
|
||||||
|
|
||||||
- [ ] Schema: `monitors` (name, type, url/hostname/port, method, keyword, json_query, expected_value, interval_s, timeout_s, retries, active, status, tags, notes), `heartbeats` (monitor_id, status, ping_ms, msg, cert_expiry, checked_at — 30-day retention prune)
|
- [x] Schema: `monitors` (name, type, url/hostname/port, method, keyword, json_query, expected_value, interval_s, timeout_s, retries, active, status, tags, notes), `heartbeats` (monitor_id, status, ping_ms, msg, cert_expiry, checked_at — 30-day retention prune)
|
||||||
- [ ] In-process scheduler: due-monitor sweep, bounded worker pool, heartbeat write, status transitions
|
- [x] In-process scheduler: due-monitor sweep, bounded worker pool, heartbeat write, status transitions
|
||||||
- [ ] Checkers: `http`/`https`, `tcp`, `ping` (unprivileged ICMP datagram via `x/net/icmp` — distroless has no ping binary), `dns`, `keyword`, `json-query`, `push` (caller-generated ingest URL)
|
- [x] Checkers: `http`/`https`, `tcp`, `ping` (unprivileged ICMP datagram via `x/net/icmp` — distroless has no ping binary), `dns`, `keyword`, `json-query`, `push` (caller-generated ingest URL)
|
||||||
- [ ] API: `/api/monitors` CRUD, `/:id/heartbeats?range=`, pause/resume, check-now
|
- [x] API: `/api/monitors` CRUD, `/:id/heartbeats?range=`, pause/resume, check-now
|
||||||
- [ ] Monitors page (status, uptime 24h/30d, ping, interval) + detail (response chart, heartbeat log)
|
- [x] Monitors page (status, uptime 24h/30d, ping, interval) + detail (response chart, heartbeat log)
|
||||||
- [ ] Board widget type `monitor` bound to a monitor id
|
- [x] Board widget type `monitor` bound to a monitor id
|
||||||
- [ ] ServiceDialog checkbox: create matching http monitor from a service URL
|
- [x] ServiceDialog checkbox: create matching http monitor from a service URL
|
||||||
- [ ] The fork's ~30 exotic monitor types were TCP stubs — not ported. Real types only.
|
- [x] The fork's ~30 exotic monitor types were TCP stubs — not ported. Real types only.
|
||||||
|
|
||||||
### Phase M2 — Domains → v0.5.0
|
### Phase M2 — Domains → v0.5.0
|
||||||
|
|
||||||
Domain-locker-grade domain intelligence.
|
Domain-locker-grade domain intelligence.
|
||||||
|
|
||||||
- [ ] Schema: `domains` (core columns + `extra` JSON for the long tail), `domain_history` (domain_id, change_type, field, old, new, at)
|
- [x] Schema: `domains` (core columns + `extra` JSON for the long tail), `domain_history` (domain_id, change_type, field, old, new, at)
|
||||||
- [ ] Lookup pipeline ported from fork `hub/domains/whois/lookup.go`, trimmed: RDAP over HTTPS → native WHOIS TCP:43 + parser; SSL chain via `crypto/tls`; DNS (NS/MX/TXT/A/AAAA) via `net.Resolver`; host geo via ip-api; provider detection via fork `detect/providers.go`; favicon
|
- [x] Lookup pipeline ported from fork `hub/domains/whois/lookup.go`, trimmed: RDAP over HTTPS → native WHOIS TCP:43 + parser; SSL chain via `crypto/tls`; DNS (NS/MX/TXT/A/AAAA) via `net.Resolver`; host geo via ip-api; provider detection via fork `detect/providers.go`; favicon
|
||||||
- [ ] Daily scheduler + manual refresh; field diffs recorded to `domain_history`
|
- [x] Daily scheduler + manual refresh; field diffs recorded to `domain_history`
|
||||||
- [ ] Alerts: `alert_rules` + `notifications` tables; dispatchers — generic webhook (JSON POST), SMTP via `net/smtp`, Discord/Slack presets. Triggers: domain expiry ≤ N days, SSL expiry ≤ N days, monitor down/recovered (M1), system offline (M3)
|
- [x] Alerts: `alert_rules` + `notifications` tables; dispatchers — generic webhook (JSON POST), SMTP via `net/smtp`, Discord/Slack presets. Triggers: domain expiry ≤ N days, SSL expiry ≤ N days, monitor down/recovered (M1), system offline (M3)
|
||||||
- [ ] Domains page + detail (expiry countdown, registrar, SSL, DNS, subdomains)
|
- [x] Domains page + detail (expiry countdown, registrar, SSL, DNS, subdomains)
|
||||||
- [ ] Board widget `domain` (expiry countdown)
|
- [x] Board widget `domain` (expiry countdown)
|
||||||
- [ ] Subdomain discovery: port `subdomain_discovery.go`, opt-in per domain
|
- [x] Subdomain discovery: port `subdomain_discovery.go`, opt-in per domain
|
||||||
- [ ] Not ported (v1): whoisxml API, EURid web scraping, SEO/robots parsing, valuation estimates — demand only
|
- [x] Not ported (v1): whoisxml API, EURid web scraping, SEO/robots parsing, valuation estimates — demand only
|
||||||
|
|
||||||
### Phase M3 — Systems → v0.6.0
|
### Phase M3 — Systems → v0.6.0
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
| latest release | yes |
|
| latest release | yes |
|
||||||
| older releases | no |
|
| older releases | no |
|
||||||
|
|
||||||
Dash is pre-1.0; only the latest release receives fixes.
|
Only the latest release receives fixes.
|
||||||
|
|
||||||
## Reporting a vulnerability
|
## Reporting a vulnerability
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ const domainCols = `id, name, tld, active, auto_renew, alert_days_before, tags,
|
|||||||
ssl_key_size, ssl_sig_algo, ssl_alt_names, host_country, host_country_code,
|
ssl_key_size, ssl_sig_algo, ssl_alt_names, host_country, host_country_code,
|
||||||
host_region, host_city, host_isp, host_org, host_as, host_lat, host_lon,
|
host_region, host_city, host_isp, host_org, host_as, host_lat, host_lon,
|
||||||
dns_provider, email_provider, hosting_provider, ca_provider, headers,
|
dns_provider, email_provider, hosting_provider, ca_provider, headers,
|
||||||
|
records, providers,
|
||||||
favicon_url, lookup_error, interval_h, last_checked, position, alerts, created_at`
|
favicon_url, lookup_error, interval_h, last_checked, position, alerts, created_at`
|
||||||
|
|
||||||
// domainRow mirrors the domains table; JSON text columns decode on scan.
|
// domainRow mirrors the domains table; JSON text columns decode on scan.
|
||||||
@@ -82,6 +83,8 @@ type domainRow struct {
|
|||||||
HostingProvider string `json:"hostingProvider"`
|
HostingProvider string `json:"hostingProvider"`
|
||||||
CAProvider string `json:"caProvider"`
|
CAProvider string `json:"caProvider"`
|
||||||
Headers map[string]string `json:"headers"`
|
Headers map[string]string `json:"headers"`
|
||||||
|
Records []domain.DNSRecord `json:"records"`
|
||||||
|
Providers domain.Providers `json:"providers"`
|
||||||
FaviconURL string `json:"faviconUrl"`
|
FaviconURL string `json:"faviconUrl"`
|
||||||
LookupError string `json:"lookupError"`
|
LookupError string `json:"lookupError"`
|
||||||
IntervalH int `json:"intervalH"`
|
IntervalH int `json:"intervalH"`
|
||||||
@@ -93,7 +96,7 @@ type domainRow struct {
|
|||||||
|
|
||||||
func scanDomain(row interface{ Scan(...any) error }) (*domainRow, error) {
|
func scanDomain(row interface{ Scan(...any) error }) (*domainRow, error) {
|
||||||
var d domainRow
|
var d domainRow
|
||||||
var tags, statuses, ns, mx, txt, v4, v6, altNames, headers, alerts string
|
var tags, statuses, ns, mx, txt, v4, v6, altNames, headers, records, providers, alerts string
|
||||||
var active, autoRenew, privacy, lock int
|
var active, autoRenew, privacy, lock int
|
||||||
err := row.Scan(&d.ID, &d.Name, &d.TLD, &active, &autoRenew, &d.AlertDaysBefore,
|
err := row.Scan(&d.ID, &d.Name, &d.TLD, &active, &autoRenew, &d.AlertDaysBefore,
|
||||||
&tags, &d.Notes, &d.ExpiryDate, &d.CreationDate, &d.UpdatedDate,
|
&tags, &d.Notes, &d.ExpiryDate, &d.CreationDate, &d.UpdatedDate,
|
||||||
@@ -104,7 +107,7 @@ func scanDomain(row interface{ Scan(...any) error }) (*domainRow, error) {
|
|||||||
&d.SSLKeySize, &d.SSLSigAlgo, &altNames, &d.HostCountry, &d.HostCountryCode,
|
&d.SSLKeySize, &d.SSLSigAlgo, &altNames, &d.HostCountry, &d.HostCountryCode,
|
||||||
&d.HostRegion, &d.HostCity, &d.HostISP, &d.HostOrg, &d.HostAS, &d.HostLat,
|
&d.HostRegion, &d.HostCity, &d.HostISP, &d.HostOrg, &d.HostAS, &d.HostLat,
|
||||||
&d.HostLon, &d.DNSProvider, &d.EmailProvider, &d.HostingProvider, &d.CAProvider,
|
&d.HostLon, &d.DNSProvider, &d.EmailProvider, &d.HostingProvider, &d.CAProvider,
|
||||||
&headers, &d.FaviconURL, &d.LookupError, &d.IntervalH, &d.LastChecked,
|
&headers, &records, &providers, &d.FaviconURL, &d.LookupError, &d.IntervalH, &d.LastChecked,
|
||||||
&d.Position, &alerts, &d.CreatedAt)
|
&d.Position, &alerts, &d.CreatedAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -124,6 +127,9 @@ func scanDomain(row interface{ Scan(...any) error }) (*domainRow, error) {
|
|||||||
d.SSLAltNames = decodeList(altNames)
|
d.SSLAltNames = decodeList(altNames)
|
||||||
d.Headers = map[string]string{}
|
d.Headers = map[string]string{}
|
||||||
_ = json.Unmarshal([]byte(headers), &d.Headers)
|
_ = json.Unmarshal([]byte(headers), &d.Headers)
|
||||||
|
d.Records = []domain.DNSRecord{}
|
||||||
|
_ = json.Unmarshal([]byte(records), &d.Records)
|
||||||
|
_ = json.Unmarshal([]byte(providers), &d.Providers)
|
||||||
return &d, nil
|
return &d, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -417,6 +423,8 @@ func (s *Server) applyResult(id string, r *domain.Result) {
|
|||||||
expiry, created, updated = fmtT(r.ExpiryDate), fmtT(r.CreationDate), fmtT(r.UpdatedDate)
|
expiry, created, updated = fmtT(r.ExpiryDate), fmtT(r.CreationDate), fmtT(r.UpdatedDate)
|
||||||
sslFrom, sslTo = fmtT(r.SSLValidFrom), fmtT(r.SSLValidTo)
|
sslFrom, sslTo = fmtT(r.SSLValidFrom), fmtT(r.SSLValidTo)
|
||||||
hdr, _ := json.Marshal(r.Headers)
|
hdr, _ := json.Marshal(r.Headers)
|
||||||
|
recs, _ := json.Marshal(r.Records)
|
||||||
|
provs, _ := json.Marshal(r.Providers)
|
||||||
|
|
||||||
_, err := s.db.Exec(`UPDATE domains SET
|
_, err := s.db.Exec(`UPDATE domains SET
|
||||||
expiry_date=?, creation_date=?, updated_date=?, registrar_name=?,
|
expiry_date=?, creation_date=?, updated_date=?, registrar_name=?,
|
||||||
@@ -428,7 +436,8 @@ func (s *Server) applyResult(id string, r *domain.Result) {
|
|||||||
ssl_key_size=?, ssl_sig_algo=?, ssl_alt_names=?, host_country=?,
|
ssl_key_size=?, ssl_sig_algo=?, ssl_alt_names=?, host_country=?,
|
||||||
host_country_code=?, host_region=?, host_city=?, host_isp=?, host_org=?,
|
host_country_code=?, host_region=?, host_city=?, host_isp=?, host_org=?,
|
||||||
host_as=?, host_lat=?, host_lon=?, dns_provider=?, email_provider=?,
|
host_as=?, host_lat=?, host_lon=?, dns_provider=?, email_provider=?,
|
||||||
hosting_provider=?, ca_provider=?, headers=?, favicon_url=?,
|
hosting_provider=?, ca_provider=?, headers=?, records=?, providers=?,
|
||||||
|
favicon_url=?,
|
||||||
lookup_error=?, last_checked=strftime('%Y-%m-%dT%H:%M:%fZ','now')
|
lookup_error=?, last_checked=strftime('%Y-%m-%dT%H:%M:%fZ','now')
|
||||||
WHERE id=?`,
|
WHERE id=?`,
|
||||||
expiry, created, updated, r.RegistrarName, r.RegistrarID, r.RegistrarURL,
|
expiry, created, updated, r.RegistrarName, r.RegistrarID, r.RegistrarURL,
|
||||||
@@ -440,7 +449,7 @@ func (s *Server) applyResult(id string, r *domain.Result) {
|
|||||||
enc(r.SSLAltNames), r.HostCountry, r.HostCountryCode, r.HostRegion,
|
enc(r.SSLAltNames), r.HostCountry, r.HostCountryCode, r.HostRegion,
|
||||||
r.HostCity, r.HostISP, r.HostOrg, r.HostAS, r.HostLat, r.HostLon,
|
r.HostCity, r.HostISP, r.HostOrg, r.HostAS, r.HostLat, r.HostLon,
|
||||||
r.DNSProvider, r.EmailProvider, r.HostingProvider, r.CAProvider,
|
r.DNSProvider, r.EmailProvider, r.HostingProvider, r.CAProvider,
|
||||||
string(hdr), r.FaviconURL, truncate(r.Error, 300), id)
|
string(hdr), string(recs), string(provs), r.FaviconURL, truncate(r.Error, 300), id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.log.Error("domain apply", zap.Error(err))
|
s.log.Error("domain apply", zap.Error(err))
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
-- +goose Up
|
||||||
|
-- Structured DNS records + provider identifications for the domain detail
|
||||||
|
-- page. JSON blobs — the flat columns stay for compat and check history.
|
||||||
|
ALTER TABLE domains ADD COLUMN records TEXT NOT NULL DEFAULT '[]';
|
||||||
|
ALTER TABLE domains ADD COLUMN providers TEXT NOT NULL DEFAULT '{}';
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
ALTER TABLE domains DROP COLUMN providers;
|
||||||
|
ALTER TABLE domains DROP COLUMN records;
|
||||||
@@ -88,9 +88,46 @@ type Result struct {
|
|||||||
HostingProvider string `json:"hostingProvider"`
|
HostingProvider string `json:"hostingProvider"`
|
||||||
CAProvider string `json:"caProvider"`
|
CAProvider string `json:"caProvider"`
|
||||||
|
|
||||||
|
// Structured intel — domainstack-style record list + vendor strip.
|
||||||
|
Records []DNSRecord `json:"records"`
|
||||||
|
Providers Providers `json:"providers"`
|
||||||
|
|
||||||
Headers map[string]string `json:"headers"`
|
Headers map[string]string `json:"headers"`
|
||||||
FaviconURL string `json:"faviconUrl"`
|
FaviconURL string `json:"faviconUrl"`
|
||||||
Error string `json:"error"` // non-fatal partial failure note
|
Error string `json:"error"` // non-fatal partial failure note
|
||||||
|
|
||||||
|
mxPairs []mxPair // structured MX; MXRecords keeps the display string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provider names the vendor behind a record or role. Domain feeds the
|
||||||
|
// favicon icon rendered next to the name in the UI.
|
||||||
|
type Provider struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Domain string `json:"domain"`
|
||||||
|
Icon string `json:"icon"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Providers is the detected-vendor strip: registrar, DNS host, hosting,
|
||||||
|
// email, and certificate authority.
|
||||||
|
type Providers struct {
|
||||||
|
Registrar *Provider `json:"registrar,omitempty"`
|
||||||
|
DNS *Provider `json:"dns,omitempty"`
|
||||||
|
Email *Provider `json:"email,omitempty"`
|
||||||
|
Hosting *Provider `json:"hosting,omitempty"`
|
||||||
|
CA *Provider `json:"ca,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DNSRecord is one resolved record with the vendor its value points at.
|
||||||
|
type DNSRecord struct {
|
||||||
|
Type string `json:"type"` // A AAAA CNAME NS MX TXT
|
||||||
|
Value string `json:"value"`
|
||||||
|
Priority *int `json:"priority,omitempty"` // MX preference
|
||||||
|
Provider *Provider `json:"provider,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type mxPair struct {
|
||||||
|
host string
|
||||||
|
pref int
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean normalises user input to a bare registrable hostname.
|
// Clean normalises user input to a bare registrable hostname.
|
||||||
@@ -125,6 +162,7 @@ func (l *Lookup) Run(ctx context.Context, rawName string) *Result {
|
|||||||
IPv4: []string{},
|
IPv4: []string{},
|
||||||
IPv6: []string{},
|
IPv6: []string{},
|
||||||
SSLAltNames: []string{},
|
SSLAltNames: []string{},
|
||||||
|
Records: []DNSRecord{},
|
||||||
Headers: map[string]string{},
|
Headers: map[string]string{},
|
||||||
FaviconURL: fmt.Sprintf("https://www.google.com/s2/favicons?domain=%s&sz=128", name),
|
FaviconURL: fmt.Sprintf("https://www.google.com/s2/favicons?domain=%s&sz=128", name),
|
||||||
}
|
}
|
||||||
@@ -155,14 +193,68 @@ func (l *Lookup) Run(ctx context.Context, rawName string) *Result {
|
|||||||
l.host(r.IPv4[0], r)
|
l.host(r.IPv4[0], r)
|
||||||
}
|
}
|
||||||
l.headers(ctx, name, r)
|
l.headers(ctx, name, r)
|
||||||
|
l.identify(r)
|
||||||
r.DNSProvider = detectDNSProvider(r.NameServers)
|
|
||||||
r.EmailProvider = detectEmailProvider(r.MXRecords)
|
|
||||||
r.HostingProvider = detectHostingProvider(r.Headers)
|
|
||||||
r.CAProvider = detectCA(r.SSLIssuer)
|
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// identify resolves provider vendors after every probe has run, then builds
|
||||||
|
// the structured record list the detail page renders.
|
||||||
|
func (l *Lookup) identify(r *Result) {
|
||||||
|
r.Providers.Registrar = matchProvider(r.RegistrarName, registrarProviders)
|
||||||
|
for _, n := range r.NameServers {
|
||||||
|
if p := matchProvider(n, nsProviders); p != nil {
|
||||||
|
r.Providers.DNS = p
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, m := range r.mxPairs {
|
||||||
|
if p := matchProvider(m.host, mailProviders); p != nil {
|
||||||
|
r.Providers.Email = p
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r.Providers.Hosting = detectHostProvider(r)
|
||||||
|
r.Providers.CA = matchProvider(r.SSLIssuer, caProviders)
|
||||||
|
// Legacy flat names mirror the strip for the list page + old clients.
|
||||||
|
r.DNSProvider = nameOf(r.Providers.DNS)
|
||||||
|
r.EmailProvider = nameOf(r.Providers.Email)
|
||||||
|
r.HostingProvider = nameOf(r.Providers.Hosting)
|
||||||
|
r.CAProvider = nameOf(r.Providers.CA)
|
||||||
|
|
||||||
|
recs := make([]DNSRecord, 0,
|
||||||
|
len(r.IPv4)+len(r.IPv6)+len(r.NameServers)+len(r.mxPairs)+len(r.TXTRecords)+1)
|
||||||
|
add := func(t, v string, pri *int, p *Provider) {
|
||||||
|
recs = append(recs, DNSRecord{Type: t, Value: v, Priority: pri, Provider: p})
|
||||||
|
}
|
||||||
|
for _, ip := range r.IPv4 {
|
||||||
|
add("A", ip, nil, r.Providers.Hosting)
|
||||||
|
}
|
||||||
|
for _, ip := range r.IPv6 {
|
||||||
|
add("AAAA", ip, nil, r.Providers.Hosting)
|
||||||
|
}
|
||||||
|
if r.CNAME != "" {
|
||||||
|
add("CNAME", r.CNAME, nil, matchProvider(r.CNAME, hostTargets))
|
||||||
|
}
|
||||||
|
for _, n := range r.NameServers {
|
||||||
|
add("NS", n, nil, matchProvider(n, nsProviders))
|
||||||
|
}
|
||||||
|
for _, m := range r.mxPairs {
|
||||||
|
pri := m.pref
|
||||||
|
add("MX", m.host, &pri, matchProvider(m.host, mailProviders))
|
||||||
|
}
|
||||||
|
for _, t := range r.TXTRecords {
|
||||||
|
add("TXT", t, nil, matchTXTProvider(t))
|
||||||
|
}
|
||||||
|
r.Records = recs
|
||||||
|
}
|
||||||
|
|
||||||
|
func nameOf(p *Provider) string {
|
||||||
|
if p == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return p.Name
|
||||||
|
}
|
||||||
|
|
||||||
// --- DNS --------------------------------------------------------------------
|
// --- DNS --------------------------------------------------------------------
|
||||||
|
|
||||||
func (l *Lookup) dns(ctx context.Context, name string, r *Result) {
|
func (l *Lookup) dns(ctx context.Context, name string, r *Result) {
|
||||||
@@ -174,7 +266,9 @@ func (l *Lookup) dns(ctx context.Context, name string, r *Result) {
|
|||||||
}
|
}
|
||||||
if mx, err := res.LookupMX(ctx, name); err == nil {
|
if mx, err := res.LookupMX(ctx, name); err == nil {
|
||||||
for _, m := range mx {
|
for _, m := range mx {
|
||||||
r.MXRecords = append(r.MXRecords, fmt.Sprintf("%s (pri %d)", strings.TrimSuffix(m.Host, "."), m.Pref))
|
host := strings.TrimSuffix(strings.ToLower(m.Host), ".")
|
||||||
|
r.mxPairs = append(r.mxPairs, mxPair{host: host, pref: int(m.Pref)})
|
||||||
|
r.MXRecords = append(r.MXRecords, fmt.Sprintf("%s (pri %d)", host, m.Pref))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if txt, err := res.LookupTXT(ctx, name); err == nil {
|
if txt, err := res.LookupTXT(ctx, name); err == nil {
|
||||||
@@ -297,108 +391,639 @@ func (l *Lookup) headers(ctx context.Context, name string, r *Result) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- Provider detection (condensed port of beszel's detect package) -----------
|
// --- Provider detection (condensed port of beszel's detect package) -----------
|
||||||
|
//
|
||||||
|
// Tables map a substring of the record value / org / issuer to a vendor name
|
||||||
|
// plus the brand domain used for the favicon icon. Order matters — first
|
||||||
|
// match wins, so specific substrings precede generic ones.
|
||||||
|
|
||||||
func detectDNSProvider(ns []string) string {
|
type provMatch struct{ sub, name, dom string }
|
||||||
table := []struct{ sub, name string }{
|
|
||||||
{"cloudflare", "Cloudflare"}, {"awsdns", "Amazon Route 53"},
|
func prov(name, dom string) *Provider {
|
||||||
{"googledomains", "Google Domains"}, {"google.com", "Google Domains"},
|
return &Provider{Name: name, Domain: dom,
|
||||||
{"namecheap", "Namecheap"}, {"godaddy", "GoDaddy"}, {"domaincontrol", "GoDaddy"},
|
Icon: fmt.Sprintf("https://www.google.com/s2/favicons?domain=%s&sz=64", dom)}
|
||||||
{"nsone.net", "NS1"}, {"digitalocean", "DigitalOcean"}, {"linode", "Linode"},
|
|
||||||
{"vultr", "Vultr"}, {"he.net", "Hurricane Electric"}, {"dynect", "Dyn (Oracle)"},
|
|
||||||
{"ultradns", "UltraDNS"}, {"dnsimple", "DNSimple"}, {"hover", "Hover"},
|
|
||||||
{"enom", "eNom"}, {"worldnic", "Network Solutions"}, {"zoneedit", "ZoneEdit"},
|
|
||||||
{"easydns", "EasyDNS"}, {"gandi", "Gandi"}, {"ovh", "OVH"},
|
|
||||||
{"hetzner", "Hetzner"}, {"azure-dns", "Microsoft Azure"},
|
|
||||||
{"dns.google", "Google Cloud DNS"}, {"porkbun", "Porkbun"},
|
|
||||||
{"cloudns", "ClouDNS"}, {"desec", "deSEC"}, {"nic.cz", "CZ.NIC"},
|
|
||||||
}
|
|
||||||
for _, n := range ns {
|
|
||||||
n = strings.ToLower(n)
|
|
||||||
for _, t := range table {
|
|
||||||
if strings.Contains(n, t.sub) {
|
|
||||||
return t.name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func detectEmailProvider(mx []string) string {
|
func matchProvider(haystack string, table []provMatch) *Provider {
|
||||||
table := []struct{ sub, name string }{
|
h := strings.ToLower(haystack)
|
||||||
{"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 {
|
for _, t := range table {
|
||||||
if strings.Contains(m, t.sub) {
|
if strings.Contains(h, t.sub) {
|
||||||
return t.name
|
return prov(t.name, t.dom)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
return nil
|
||||||
return ""
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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"])
|
server := strings.ToLower(h["server"] + " " + h["x-powered-by"] + " " + h["via"])
|
||||||
if _, ok := h["cf-ray"]; ok || strings.Contains(server, "cloudflare") {
|
switch {
|
||||||
return "Cloudflare"
|
case h["cf-ray"] != "" || strings.Contains(server, "cloudflare"):
|
||||||
|
return prov("Cloudflare", "cloudflare.com")
|
||||||
|
case h["x-vercel-id"] != "" || strings.Contains(server, "vercel"):
|
||||||
|
return prov("Vercel", "vercel.com")
|
||||||
|
case h["x-nf-request-id"] != "" || strings.Contains(server, "netlify"):
|
||||||
|
return prov("Netlify", "netlify.com")
|
||||||
|
case h["x-amz-cf-id"] != "" || strings.Contains(server, "cloudfront"):
|
||||||
|
return prov("AWS CloudFront", "aws.amazon.com")
|
||||||
|
case h["x-github-request-id"] != "" || strings.Contains(server, "github"):
|
||||||
|
return prov("GitHub Pages", "github.com")
|
||||||
|
case strings.Contains(server, "fly.io") || h["fly-request-id"] != "":
|
||||||
|
return prov("Fly.io", "fly.io")
|
||||||
|
case strings.Contains(server, "render"):
|
||||||
|
return prov("Render", "render.com")
|
||||||
|
case strings.Contains(server, "shopify"):
|
||||||
|
return prov("Shopify", "shopify.com")
|
||||||
|
case strings.Contains(server, "squarespace"):
|
||||||
|
return prov("Squarespace", "squarespace.com")
|
||||||
|
case strings.Contains(server, "wix"):
|
||||||
|
return prov("Wix", "wix.com")
|
||||||
}
|
}
|
||||||
if _, ok := h["x-vercel-id"]; ok || strings.Contains(server, "vercel") {
|
webServers := []provMatch{
|
||||||
return "Vercel"
|
{"awselb", "AWS ELB", "aws.amazon.com"},
|
||||||
|
{"gws", "Google", "google.com"},
|
||||||
|
{"kestrel", "Kestrel/.NET", "microsoft.com"},
|
||||||
|
{"cowboy", "Cowboy/Erlang", "heroku.com"},
|
||||||
}
|
}
|
||||||
if _, ok := h["x-nf-request-id"]; ok || strings.Contains(server, "netlify") {
|
if p := matchProvider(server, webServers); p != nil {
|
||||||
return "Netlify"
|
return p
|
||||||
}
|
}
|
||||||
if _, ok := h["x-amz-cf-id"]; ok || strings.Contains(server, "cloudfront") {
|
if p := matchProvider(r.HostOrg+" "+r.HostAS+" "+r.HostISP, asnProviders); p != nil {
|
||||||
return "AWS CloudFront"
|
return p
|
||||||
}
|
}
|
||||||
if _, ok := h["x-github-request-id"]; ok || strings.Contains(server, "github") {
|
if p := matchProvider(server, []provMatch{
|
||||||
return "GitHub Pages"
|
{"nginx", "nginx", "nginx.org"},
|
||||||
|
{"apache", "Apache", "apache.org"},
|
||||||
|
{"caddy", "Caddy", "caddyserver.com"},
|
||||||
|
{"litespeed", "LiteSpeed", "litespeedtech.com"},
|
||||||
|
{"openresty", "OpenResty", "openresty.org"},
|
||||||
|
{"envoy", "Envoy", "envoyproxy.io"},
|
||||||
|
}); p != nil {
|
||||||
|
return p
|
||||||
}
|
}
|
||||||
if strings.Contains(server, "fly.io") || h["fly-request-id"] != "" {
|
return nil
|
||||||
return "Fly.io"
|
|
||||||
}
|
|
||||||
if strings.Contains(server, "render") {
|
|
||||||
return "Render"
|
|
||||||
}
|
|
||||||
table := []struct{ sub, name string }{
|
|
||||||
{"nginx", "nginx"}, {"apache", "Apache"}, {"caddy", "Caddy"},
|
|
||||||
{"litespeed", "LiteSpeed"}, {"openresty", "OpenResty"},
|
|
||||||
{"envoy", "Envoy"}, {"awselb", "AWS ELB"}, {"gws", "Google"},
|
|
||||||
{"kestrel", "Kestrel/.NET"}, {"cowboy", "Cowboy/Erlang"},
|
|
||||||
}
|
|
||||||
for _, t := range table {
|
|
||||||
if strings.Contains(server, t.sub) {
|
|
||||||
return t.name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func detectCA(issuer string) string {
|
// txtProviders: SPF includes and domain-verification strings → vendor.
|
||||||
i := strings.ToLower(issuer)
|
var txtProviders = []provMatch{
|
||||||
table := []struct{ sub, name string }{
|
{"v=spf1 include:_spf.google.com", "Google Workspace", "google.com"},
|
||||||
{"let's encrypt", "Let's Encrypt"}, {"letsencrypt", "Let's Encrypt"},
|
{"include:spf.protonmail.ch", "Proton Mail", "proton.me"},
|
||||||
{"digicert", "DigiCert"}, {"sectigo", "Sectigo"}, {"comodoca", "Sectigo"},
|
{"include:mail.protonmail.ch", "Proton Mail", "proton.me"},
|
||||||
{"globalsign", "GlobalSign"}, {"godaddy", "GoDaddy"}, {"starfield", "GoDaddy"},
|
{"include:_spf.mx.cloudflare.net", "Cloudflare", "cloudflare.com"},
|
||||||
{"entrust", "Entrust"}, {"google trust", "Google Trust Services"},
|
{"include:servers.mcsv.net", "Mailchimp", "mailchimp.com"},
|
||||||
{"amazon", "Amazon"}, {"zerossl", "ZeroSSL"}, {"buypass", "Buypass"},
|
{"include:amazonses.com", "Amazon SES", "aws.amazon.com"},
|
||||||
{"ssl.com", "SSL.com"}, {"actalis", "Actalis"}, {"certum", "Certum"},
|
{"include:mailgun.org", "Mailgun", "mailgun.com"},
|
||||||
{"harica", "HARICA"}, {"swisssign", "SwissSign"}, {"cloudflare", "Cloudflare"},
|
{"include:sendgrid.net", "SendGrid", "sendgrid.com"},
|
||||||
{"pki.goog", "Google Trust Services"}, {"microsoft", "Microsoft"},
|
{"include:spf.protection.outlook.com", "Microsoft 365", "microsoft.com"},
|
||||||
}
|
{"include:spf.purelymail.com", "Purelymail", "purelymail.com"},
|
||||||
for _, t := range table {
|
{"_spf.purelymail.com", "Purelymail", "purelymail.com"},
|
||||||
if strings.Contains(i, t.sub) {
|
{"purelymail_ownership_proof", "Purelymail", "purelymail.com"},
|
||||||
return t.name
|
{"openai-domain-verification", "OpenAI", "openai.com"},
|
||||||
}
|
{"include:_spf.migadu.com", "Migadu", "migadu.com"},
|
||||||
}
|
{"include:mx.zohomail.com", "Zoho Mail", "zoho.com"},
|
||||||
return ""
|
{"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 { useEffect, useRef, useState } from "react";
|
||||||
import { Bell, Download, Lock, Upload } from "lucide-react";
|
import { Bell, Download, HardDrive, Image, LayoutGrid, Lock, Moon, Palette, Sun, Upload, type LucideIcon } from "lucide-react";
|
||||||
import { useBoard } from "@/board/store";
|
import { useBoard } from "@/board/store";
|
||||||
import { api } from "@/api";
|
import { api } from "@/api";
|
||||||
import { rendererDescriptions, rendererLabels, rendererList } from "@/renderers";
|
import { rendererDescriptions, rendererIcons, rendererLabels, rendererList } from "@/renderers";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { LOCALES, t } from "@/i18n";
|
import { LOCALES, t } from "@/i18n";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -44,6 +44,19 @@ const TRANSPORTS = {
|
|||||||
} as const;
|
} as const;
|
||||||
type TransportName = keyof typeof TRANSPORTS;
|
type TransportName = keyof typeof TRANSPORTS;
|
||||||
|
|
||||||
|
// Group — a labelled settings section. Icon + mono header keeps the dialog
|
||||||
|
// scannable now that it holds five distinct concerns.
|
||||||
|
function Group({ icon: Icon, title, children }: { icon: LucideIcon; title: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<section className="flex flex-col gap-2.5 border-t border-border/60 pt-4 first:border-0 first:pt-0">
|
||||||
|
<h3 className="flex items-center gap-1.5 font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">
|
||||||
|
<Icon size={11} /> {title}
|
||||||
|
</h3>
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function SettingsDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (o: boolean) => void }) {
|
export function SettingsDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (o: boolean) => void }) {
|
||||||
const board = useBoard();
|
const board = useBoard();
|
||||||
const fileRef = useRef<HTMLInputElement>(null);
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -143,50 +156,70 @@ export function SettingsDialog({ open, onOpenChange }: { open: boolean; onOpenCh
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="max-w-sm">
|
<DialogContent className="max-w-xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{t("settings.title")}</DialogTitle>
|
<DialogTitle>{t("settings.title")}</DialogTitle>
|
||||||
<DialogDescription>{t("settings.desc")}</DialogDescription>
|
<DialogDescription>{t("settings.desc")}</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="flex max-h-[70vh] flex-col gap-4 overflow-y-auto pr-1">
|
<div className="flex max-h-[72vh] flex-col gap-5 overflow-y-auto pr-1">
|
||||||
<div className="flex flex-col gap-1.5">
|
<Group icon={LayoutGrid} title={t("settings.renderer")}>
|
||||||
<Label>{t("settings.renderer")}</Label>
|
<div className="grid grid-cols-2 gap-2" role="radiogroup" aria-label={t("settings.renderer")}>
|
||||||
<div className="grid grid-cols-2 gap-1.5" role="radiogroup" aria-label={t("settings.renderer")}>
|
{rendererList.map((r) => {
|
||||||
{rendererList.map((r) => (
|
const Icon = rendererIcons[r];
|
||||||
|
const active = board.renderer === r;
|
||||||
|
return (
|
||||||
<button
|
<button
|
||||||
key={r}
|
key={r}
|
||||||
type="button"
|
type="button"
|
||||||
role="radio"
|
role="radio"
|
||||||
aria-checked={board.renderer === r}
|
aria-checked={active}
|
||||||
onClick={() => void board.setSetting("renderer", r)}
|
onClick={() => void board.setSetting("renderer", r)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-[8px] border px-3 py-2.5 text-left transition-colors",
|
"flex items-center gap-3 rounded-[9px] border px-3 py-2.5 text-left transition-colors",
|
||||||
board.renderer === r
|
active
|
||||||
? "border-border-strong bg-surface-hover"
|
? "border-accent bg-accent/10"
|
||||||
: "border-border hover:border-border-strong hover:bg-surface-hover",
|
: "border-border hover:border-border-strong hover:bg-surface-hover",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
<span className={cn(
|
||||||
|
"flex size-8 shrink-0 items-center justify-center rounded-[7px] border",
|
||||||
|
active ? "border-accent/40 bg-accent/15 text-accent" : "border-border bg-bg text-text-faint",
|
||||||
|
)}>
|
||||||
|
<Icon size={15} />
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0">
|
||||||
<span className="block text-[12.5px] font-medium">{rendererLabels[r]}</span>
|
<span className="block text-[12.5px] font-medium">{rendererLabels[r]}</span>
|
||||||
<span className="block font-mono text-[10px] text-text-faint">{rendererDescriptions[r]}</span>
|
<span className="block truncate font-mono text-[10px] text-text-faint">{rendererDescriptions[r]}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Group icon={Palette} title={t("settings.appearance")}>
|
||||||
|
<div className="grid grid-cols-2 gap-3 max-[560px]:grid-cols-1">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>{t("settings.theme")}</Label>
|
||||||
|
<div className="flex overflow-hidden rounded-[7px] border border-border">
|
||||||
|
{(["dark", "light"] as const).map((th) => (
|
||||||
|
<button
|
||||||
|
key={th}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={board.theme === th}
|
||||||
|
onClick={() => void board.setSetting("theme", th)}
|
||||||
|
className={cn(
|
||||||
|
"flex flex-1 items-center justify-center gap-1.5 px-3 py-1.5 text-[12px] transition-colors",
|
||||||
|
board.theme === th ? "bg-surface-hover text-text" : "text-text-faint hover:text-text",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{th === "dark" ? <Moon size={12} /> : <Sun size={12} />}
|
||||||
|
{th === "dark" ? t("settings.dark") : t("settings.light")}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<Label>{t("settings.theme")}</Label>
|
|
||||||
<Select value={board.theme} onValueChange={(v) => {
|
|
||||||
if (v === "dark" || v === "light") void board.setSetting("theme", v);
|
|
||||||
}}>
|
|
||||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="dark">{t("settings.dark")}</SelectItem>
|
|
||||||
<SelectItem value="light">{t("settings.light")}</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label>{t("settings.language")}</Label>
|
<Label>{t("settings.language")}</Label>
|
||||||
<Select
|
<Select
|
||||||
@@ -201,24 +234,25 @@ export function SettingsDialog({ open, onOpenChange }: { open: boolean; onOpenCh
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label htmlFor="accent">{t("settings.accent")}</Label>
|
<Label htmlFor="accent">{t("settings.accent")}</Label>
|
||||||
<div className="flex gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
id="accent" type="color" aria-label={t("settings.accent")}
|
id="accent" type="color" aria-label={t("settings.accent")}
|
||||||
value={/^#[0-9a-fA-F]{6}$/.test(board.accent) ? board.accent : "#3b82f6"}
|
value={/^#[0-9a-fA-F]{6}$/.test(board.accent) ? board.accent : "#3b82f6"}
|
||||||
onChange={(e) => void board.setSetting("accent", e.target.value)}
|
onChange={(e) => void board.setSetting("accent", e.target.value)}
|
||||||
className="h-8 w-12 cursor-pointer rounded-[6px] border border-border bg-surface p-0.5"
|
className="h-8 w-12 cursor-pointer rounded-[6px] border border-border bg-surface p-0.5"
|
||||||
/>
|
/>
|
||||||
|
<span className="font-mono text-[11px] text-text-faint">{board.accent || "—"}</span>
|
||||||
<Button type="button" variant="outline" size="sm" onClick={() => void board.setSetting("accent", "")}>
|
<Button type="button" variant="outline" size="sm" onClick={() => void board.setSetting("accent", "")}>
|
||||||
{t("common.reset")}
|
{t("common.reset")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Group>
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
<Group icon={Image} title={t("settings.wallpaper")}>
|
||||||
<Label htmlFor="wallpaper">{t("settings.wallpaper")}</Label>
|
|
||||||
<Input
|
<Input
|
||||||
id="wallpaper"
|
id="wallpaper"
|
||||||
value={board.wallpaper}
|
value={board.wallpaper}
|
||||||
@@ -226,22 +260,19 @@ export function SettingsDialog({ open, onOpenChange }: { open: boolean; onOpenCh
|
|||||||
placeholder={t("settings.wallpaperPh")}
|
placeholder={t("settings.wallpaperPh")}
|
||||||
className="font-mono text-[12px]"
|
className="font-mono text-[12px]"
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<Label htmlFor="custom-css">{t("settings.customCss")}</Label>
|
|
||||||
<textarea
|
<textarea
|
||||||
id="custom-css"
|
id="custom-css"
|
||||||
|
aria-label={t("settings.customCss")}
|
||||||
value={board.customCss}
|
value={board.customCss}
|
||||||
onChange={(e) => void board.setSetting("customCss", e.target.value)}
|
onChange={(e) => void board.setSetting("customCss", e.target.value)}
|
||||||
placeholder=".item-tile { border-radius: 0; }"
|
placeholder=".item-tile { border-radius: 0; }"
|
||||||
rows={3}
|
rows={3}
|
||||||
className="w-full resize-y rounded-[7px] border border-border bg-bg px-2.5 py-2 font-mono text-[11.5px] outline-none focus:border-border-strong"
|
className="w-full resize-y rounded-[7px] border border-border bg-bg px-2.5 py-2 font-mono text-[11.5px] outline-none focus:border-border-strong"
|
||||||
/>
|
/>
|
||||||
</div>
|
<p className="text-[11px] text-text-faint">{t("settings.customCssHint")}</p>
|
||||||
|
</Group>
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
<Group icon={Bell} title={t("settings.notifications")}>
|
||||||
<Label>{t("settings.notifications")}</Label>
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Select value={transport} onValueChange={(v) => {
|
<Select value={transport} onValueChange={(v) => {
|
||||||
// SAFETY: Select only yields TRANSPORTS keys.
|
// SAFETY: Select only yields TRANSPORTS keys.
|
||||||
@@ -281,13 +312,13 @@ export function SettingsDialog({ open, onOpenChange }: { open: boolean; onOpenCh
|
|||||||
{t("settings.notifyFanout")}
|
{t("settings.notifyFanout")}
|
||||||
</p>
|
</p>
|
||||||
{notifyMsg ? <p className="text-[11px] text-text-faint">{notifyMsg}</p> : null}
|
{notifyMsg ? <p className="text-[11px] text-text-faint">{notifyMsg}</p> : null}
|
||||||
</div>
|
</Group>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2.5 rounded-lg border border-border p-3">
|
<Group icon={Lock} title={t("auth.section")}>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between rounded-lg border border-border px-3 py-2.5">
|
||||||
<Label className="flex items-center gap-1.5">
|
<p className="text-[11px] text-text-faint">
|
||||||
<Lock size={12} /> {t("auth.section")}
|
{auth.enabled ? `${t("auth.onHint")} ${auth.username ?? ""}` : t("auth.offHint")}
|
||||||
</Label>
|
</p>
|
||||||
{auth.enabled ? (
|
{auth.enabled ? (
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button type="button" variant="outline" size="sm" onClick={() => void signOut()}>
|
<Button type="button" variant="outline" size="sm" onClick={() => void signOut()}>
|
||||||
@@ -303,12 +334,10 @@ export function SettingsDialog({ open, onOpenChange }: { open: boolean; onOpenCh
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[11px] text-text-faint">
|
</Group>
|
||||||
{auth.enabled ? `${t("auth.onHint")} ${auth.username ?? ""}` : t("auth.offHint")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-2 pt-1">
|
<Group icon={HardDrive} title={t("settings.backup")}>
|
||||||
|
<div className="flex gap-2">
|
||||||
<Button type="button" variant="outline" size="sm" onClick={() => void exportJson()}>
|
<Button type="button" variant="outline" size="sm" onClick={() => void exportJson()}>
|
||||||
<Download size={12} /> {t("settings.exportJson")}
|
<Download size={12} /> {t("settings.exportJson")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -326,6 +355,7 @@ export function SettingsDialog({ open, onOpenChange }: { open: boolean; onOpenCh
|
|||||||
</div>
|
</div>
|
||||||
<p className="text-[11px] text-text-faint">{t("settings.importHint")}</p>
|
<p className="text-[11px] text-text-faint">{t("settings.importHint")}</p>
|
||||||
{msg ? <p className="text-[12px] text-destructive">{msg}</p> : null}
|
{msg ? <p className="text-[12px] text-destructive">{msg}</p> : null}
|
||||||
|
</Group>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -0,0 +1,235 @@
|
|||||||
|
// Shared time-series chart primitives — the Beszel-grade look on our tokens:
|
||||||
|
// titled cards with a subtitle, soft area fills over a real time axis,
|
||||||
|
// unit-aware Y ticks, a dotted tooltip sorted by value, and legend chips.
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import {
|
||||||
|
Area,
|
||||||
|
CartesianGrid,
|
||||||
|
ComposedChart,
|
||||||
|
Line,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Tooltip,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
} from "recharts";
|
||||||
|
import { t } from "@/i18n";
|
||||||
|
|
||||||
|
/** One chart row: epoch ms plus named series values. */
|
||||||
|
export type ChartRow = { t: number } & Record<string, number | undefined>;
|
||||||
|
|
||||||
|
export interface Series {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
color: string;
|
||||||
|
/** Shared value → stacked areas (container charts). */
|
||||||
|
stack?: string;
|
||||||
|
/** Render as a bare line, no fill (temperatures). */
|
||||||
|
line?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CHART_COLORS = [
|
||||||
|
"var(--chart-1)",
|
||||||
|
"var(--chart-2)",
|
||||||
|
"var(--chart-3)",
|
||||||
|
"var(--chart-4)",
|
||||||
|
"var(--chart-5)",
|
||||||
|
"var(--chart-6)",
|
||||||
|
"var(--chart-7)",
|
||||||
|
"var(--chart-8)",
|
||||||
|
];
|
||||||
|
export const chartColor = (i: number) => CHART_COLORS[i % CHART_COLORS.length] ?? "var(--accent)";
|
||||||
|
|
||||||
|
const axisTick = { fontSize: 10, fill: "var(--text-faint)" };
|
||||||
|
|
||||||
|
/** Auto Y-domain with headroom, rounding to a readable ceiling. */
|
||||||
|
export function niceMax(dataMax: number): number {
|
||||||
|
if (!Number.isFinite(dataMax) || dataMax <= 0) return 1;
|
||||||
|
if (dataMax > 10) return Math.ceil(dataMax * 1.05);
|
||||||
|
if (dataMax > 1) return Math.ceil(dataMax * 10.5) / 10;
|
||||||
|
return Math.ceil(dataMax * 105) / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tick format by range: HH:mm up to 2 days, day+time to 2 weeks, then dates. */
|
||||||
|
export function xTick(spanMs: number): (v: number) => string {
|
||||||
|
if (spanMs <= 48 * 3600_000)
|
||||||
|
return (v) => new Date(v).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||||
|
if (spanMs <= 14 * 86400_000)
|
||||||
|
return (v) =>
|
||||||
|
new Date(v).toLocaleString([], { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" });
|
||||||
|
return (v) => new Date(v).toLocaleDateString([], { month: "short", day: "numeric" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullTime = (ms: number) =>
|
||||||
|
new Date(ms).toLocaleString([], {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit",
|
||||||
|
});
|
||||||
|
|
||||||
|
interface TipItem {
|
||||||
|
name?: string;
|
||||||
|
value?: number | string;
|
||||||
|
color?: string;
|
||||||
|
stroke?: string;
|
||||||
|
dataKey?: string | number;
|
||||||
|
payload?: ChartRow;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dotted tooltip: timestamp header, per-series rows sorted by value desc. */
|
||||||
|
function ChartTip({
|
||||||
|
active,
|
||||||
|
payload,
|
||||||
|
fmt,
|
||||||
|
}: {
|
||||||
|
active?: boolean;
|
||||||
|
payload?: TipItem[];
|
||||||
|
fmt: (v: number) => string;
|
||||||
|
}) {
|
||||||
|
if (!active || !payload?.length) return null;
|
||||||
|
const t0 = payload[0]?.payload?.t;
|
||||||
|
const rows = payload
|
||||||
|
.filter((p): p is TipItem & { value: number } => typeof p.value === "number")
|
||||||
|
.sort((a, b) => b.value - a.value);
|
||||||
|
if (!rows.length) return null;
|
||||||
|
return (
|
||||||
|
<div className="min-w-28 rounded-lg border border-border-strong bg-popover px-2.5 py-1.5 text-[11px] shadow-xl">
|
||||||
|
{t0 !== undefined && <div className="mb-1 font-medium text-text-dim">{fullTime(t0)}</div>}
|
||||||
|
<div className="grid gap-1">
|
||||||
|
{rows.map((p) => (
|
||||||
|
<div key={String(p.dataKey ?? p.name)} className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className="size-2 shrink-0 rounded-[2px]"
|
||||||
|
style={{ background: p.color ?? p.stroke ?? "var(--text-faint)" }}
|
||||||
|
/>
|
||||||
|
<span className="flex-1 truncate text-text-faint">{p.name}</span>
|
||||||
|
<span className="font-mono font-medium">{fmt(p.value)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Area/line chart on a time-scaled X axis. Empty data renders a hint, not a blank box. */
|
||||||
|
export function MetricChart({
|
||||||
|
rows,
|
||||||
|
series,
|
||||||
|
fmt,
|
||||||
|
domain,
|
||||||
|
spanMs,
|
||||||
|
}: {
|
||||||
|
rows: ChartRow[];
|
||||||
|
series: Series[];
|
||||||
|
fmt: (v: number) => string;
|
||||||
|
domain?: [number, number] | undefined;
|
||||||
|
spanMs: number;
|
||||||
|
}) {
|
||||||
|
if (!rows.length) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center text-[11px] text-text-faint">
|
||||||
|
{t("chart.noData")}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<ComposedChart data={rows} margin={{ top: 4, right: 8, bottom: 0, left: 0 }}>
|
||||||
|
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" vertical={false} />
|
||||||
|
<XAxis
|
||||||
|
dataKey="t"
|
||||||
|
type="number"
|
||||||
|
scale="time"
|
||||||
|
domain={["dataMin", "dataMax"]}
|
||||||
|
tick={axisTick}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
tickFormatter={xTick(spanMs)}
|
||||||
|
minTickGap={28}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tick={axisTick}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
width="auto"
|
||||||
|
domain={domain ?? [0, niceMax]}
|
||||||
|
tickFormatter={(v) => fmt(Number(v))}
|
||||||
|
/>
|
||||||
|
<Tooltip content={<ChartTip fmt={fmt} />} cursor={{ stroke: "var(--border-strong)" }} />
|
||||||
|
{series.map((s) =>
|
||||||
|
s.line ? (
|
||||||
|
<Line
|
||||||
|
key={s.key}
|
||||||
|
dataKey={s.key}
|
||||||
|
name={s.label}
|
||||||
|
type="monotone"
|
||||||
|
stroke={s.color}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
dot={false}
|
||||||
|
connectNulls
|
||||||
|
isAnimationActive={false}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Area
|
||||||
|
key={s.key}
|
||||||
|
dataKey={s.key}
|
||||||
|
name={s.label}
|
||||||
|
type="monotone"
|
||||||
|
stroke={s.color}
|
||||||
|
fill={s.color}
|
||||||
|
fillOpacity={s.stack ? 0.65 : 0.14}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
dot={false}
|
||||||
|
{...(s.stack !== undefined ? { stackId: s.stack } : {})}
|
||||||
|
isAnimationActive={false}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</ComposedChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Titled chart card in the Beszel layout: title + subtitle on top, legend chips below. */
|
||||||
|
export function ChartCard({
|
||||||
|
title,
|
||||||
|
hint,
|
||||||
|
actions,
|
||||||
|
series,
|
||||||
|
height = 160,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
hint?: string;
|
||||||
|
actions?: ReactNode;
|
||||||
|
series?: Series[];
|
||||||
|
height?: number;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-[10px] border border-border bg-surface p-3 pt-3.5">
|
||||||
|
<div className="mb-2 flex items-start justify-between gap-2 px-1">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-[13px] font-medium tracking-tight">{title}</h3>
|
||||||
|
{hint ? <p className="text-[10.5px] text-text-faint">{hint}</p> : null}
|
||||||
|
</div>
|
||||||
|
{actions}
|
||||||
|
</div>
|
||||||
|
<div style={{ height }}>{children}</div>
|
||||||
|
{series && series.length > 1 && (
|
||||||
|
<div className="mt-2 flex flex-wrap gap-x-3.5 gap-y-1 px-1">
|
||||||
|
{series.map((s) => (
|
||||||
|
<span
|
||||||
|
key={s.key}
|
||||||
|
className="flex items-center gap-1.5 font-mono text-[10px] text-text-faint"
|
||||||
|
>
|
||||||
|
<span className="size-2 rounded-[2px]" style={{ background: s.color }} />
|
||||||
|
{s.label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -109,6 +109,7 @@ export const cs: Partial<Record<Key, string>> = {
|
|||||||
"settings.testNotify": "Odeslat testovací notifikaci",
|
"settings.testNotify": "Odeslat testovací notifikaci",
|
||||||
"settings.wallpaperPh": "https://… — prázdné = žádná",
|
"settings.wallpaperPh": "https://… — prázdné = žádná",
|
||||||
"settings.importFailed": "Import selhal",
|
"settings.importFailed": "Import selhal",
|
||||||
|
"settings.backup": "Záloha a import",
|
||||||
|
|
||||||
"service.editTitle": "Upravit službu",
|
"service.editTitle": "Upravit službu",
|
||||||
"service.newTitle": "Přidat službu",
|
"service.newTitle": "Přidat službu",
|
||||||
@@ -323,10 +324,29 @@ export const cs: Partial<Record<Key, string>> = {
|
|||||||
"systems.addDesc": "Vytvoří bearer token — spusťte s ním dash-agent.",
|
"systems.addDesc": "Vytvoří bearer token — spusťte s ním dash-agent.",
|
||||||
"systems.notFound": "Systém nenalezen.",
|
"systems.notFound": "Systém nenalezen.",
|
||||||
"systems.usage": "Využití",
|
"systems.usage": "Využití",
|
||||||
"systems.cpuPct": "CPU %",
|
"systems.chartCpu": "Využití CPU",
|
||||||
"systems.memPct": "Paměť %",
|
"systems.chartCpuHint": "Průměrné využití CPU celého systému",
|
||||||
"systems.netKbs": "Síť KB/s",
|
"systems.chartMem": "Využití paměti",
|
||||||
"systems.load1m": "Load 1m",
|
"systems.chartMemHint": "Přesné využití v zaznamenaný čas",
|
||||||
|
"systems.chartUsed": "obsazeno",
|
||||||
|
"systems.chartDisk": "Využití disku",
|
||||||
|
"systems.chartDiskHint": "Využití kořenového oddílu",
|
||||||
|
"systems.chartNet": "Šířka pásma",
|
||||||
|
"systems.chartNetHint": "Síťový provoz veřejných rozhraní",
|
||||||
|
"systems.chartRx": "přijato",
|
||||||
|
"systems.chartTx": "odesláno",
|
||||||
|
"systems.chartLoad": "Průměrné zatížení",
|
||||||
|
"systems.chartLoadHint": "Zátěž za 1, 5 a 15 minut",
|
||||||
|
"systems.chartTemp": "Teplota",
|
||||||
|
"systems.chartTempHint": "Systémové senzory",
|
||||||
|
"systems.chartDockerCpu": "CPU Dockeru",
|
||||||
|
"systems.chartDockerCpuHint": "CPU na kontejner, procent jednoho jádra",
|
||||||
|
"systems.chartDockerMem": "Paměť Dockeru",
|
||||||
|
"systems.chartDockerMemHint": "Paměť na kontejner",
|
||||||
|
"systems.chartGpu": "Využití GPU",
|
||||||
|
"systems.chartGpuHint": "Využití na GPU",
|
||||||
|
"systems.chartVram": "VRAM GPU",
|
||||||
|
"systems.chartVramHint": "Paměť na GPU",
|
||||||
"systems.temperatures": "Teploty",
|
"systems.temperatures": "Teploty",
|
||||||
"systems.drivesSmart": "Disky (SMART)",
|
"systems.drivesSmart": "Disky (SMART)",
|
||||||
"systems.agentCmd": "Příkaz agenta",
|
"systems.agentCmd": "Příkaz agenta",
|
||||||
@@ -335,6 +355,8 @@ export const cs: Partial<Record<Key, string>> = {
|
|||||||
"systems.failing": "selhává",
|
"systems.failing": "selhává",
|
||||||
"systems.uptime": "Doba běhu",
|
"systems.uptime": "Doba běhu",
|
||||||
|
|
||||||
|
"chart.noData": "Zatím žádná data.",
|
||||||
|
|
||||||
"incidents.title": "Incidenty",
|
"incidents.title": "Incidenty",
|
||||||
"incidents.subtitle": "Automaticky otevřené při výpadcích monitorů, nebo vedené ručně.",
|
"incidents.subtitle": "Automaticky otevřené při výpadcích monitorů, nebo vedené ručně.",
|
||||||
"incidents.new": "Nový incident",
|
"incidents.new": "Nový incident",
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ export const en = {
|
|||||||
"settings.testNotify": "Send test notification",
|
"settings.testNotify": "Send test notification",
|
||||||
"settings.wallpaperPh": "https://… — blank for none",
|
"settings.wallpaperPh": "https://… — blank for none",
|
||||||
"settings.importFailed": "Import failed",
|
"settings.importFailed": "Import failed",
|
||||||
|
"settings.backup": "Backup & import",
|
||||||
|
|
||||||
"service.editTitle": "Edit service",
|
"service.editTitle": "Edit service",
|
||||||
"service.newTitle": "Add service",
|
"service.newTitle": "Add service",
|
||||||
@@ -322,10 +323,29 @@ export const en = {
|
|||||||
"systems.addDesc": "Creates a bearer token — run dash-agent with it.",
|
"systems.addDesc": "Creates a bearer token — run dash-agent with it.",
|
||||||
"systems.notFound": "System not found.",
|
"systems.notFound": "System not found.",
|
||||||
"systems.usage": "Usage",
|
"systems.usage": "Usage",
|
||||||
"systems.cpuPct": "CPU %",
|
"systems.chartCpu": "CPU Usage",
|
||||||
"systems.memPct": "Memory %",
|
"systems.chartCpuHint": "Average system-wide CPU utilization",
|
||||||
"systems.netKbs": "Network KB/s",
|
"systems.chartMem": "Memory Usage",
|
||||||
"systems.load1m": "Load 1m",
|
"systems.chartMemHint": "Precise utilization at the recorded time",
|
||||||
|
"systems.chartUsed": "used",
|
||||||
|
"systems.chartDisk": "Disk Usage",
|
||||||
|
"systems.chartDiskHint": "Usage of the root partition",
|
||||||
|
"systems.chartNet": "Bandwidth",
|
||||||
|
"systems.chartNetHint": "Network traffic of public interfaces",
|
||||||
|
"systems.chartRx": "received",
|
||||||
|
"systems.chartTx": "sent",
|
||||||
|
"systems.chartLoad": "Load Average",
|
||||||
|
"systems.chartLoadHint": "System load over 1, 5, and 15 minutes",
|
||||||
|
"systems.chartTemp": "Temperature",
|
||||||
|
"systems.chartTempHint": "System sensors",
|
||||||
|
"systems.chartDockerCpu": "Docker CPU Usage",
|
||||||
|
"systems.chartDockerCpuHint": "Per-container CPU, percent of one core",
|
||||||
|
"systems.chartDockerMem": "Docker Memory",
|
||||||
|
"systems.chartDockerMemHint": "Per-container memory usage",
|
||||||
|
"systems.chartGpu": "GPU Usage",
|
||||||
|
"systems.chartGpuHint": "Per-GPU utilization",
|
||||||
|
"systems.chartVram": "GPU VRAM",
|
||||||
|
"systems.chartVramHint": "Per-GPU memory usage",
|
||||||
"systems.temperatures": "Temperatures",
|
"systems.temperatures": "Temperatures",
|
||||||
"systems.drivesSmart": "Drives (SMART)",
|
"systems.drivesSmart": "Drives (SMART)",
|
||||||
"systems.agentCmd": "Agent command",
|
"systems.agentCmd": "Agent command",
|
||||||
@@ -334,6 +354,8 @@ export const en = {
|
|||||||
"systems.failing": "failing",
|
"systems.failing": "failing",
|
||||||
"systems.uptime": "Uptime",
|
"systems.uptime": "Uptime",
|
||||||
|
|
||||||
|
"chart.noData": "No data yet.",
|
||||||
|
|
||||||
"incidents.title": "Incidents",
|
"incidents.title": "Incidents",
|
||||||
"incidents.subtitle": "Auto-opened on monitor outages, or tracked manually.",
|
"incidents.subtitle": "Auto-opened on monitor outages, or tracked manually.",
|
||||||
"incidents.new": "New incident",
|
"incidents.new": "New incident",
|
||||||
|
|||||||
@@ -25,6 +25,16 @@
|
|||||||
--icon-bg: #1c1c1e;
|
--icon-bg: #1c1c1e;
|
||||||
--popover: #141414;
|
--popover: #141414;
|
||||||
|
|
||||||
|
/* chart series palette (monitoring graphs) */
|
||||||
|
--chart-1: #3b82f6;
|
||||||
|
--chart-2: #10b981;
|
||||||
|
--chart-3: #8b5cf6;
|
||||||
|
--chart-4: #f59e0b;
|
||||||
|
--chart-5: #22d3ee;
|
||||||
|
--chart-6: #fb7185;
|
||||||
|
--chart-7: #a3e635;
|
||||||
|
--chart-8: #c084fc;
|
||||||
|
|
||||||
--background: var(--bg);
|
--background: var(--bg);
|
||||||
--foreground: var(--text);
|
--foreground: var(--text);
|
||||||
--card: var(--surface);
|
--card: var(--surface);
|
||||||
@@ -57,6 +67,16 @@
|
|||||||
--icon-bg: #f4f4f5;
|
--icon-bg: #f4f4f5;
|
||||||
--popover: #ffffff;
|
--popover: #ffffff;
|
||||||
|
|
||||||
|
/* chart series palette (monitoring graphs) — darker for light bg */
|
||||||
|
--chart-1: #2563eb;
|
||||||
|
--chart-2: #059669;
|
||||||
|
--chart-3: #7c3aed;
|
||||||
|
--chart-4: #d97706;
|
||||||
|
--chart-5: #0891b2;
|
||||||
|
--chart-6: #e11d48;
|
||||||
|
--chart-7: #65a30d;
|
||||||
|
--chart-8: #9333ea;
|
||||||
|
|
||||||
--background: var(--bg);
|
--background: var(--bg);
|
||||||
--foreground: var(--text);
|
--foreground: var(--text);
|
||||||
--card: var(--surface);
|
--card: var(--surface);
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { Link } from "wouter";
|
import { Link } from "wouter";
|
||||||
import { ArrowLeft, Pause, Play, RefreshCw, Shield, Trash2 } from "lucide-react";
|
import { ArrowLeft, Globe, Pause, Play, RefreshCw, Shield, Trash2 } from "lucide-react";
|
||||||
import { api } from "@/api";
|
import { api } from "@/api";
|
||||||
import type { DomainCheck, DomainView, Subdomain } from "@/types";
|
import type { DNSRecord, DomainCheck, DomainView, Provider, Subdomain } from "@/types";
|
||||||
import { DomainDialog } from "@/components/DomainDialog";
|
import { DomainDialog } from "@/components/DomainDialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -32,6 +32,61 @@ function fmtDate(iso: string | null | undefined) {
|
|||||||
return new Date(iso).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
|
return new Date(iso).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Vendor chip shown next to records and inside the provider strip.
|
||||||
|
function ProviderBadge({ p }: { p: Provider | null | undefined }) {
|
||||||
|
if (!p?.name) return null;
|
||||||
|
return (
|
||||||
|
<span className="inline-flex shrink-0 items-center gap-1.5 rounded-full border border-border/60 bg-bg px-2 py-0.5">
|
||||||
|
{p.icon
|
||||||
|
? <img src={p.icon} alt="" className="size-3 rounded-[3px]" loading="lazy" />
|
||||||
|
: <Globe size={11} className="text-text-faint" />}
|
||||||
|
<span className="whitespace-nowrap text-[10.5px] text-text-dim">{p.name}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProvTile({ label, p }: { label: string; p: Provider | undefined }) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-w-0 items-center gap-2.5 rounded-[9px] border border-border/60 bg-bg px-3 py-2.5">
|
||||||
|
{p?.icon
|
||||||
|
? <img src={p.icon} alt="" className="size-5 rounded-[5px]" loading="lazy" />
|
||||||
|
: <Globe size={17} className="shrink-0 text-text-faint" />}
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="font-mono text-[9px] uppercase tracking-[0.1em] text-text-faint">{label}</div>
|
||||||
|
<div className="truncate text-[12px] font-medium text-text">{p?.name || "—"}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const recTypeColor = {
|
||||||
|
A: "text-[var(--chart-1)]",
|
||||||
|
AAAA: "text-[var(--chart-1)]",
|
||||||
|
CNAME: "text-[var(--chart-4)]",
|
||||||
|
NS: "text-[var(--chart-2)]",
|
||||||
|
MX: "text-[var(--chart-3)]",
|
||||||
|
TXT: "text-[var(--chart-5)]",
|
||||||
|
} satisfies Record<NonNullable<DNSRecord["type"]>, string>;
|
||||||
|
const recOrder = ["A", "AAAA", "CNAME", "NS", "MX", "TXT"];
|
||||||
|
|
||||||
|
function RecordRow({ r }: { r: DNSRecord }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3 border-b border-border/50 px-4 py-2 last:border-0">
|
||||||
|
<span className={cn("w-11 shrink-0 font-mono text-[9.5px] font-semibold uppercase tracking-[0.1em]",
|
||||||
|
(r.type ? recTypeColor[r.type] : undefined) ?? "text-text-faint")}>
|
||||||
|
{r.type}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1 truncate font-mono text-[11px] text-text" title={r.value ?? ""}>
|
||||||
|
{r.value}
|
||||||
|
{r.type === "MX" && r.priority !== null && r.priority !== undefined
|
||||||
|
? <span className="text-text-faint"> (pri {r.priority})</span>
|
||||||
|
: null}
|
||||||
|
</span>
|
||||||
|
<ProviderBadge p={r.provider} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function DomainDetailPage({ id }: { id: string }) {
|
export function DomainDetailPage({ id }: { id: string }) {
|
||||||
const [d, setD] = useState<DomainView | null>(null);
|
const [d, setD] = useState<DomainView | null>(null);
|
||||||
const [checks, setChecks] = useState<DomainCheck[]>([]);
|
const [checks, setChecks] = useState<DomainCheck[]>([]);
|
||||||
@@ -133,13 +188,26 @@ export function DomainDetailPage({ id }: { id: string }) {
|
|||||||
<div className="font-mono text-[10px] text-text-faint">{d.caProvider || "—"}</div>
|
<div className="font-mono text-[10px] text-text-faint">{d.caProvider || "—"}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-[10px] border border-border bg-surface px-4 py-3">
|
<div className="rounded-[10px] border border-border bg-surface px-4 py-3">
|
||||||
<div className="font-mono text-[10px] uppercase tracking-[0.1em] text-text-faint">{t("domains.providers")}</div>
|
<div className="font-mono text-[10px] uppercase tracking-[0.1em] text-text-faint">{t("domains.checked")}</div>
|
||||||
<div className="mt-1 truncate text-[13px] font-medium">{d.dnsProvider || "—"}</div>
|
<div className="mt-1 truncate text-[13px] font-medium">
|
||||||
<div className="font-mono text-[10px] text-text-faint">{d.hostingProvider || d.emailProvider || "—"}</div>
|
{d.lastChecked ? new Date(d.lastChecked).toLocaleDateString() : "—"}
|
||||||
|
</div>
|
||||||
|
<div className="font-mono text-[10px] text-text-faint">every {d.intervalH}h</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3 max-[820px]:grid-cols-1">
|
{/* Vendor strip — registrar, DNS, hosting, email, CA */}
|
||||||
|
<Section title={t("domains.providers")}>
|
||||||
|
<div className="grid grid-cols-5 gap-2 p-2.5 max-[900px]:grid-cols-3 max-[560px]:grid-cols-2">
|
||||||
|
<ProvTile label={t("domains.registrar")} p={d.providers?.registrar} />
|
||||||
|
<ProvTile label={t("domains.dns")} p={d.providers?.dns} />
|
||||||
|
<ProvTile label={t("domains.hosting")} p={d.providers?.hosting} />
|
||||||
|
<ProvTile label={t("domains.email")} p={d.providers?.email} />
|
||||||
|
<ProvTile label={t("domains.tlsCert")} p={d.providers?.ca} />
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<div className="mt-3 grid grid-cols-2 gap-3 max-[820px]:grid-cols-1">
|
||||||
<Section title={t("domains.registration")}>
|
<Section title={t("domains.registration")}>
|
||||||
<Row k={t("domains.created")} v={fmtDate(d.creationDate)} />
|
<Row k={t("domains.created")} v={fmtDate(d.creationDate)} />
|
||||||
<Row k={t("domains.updated")} v={fmtDate(d.updatedDate)} />
|
<Row k={t("domains.updated")} v={fmtDate(d.updatedDate)} />
|
||||||
@@ -153,19 +221,17 @@ export function DomainDetailPage({ id }: { id: string }) {
|
|||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Section title={t("domains.dns")}>
|
<Section title={t("domains.dns")}>
|
||||||
{d.nameServers?.map((n) => <Row key={n} k="NS" v={n} mono />)}
|
{[...(d.records ?? [])]
|
||||||
{d.mxRecords?.map((m) => <Row key={m} k="MX" v={m} mono />)}
|
.sort((a, b) => recOrder.indexOf(a.type ?? "") - recOrder.indexOf(b.type ?? ""))
|
||||||
{d.ipv4?.map((a) => <Row key={a} k="A" v={a} mono />)}
|
.map((r, i) => <RecordRow key={i} r={r} />)}
|
||||||
{d.ipv6?.map((a) => <Row key={a} k="AAAA" v={a} mono />)}
|
{(d.records ?? []).length === 0 ? (
|
||||||
{d.cname ? <Row k="CNAME" v={d.cname} mono /> : null}
|
<p className="px-4 py-4 text-center text-[12px] text-text-faint">—</p>
|
||||||
{d.txtRecords?.slice(0, 4).map((t, i) => <Row key={i} k="TXT" v={t.length > 60 ? t.slice(0, 60) + "…" : t} mono />)}
|
) : null}
|
||||||
{d.dnsProvider ? <Row k={t("domains.dnsProvider")} v={d.dnsProvider} /> : null}
|
|
||||||
{d.emailProvider ? <Row k={t("domains.email")} v={d.emailProvider} /> : null}
|
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Section title={t("domains.tlsCert")}>
|
<Section title={t("domains.tlsCert")}>
|
||||||
<Row k={t("domains.issuer")} v={d.sslIssuer} />
|
<Row k={t("domains.issuer")} v={d.sslIssuer} />
|
||||||
<Row k="CA" v={d.caProvider} />
|
<Row k="CA" v={d.providers?.ca ? <ProviderBadge p={d.providers.ca} /> : d.caProvider} />
|
||||||
<Row k={t("domains.valid")} v={`${fmtDate(d.sslValidFrom)} → ${fmtDate(d.sslValidTo)}`} mono />
|
<Row k={t("domains.valid")} v={`${fmtDate(d.sslValidFrom)} → ${fmtDate(d.sslValidTo)}`} mono />
|
||||||
<Row k={t("domains.subject")} v={d.sslSubject} mono />
|
<Row k={t("domains.subject")} v={d.sslSubject} mono />
|
||||||
<Row k={t("domains.key")} v={d.sslKeySize ? `${d.sslKeySize} bit` : ""} mono />
|
<Row k={t("domains.key")} v={d.sslKeySize ? `${d.sslKeySize} bit` : ""} mono />
|
||||||
@@ -181,7 +247,7 @@ export function DomainDetailPage({ id }: { id: string }) {
|
|||||||
<Row k={t("domains.isp")} v={d.hostIsp} />
|
<Row k={t("domains.isp")} v={d.hostIsp} />
|
||||||
<Row k={t("domains.org")} v={d.hostOrg} />
|
<Row k={t("domains.org")} v={d.hostOrg} />
|
||||||
<Row k="AS" v={d.hostAs} mono />
|
<Row k="AS" v={d.hostAs} mono />
|
||||||
<Row k={t("domains.hosting")} v={d.hostingProvider} />
|
<Row k={t("domains.hosting")} v={d.providers?.hosting ? <ProviderBadge p={d.providers.hosting} /> : d.hostingProvider} />
|
||||||
<Row k={t("domains.checked")} v={d.lastChecked ? new Date(d.lastChecked).toLocaleString() : "—"} mono />
|
<Row k={t("domains.checked")} v={d.lastChecked ? new Date(d.lastChecked).toLocaleString() : "—"} mono />
|
||||||
</Section>
|
</Section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { Link } from "wouter";
|
import { Link } from "wouter";
|
||||||
import { ArrowLeft, Pause, Play, RefreshCw, Trash2 } from "lucide-react";
|
import { ArrowLeft, Pause, Play, RefreshCw, Trash2 } from "lucide-react";
|
||||||
import { Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
|
||||||
import { api } from "@/api";
|
import { api } from "@/api";
|
||||||
|
import { ChartCard, MetricChart, chartColor, type ChartRow } from "@/components/chart";
|
||||||
import type { Heartbeat, MonitorView } from "@/types";
|
import type { Heartbeat, MonitorView } from "@/types";
|
||||||
import { MonitorDialog } from "@/components/MonitorDialog";
|
import { MonitorDialog } from "@/components/MonitorDialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -32,10 +32,9 @@ export function MonitorDetailPage({ id }: { id: string }) {
|
|||||||
|
|
||||||
if (!m) return <main className="mx-auto max-w-5xl px-7 py-8 text-[13px] text-text-faint">{t("common.loading")}</main>;
|
if (!m) return <main className="mx-auto max-w-5xl px-7 py-8 text-[13px] text-text-faint">{t("common.loading")}</main>;
|
||||||
|
|
||||||
const chart = hbs.map((h) => ({
|
const rows: ChartRow[] = hbs.map((h) => ({
|
||||||
t: new Date(h.checkedAt ?? "").toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }),
|
t: new Date(h.checkedAt ?? "").getTime(),
|
||||||
ping: h.pingMs,
|
ping: h.pingMs,
|
||||||
up: h.status === "up",
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -94,8 +93,12 @@ export function MonitorDetailPage({ id }: { id: string }) {
|
|||||||
<UptimeBar hbs={hbs} hours={hours} />
|
<UptimeBar hbs={hbs} hours={hours} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mb-2 flex items-center justify-between">
|
<div className="mb-6">
|
||||||
<h2 className="font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">{t("monitors.responseTime")}</h2>
|
<ChartCard
|
||||||
|
title={t("monitors.responseTime")}
|
||||||
|
height={208}
|
||||||
|
series={[{ key: "ping", label: "ms", color: chartColor(0) }]}
|
||||||
|
actions={
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
{[24, 168, 720].map((h) => (
|
{[24, 168, 720].map((h) => (
|
||||||
<button key={h} type="button" onClick={() => setHours(h)}
|
<button key={h} type="button" onClick={() => setHours(h)}
|
||||||
@@ -105,20 +108,15 @@ export function MonitorDetailPage({ id }: { id: string }) {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
}
|
||||||
<div className="mb-6 h-56 rounded-[10px] border border-border bg-surface p-3">
|
>
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<MetricChart
|
||||||
<AreaChart data={chart} margin={{ top: 4, right: 4, bottom: 0, left: -18 }}>
|
rows={rows}
|
||||||
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" vertical={false} />
|
series={[{ key: "ping", label: "ms", color: chartColor(0) }]}
|
||||||
<XAxis dataKey="t" tick={{ fontSize: 10, fill: "var(--text-faint)" }} tickLine={false} axisLine={false} minTickGap={40} />
|
fmt={(v) => `${Math.round(v)} ms`}
|
||||||
<YAxis tick={{ fontSize: 10, fill: "var(--text-faint)" }} tickLine={false} axisLine={false} unit=" ms" width={62} />
|
spanMs={hours * 3600_000}
|
||||||
<Tooltip
|
|
||||||
contentStyle={{ background: "var(--surface)", border: "1px solid var(--border)", borderRadius: 8, fontSize: 11 }}
|
|
||||||
labelStyle={{ color: "var(--text-faint)" }}
|
|
||||||
/>
|
/>
|
||||||
<Area type="monotone" dataKey="ping" stroke="var(--up)" fill="var(--up)" fillOpacity={0.12} strokeWidth={1.5} dot={false} isAnimationActive={false} />
|
</ChartCard>
|
||||||
</AreaChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2 className="mb-2 font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">{t("monitors.recentChecks")}</h2>
|
<h2 className="mb-2 font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">{t("monitors.recentChecks")}</h2>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { Link } from "wouter";
|
import { Link } from "wouter";
|
||||||
import { ArrowLeft, Copy, Trash2 } from "lucide-react";
|
import { ArrowLeft, Copy, Trash2 } from "lucide-react";
|
||||||
import { Area, AreaChart, CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
|
||||||
import { api } from "@/api";
|
import { api } from "@/api";
|
||||||
|
import { ChartCard, MetricChart, chartColor, type ChartRow, type Series } from "@/components/chart";
|
||||||
import type { System, SystemStat } from "@/types";
|
import type { System, SystemStat } from "@/types";
|
||||||
import { agentCmd, fmtBytes, fmtUptime } from "@/pages/SystemsPage";
|
import { agentCmd, fmtBytes, fmtUptime } from "@/pages/SystemsPage";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -40,16 +40,73 @@ export function SystemDetailPage({ id }: { id: string }) {
|
|||||||
if (!sys) return <main className="mx-auto max-w-5xl px-7 py-8 text-[13px] text-text-faint">{t("common.loading")}</main>;
|
if (!sys) return <main className="mx-auto max-w-5xl px-7 py-8 text-[13px] text-text-faint">{t("common.loading")}</main>;
|
||||||
|
|
||||||
const l = sys.latest;
|
const l = sys.latest;
|
||||||
const chart = stats.map((s) => ({
|
const spanMs = hours * 3600_000;
|
||||||
t: new Date(s.ts ?? "").toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }),
|
|
||||||
|
// Rows carry fixed keys plus dynamic `c:`/`m:` (containers), `s:` (sensors),
|
||||||
|
// `g:`/`v:` (GPU util/VRAM) keys; series pick them up by name.
|
||||||
|
const rows: ChartRow[] = stats.map((s) => {
|
||||||
|
const r: ChartRow = {
|
||||||
|
t: new Date(s.ts ?? "").getTime(),
|
||||||
cpu: s.cpu,
|
cpu: s.cpu,
|
||||||
memPct: s.memTotal ? ((s.memUsed ?? 0) / s.memTotal) * 100 : undefined,
|
mem: s.memUsed,
|
||||||
rx: (s.netRx ?? 0) / 1024,
|
swap: s.swapUsed,
|
||||||
tx: (s.netTx ?? 0) / 1024,
|
disk: s.diskUsed,
|
||||||
load: s.load1,
|
rx: s.netRx,
|
||||||
}));
|
tx: s.netTx,
|
||||||
const axisTick = { fontSize: 10, fill: "var(--text-faint)" };
|
l1: s.load1,
|
||||||
const tipStyle = { background: "var(--surface)", border: "1px solid var(--border)", borderRadius: 8, fontSize: 11 };
|
l5: s.load5,
|
||||||
|
l15: s.load15,
|
||||||
|
};
|
||||||
|
for (const c of s.containers ?? []) {
|
||||||
|
if (c.name) {
|
||||||
|
r[`c:${c.name}`] = c.cpu ?? 0;
|
||||||
|
r[`m:${c.name}`] = c.memUsed ?? 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [k, v] of Object.entries(s.temps ?? {})) r[`s:${k}`] = v;
|
||||||
|
for (const g of s.gpu ?? []) {
|
||||||
|
if (g.name) {
|
||||||
|
r[`g:${g.name}`] = g.utilPct;
|
||||||
|
r[`v:${g.name}`] = g.memUsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return r;
|
||||||
|
});
|
||||||
|
|
||||||
|
const names = (ns: (string | undefined)[]) =>
|
||||||
|
[...new Set(ns.filter((n): n is string => n !== undefined))];
|
||||||
|
const containerNames = names(stats.flatMap((s) => (s.containers ?? []).map((c) => c.name)));
|
||||||
|
const tempNames = names(stats.flatMap((s) => Object.keys(s.temps ?? {})));
|
||||||
|
const gpuNames = names(stats.flatMap((s) => (s.gpu ?? []).map((g) => g.name)));
|
||||||
|
// Zero-fill absent containers so stacked areas stay flush over restarts.
|
||||||
|
for (const r of rows) {
|
||||||
|
for (const n of containerNames) {
|
||||||
|
r[`c:${n}`] ??= 0;
|
||||||
|
r[`m:${n}`] ??= 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pct = (v: number) => `${v.toFixed(1)}%`;
|
||||||
|
const cpuSeries: Series[] = [{ key: "cpu", label: "cpu", color: chartColor(0) }];
|
||||||
|
const dockerCpuSeries: Series[] = containerNames.map((n, i) => ({ key: `c:${n}`, label: n, color: chartColor(i), stack: "a" }));
|
||||||
|
const memSeries: Series[] = [
|
||||||
|
{ key: "mem", label: t("systems.chartUsed"), color: chartColor(1) },
|
||||||
|
{ key: "swap", label: "swap", color: chartColor(3) },
|
||||||
|
];
|
||||||
|
const dockerMemSeries: Series[] = containerNames.map((n, i) => ({ key: `m:${n}`, label: n, color: chartColor(i), stack: "a" }));
|
||||||
|
const diskSeries: Series[] = [{ key: "disk", label: t("systems.chartUsed"), color: chartColor(2) }];
|
||||||
|
const netSeries: Series[] = [
|
||||||
|
{ key: "rx", label: t("systems.chartRx"), color: chartColor(4) },
|
||||||
|
{ key: "tx", label: t("systems.chartTx"), color: chartColor(5) },
|
||||||
|
];
|
||||||
|
const loadSeries: Series[] = [
|
||||||
|
{ key: "l1", label: "1 min", color: chartColor(0) },
|
||||||
|
{ key: "l5", label: "5 min", color: chartColor(3) },
|
||||||
|
{ key: "l15", label: "15 min", color: chartColor(5) },
|
||||||
|
];
|
||||||
|
const tempSeries: Series[] = tempNames.map((n, i) => ({ key: `s:${n}`, label: n, color: chartColor(i), line: true }));
|
||||||
|
const gpuSeries: Series[] = gpuNames.map((n, i) => ({ key: `g:${n}`, label: n, color: chartColor(i) }));
|
||||||
|
const vramSeries: Series[] = gpuNames.map((n, i) => ({ key: `v:${n}`, label: n, color: chartColor(i) }));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="mx-auto w-full max-w-5xl px-7 py-8">
|
<main className="mx-auto w-full max-w-5xl px-7 py-8">
|
||||||
@@ -111,55 +168,46 @@ export function SystemDetailPage({ id }: { id: string }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mb-6 grid grid-cols-2 gap-3 max-[720px]:grid-cols-1">
|
<div className="mb-6 grid grid-cols-2 gap-3 max-[720px]:grid-cols-1">
|
||||||
<div className="h-44 rounded-[10px] border border-border bg-surface p-3">
|
<ChartCard title={t("systems.chartCpu")} hint={t("systems.chartCpuHint")}>
|
||||||
<div className="mb-1 font-mono text-[9.5px] uppercase tracking-[0.1em] text-text-faint">{t("systems.cpuPct")}</div>
|
<MetricChart rows={rows} series={cpuSeries} fmt={pct} domain={[0, 100]} spanMs={spanMs} />
|
||||||
<ResponsiveContainer width="100%" height="85%">
|
</ChartCard>
|
||||||
<AreaChart data={chart} margin={{ top: 4, right: 4, bottom: 0, left: -22 }}>
|
{dockerCpuSeries.length > 0 && (
|
||||||
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" vertical={false} />
|
<ChartCard title={t("systems.chartDockerCpu")} hint={t("systems.chartDockerCpuHint")} series={dockerCpuSeries}>
|
||||||
<XAxis dataKey="t" tick={axisTick} tickLine={false} axisLine={false} minTickGap={40} />
|
<MetricChart rows={rows} series={dockerCpuSeries} fmt={pct} spanMs={spanMs} />
|
||||||
<YAxis tick={axisTick} tickLine={false} axisLine={false} domain={[0, 100]} unit="%" width={48} />
|
</ChartCard>
|
||||||
<Tooltip contentStyle={tipStyle} labelStyle={{ color: "var(--text-faint)" }} />
|
)}
|
||||||
<Area type="monotone" dataKey="cpu" stroke="var(--up)" fill="var(--up)" fillOpacity={0.12} strokeWidth={1.5} dot={false} isAnimationActive={false} />
|
<ChartCard title={t("systems.chartMem")} hint={t("systems.chartMemHint")} series={memSeries}>
|
||||||
</AreaChart>
|
<MetricChart rows={rows} series={memSeries} fmt={fmtBytes} spanMs={spanMs} />
|
||||||
</ResponsiveContainer>
|
</ChartCard>
|
||||||
</div>
|
{dockerMemSeries.length > 0 && (
|
||||||
<div className="h-44 rounded-[10px] border border-border bg-surface p-3">
|
<ChartCard title={t("systems.chartDockerMem")} hint={t("systems.chartDockerMemHint")} series={dockerMemSeries}>
|
||||||
<div className="mb-1 font-mono text-[9.5px] uppercase tracking-[0.1em] text-text-faint">{t("systems.memPct")}</div>
|
<MetricChart rows={rows} series={dockerMemSeries} fmt={fmtBytes} spanMs={spanMs} />
|
||||||
<ResponsiveContainer width="100%" height="85%">
|
</ChartCard>
|
||||||
<AreaChart data={chart} margin={{ top: 4, right: 4, bottom: 0, left: -22 }}>
|
)}
|
||||||
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" vertical={false} />
|
<ChartCard title={t("systems.chartDisk")} hint={t("systems.chartDiskHint")}>
|
||||||
<XAxis dataKey="t" tick={axisTick} tickLine={false} axisLine={false} minTickGap={40} />
|
<MetricChart rows={rows} series={diskSeries} fmt={fmtBytes} domain={l?.diskTotal ? [0, l.diskTotal] : undefined} spanMs={spanMs} />
|
||||||
<YAxis tick={axisTick} tickLine={false} axisLine={false} domain={[0, 100]} unit="%" width={48} />
|
</ChartCard>
|
||||||
<Tooltip contentStyle={tipStyle} labelStyle={{ color: "var(--text-faint)" }} />
|
<ChartCard title={t("systems.chartNet")} hint={t("systems.chartNetHint")} series={netSeries}>
|
||||||
<Area type="monotone" dataKey="memPct" stroke="var(--up)" fill="var(--up)" fillOpacity={0.12} strokeWidth={1.5} dot={false} isAnimationActive={false} />
|
<MetricChart rows={rows} series={netSeries} fmt={(v) => `${fmtBytes(v)}/s`} spanMs={spanMs} />
|
||||||
</AreaChart>
|
</ChartCard>
|
||||||
</ResponsiveContainer>
|
<ChartCard title={t("systems.chartLoad")} hint={t("systems.chartLoadHint")} series={loadSeries}>
|
||||||
</div>
|
<MetricChart rows={rows} series={loadSeries} fmt={(v) => v.toFixed(2)} spanMs={spanMs} />
|
||||||
<div className="h-44 rounded-[10px] border border-border bg-surface p-3">
|
</ChartCard>
|
||||||
<div className="mb-1 font-mono text-[9.5px] uppercase tracking-[0.1em] text-text-faint">{t("systems.netKbs")}</div>
|
{tempSeries.length > 0 && (
|
||||||
<ResponsiveContainer width="100%" height="85%">
|
<ChartCard title={t("systems.chartTemp")} hint={t("systems.chartTempHint")} series={tempSeries}>
|
||||||
<LineChart data={chart} margin={{ top: 4, right: 4, bottom: 0, left: -18 }}>
|
<MetricChart rows={rows} series={tempSeries} fmt={(v) => `${v.toFixed(0)}°C`} spanMs={spanMs} />
|
||||||
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" vertical={false} />
|
</ChartCard>
|
||||||
<XAxis dataKey="t" tick={axisTick} tickLine={false} axisLine={false} minTickGap={40} />
|
)}
|
||||||
<YAxis tick={axisTick} tickLine={false} axisLine={false} width={58} />
|
{gpuSeries.length > 0 && (
|
||||||
<Tooltip contentStyle={tipStyle} labelStyle={{ color: "var(--text-faint)" }} />
|
<ChartCard title={t("systems.chartGpu")} hint={t("systems.chartGpuHint")} series={gpuSeries}>
|
||||||
<Line type="monotone" dataKey="rx" name="rx" stroke="var(--up)" strokeWidth={1.5} dot={false} isAnimationActive={false} />
|
<MetricChart rows={rows} series={gpuSeries} fmt={pct} domain={[0, 100]} spanMs={spanMs} />
|
||||||
<Line type="monotone" dataKey="tx" name="tx" stroke="var(--text-faint)" strokeWidth={1.5} dot={false} isAnimationActive={false} />
|
</ChartCard>
|
||||||
</LineChart>
|
)}
|
||||||
</ResponsiveContainer>
|
{vramSeries.length > 0 && (
|
||||||
</div>
|
<ChartCard title={t("systems.chartVram")} hint={t("systems.chartVramHint")} series={vramSeries}>
|
||||||
<div className="h-44 rounded-[10px] border border-border bg-surface p-3">
|
<MetricChart rows={rows} series={vramSeries} fmt={fmtBytes} spanMs={spanMs} />
|
||||||
<div className="mb-1 font-mono text-[9.5px] uppercase tracking-[0.1em] text-text-faint">{t("systems.load1m")}</div>
|
</ChartCard>
|
||||||
<ResponsiveContainer width="100%" height="85%">
|
)}
|
||||||
<AreaChart data={chart} margin={{ top: 4, right: 4, bottom: 0, left: -22 }}>
|
|
||||||
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" vertical={false} />
|
|
||||||
<XAxis dataKey="t" tick={axisTick} tickLine={false} axisLine={false} minTickGap={40} />
|
|
||||||
<YAxis tick={axisTick} tickLine={false} axisLine={false} width={48} />
|
|
||||||
<Tooltip contentStyle={tipStyle} labelStyle={{ color: "var(--text-faint)" }} />
|
|
||||||
<Area type="monotone" dataKey="load" stroke="var(--up)" fill="var(--up)" fillOpacity={0.12} strokeWidth={1.5} dot={false} isAnimationActive={false} />
|
|
||||||
</AreaChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3 max-[720px]:grid-cols-1">
|
<div className="grid grid-cols-2 gap-3 max-[720px]:grid-cols-1">
|
||||||
|
|||||||
@@ -15,11 +15,14 @@ import { t } from "@/i18n";
|
|||||||
const TB = 2 ** 40;
|
const TB = 2 ** 40;
|
||||||
const GB = 2 ** 30;
|
const GB = 2 ** 30;
|
||||||
const MB = 2 ** 20;
|
const MB = 2 ** 20;
|
||||||
|
const KB = 2 ** 10;
|
||||||
|
|
||||||
export function fmtBytes(b: number) {
|
export function fmtBytes(b: number) {
|
||||||
if (b >= TB) return `${(b / TB).toFixed(1)} TB`;
|
if (b >= TB) return `${(b / TB).toFixed(1)} TB`;
|
||||||
if (b >= GB) return `${(b / GB).toFixed(1)} GB`;
|
if (b >= GB) return `${(b / GB).toFixed(1)} GB`;
|
||||||
return `${(b / MB).toFixed(0)} MB`;
|
if (b >= MB) return `${(b / MB).toFixed(1)} MB`;
|
||||||
|
if (b >= KB) return `${(b / KB).toFixed(0)} KB`;
|
||||||
|
return `${Math.round(b)} B`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fmtUptime(s: number) {
|
export function fmtUptime(s: number) {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { LayoutGrid, LayoutList, Newspaper, SquareTerminal, type LucideIcon } from "lucide-react";
|
||||||
import { bento } from "./bento";
|
import { bento } from "./bento";
|
||||||
import { cards } from "./cards";
|
import { cards } from "./cards";
|
||||||
import { editorial } from "./editorial";
|
import { editorial } from "./editorial";
|
||||||
@@ -27,3 +28,10 @@ export const rendererDescriptions: Record<RendererName, string> = {
|
|||||||
index: "editorial list · masthead",
|
index: "editorial list · masthead",
|
||||||
console: "terminal panels · mono",
|
console: "terminal panels · mono",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const rendererIcons: Record<RendererName, LucideIcon> = {
|
||||||
|
bento: LayoutGrid,
|
||||||
|
cards: LayoutList,
|
||||||
|
index: Newspaper,
|
||||||
|
console: SquareTerminal,
|
||||||
|
};
|
||||||
@@ -20,6 +20,9 @@ export type Heartbeat = components["schemas"]["Heartbeat"];
|
|||||||
export type Domain = components["schemas"]["Domain"];
|
export type Domain = components["schemas"]["Domain"];
|
||||||
export type DomainInput = components["schemas"]["DomainInput"];
|
export type DomainInput = components["schemas"]["DomainInput"];
|
||||||
export type DomainView = components["schemas"]["DomainView"];
|
export type DomainView = components["schemas"]["DomainView"];
|
||||||
|
export type DNSRecord = components["schemas"]["DNSRecord"];
|
||||||
|
export type Provider = components["schemas"]["Provider"];
|
||||||
|
export type Providers = components["schemas"]["Providers"];
|
||||||
export type DomainCheck = components["schemas"]["DomainCheck"];
|
export type DomainCheck = components["schemas"]["DomainCheck"];
|
||||||
export type Subdomain = components["schemas"]["Subdomain"];
|
export type Subdomain = components["schemas"]["Subdomain"];
|
||||||
export type System = components["schemas"]["System"];
|
export type System = components["schemas"]["System"];
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||||
|
<rect width="512" height="512" rx="104" fill="#0a0a0a"/>
|
||||||
|
<g fill="#3b82f6">
|
||||||
|
<rect x="120" y="120" width="124" height="124" rx="28"/>
|
||||||
|
<rect x="268" y="120" width="124" height="124" rx="28" fill="#6b9ef8"/>
|
||||||
|
<rect x="120" y="268" width="124" height="124" rx="28" fill="#1d4fd7"/>
|
||||||
|
<rect x="268" y="268" width="124" height="124" rx="28"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 431 B |
@@ -1,5 +1,8 @@
|
|||||||
services:
|
services:
|
||||||
dash:
|
dash:
|
||||||
|
# Prebuilt release image — `docker compose pull && docker compose up -d`.
|
||||||
|
# To build from source instead: `docker compose up -d --build`.
|
||||||
|
image: ghcr.io/dvorinka/dash:latest
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: infra/Dockerfile
|
dockerfile: infra/Dockerfile
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
# v1.0.0 launch post — draft
|
# v2.0.0 launch post — draft
|
||||||
|
|
||||||
For r/selfhosted, HN Show, and similar. Edit freely before posting.
|
For r/selfhosted, HN Show, and similar. Edit freely before posting.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Title:** Dash — a self-hosted homelab dashboard with monitoring built in.
|
**Title:** Dash — a self-hosted homelab dashboard with monitoring built in.
|
||||||
Single Go binary, no YAML, no auth.
|
Single Go binary, no YAML, optional auth.
|
||||||
|
|
||||||
**Body:**
|
**Body:**
|
||||||
|
|
||||||
@@ -19,13 +19,17 @@ What it does:
|
|||||||
upload. Imports your existing Homepage (`services.yml`), Homarr, or Dashy
|
upload. Imports your existing Homepage (`services.yml`), Homarr, or Dashy
|
||||||
config in one click.
|
config in one click.
|
||||||
- **Monitors** — HTTP/TCP/ping/DNS/keyword/JSON-path checks plus push monitors
|
- **Monitors** — HTTP/TCP/ping/DNS/keyword/JSON-path checks plus push monitors
|
||||||
for cron jobs. Webhook alerts (Slack/Discord/ntfy). Uptime history.
|
for cron jobs. Uptime graphs, per-monitor alert rules, and notifications via
|
||||||
|
webhook, Slack, Discord, Telegram, Gotify, ntfy, or SMTP.
|
||||||
- **Domains** — WHOIS/RDAP, DNS, TLS certificate expiry, provider detection.
|
- **Domains** — WHOIS/RDAP, DNS, TLS certificate expiry, provider detection.
|
||||||
Inspired by Domain Locker, minus the paid APIs.
|
Inspired by Domain Locker, minus the paid APIs.
|
||||||
- **Systems** — a ~4 MB static agent pushes CPU/mem/disk/net/temps/Docker
|
- **Systems** — a ~4 MB static agent pushes CPU/mem/disk/net/temps, per-
|
||||||
container states. Inspired by Beszel, push-JSON instead of SSH.
|
container stats, and SMART/ZFS/GPU when present. Inspired by Beszel,
|
||||||
|
push-JSON instead of SSH.
|
||||||
- **Ops** — incidents, maintenance windows, public status pages, SVG badges,
|
- **Ops** — incidents, maintenance windows, public status pages, SVG badges,
|
||||||
Prometheus `/api/metrics`.
|
Prometheus `/api/metrics`.
|
||||||
|
- **Extras** — multiple boards, iframe/JSON-path widgets, CT-log subdomain
|
||||||
|
discovery, EN/CZ locales, installable PWA, custom CSS/wallpaper.
|
||||||
|
|
||||||
Deployment is one container:
|
Deployment is one container:
|
||||||
|
|
||||||
@@ -33,8 +37,9 @@ Deployment is one container:
|
|||||||
docker run -d -v ./data:/data -p 3000:3000 ghcr.io/dvorinka/dash:latest
|
docker run -d -v ./data:/data -p 3000:3000 ghcr.io/dvorinka/dash:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
SQLite inside, no database to babysit, no accounts. Deliberately no auth —
|
SQLite inside, no database to babysit. Auth is opt-in: local
|
||||||
it expects a trusted LAN or your own proxy in front.
|
username/password with bcrypt + session cookies, off by default; public
|
||||||
|
status pages stay open either way.
|
||||||
|
|
||||||
Stack: Go + Gin + SQLite (single static binary serving an embedded React UI).
|
Stack: Go + Gin + SQLite (single static binary serving an embedded React UI).
|
||||||
MIT licensed.
|
MIT licensed.
|
||||||
@@ -53,5 +58,5 @@ Show HN: Dash – self-hosted homelab dashboard with monitoring, one Go binary
|
|||||||
|
|
||||||
Dash combines a service board, uptime monitors, domain/SSL watching, system
|
Dash combines a service board, uptime monitors, domain/SSL watching, system
|
||||||
metrics via a tiny push agent, and public status pages. Single container or
|
metrics via a tiny push agent, and public status pages. Single container or
|
||||||
single binary, SQLite storage, no YAML, no auth (LAN-trust model). MIT.
|
single binary, SQLite storage, no YAML. Optional local auth. MIT.
|
||||||
https://github.com/Dvorinka/Dash
|
https://github.com/Dvorinka/Dash
|
||||||
@@ -21,12 +21,16 @@ snapshot; `/api/import/csv` bulk-loads monitors and domains from CSV.
|
|||||||
|
|
||||||
## Notifications
|
## Notifications
|
||||||
|
|
||||||
**Settings → Notifications** holds one generic webhook URL
|
**Settings → Notifications** configures any number of transports — generic
|
||||||
(`notify_webhook`). On monitor up/down transitions and domain events, Dash
|
webhook, Slack, Discord, Telegram, Gotify, ntfy, and SMTP — each stored as a
|
||||||
POSTs a JSON payload with `event`, `name`, `text`, `content`, `message`, and
|
`notify_<name>` settings object. Monitor up/down transitions and domain
|
||||||
`at` fields — shaped so Slack, Discord, and ntfy all render it sensibly.
|
events fan out to every configured transport; the payload carries `event`,
|
||||||
**Test** sends a `test` event. Delivery is async and non-fatal; failures are
|
`name`, `text`, `content`, `message`, and `at` fields. **Test** sends a `test`
|
||||||
logged.
|
event. Delivery is async and non-fatal; failures are logged.
|
||||||
|
|
||||||
|
Per-monitor alert rules (consecutive failures before down, latency-warn ms,
|
||||||
|
mute) live on each monitor; domains get a cert-days threshold and mute.
|
||||||
|
Maintenance windows still take precedence.
|
||||||
|
|
||||||
## Monitors
|
## Monitors
|
||||||
|
|
||||||
@@ -47,6 +51,27 @@ Bearer-token push ingest at `POST /api/systems/ingest`. A system is marked
|
|||||||
offline when no sample arrives within a few intervals. Stats are pruned during
|
offline when no sample arrives within a few intervals. Stats are pruned during
|
||||||
the hourly sweep.
|
the hourly sweep.
|
||||||
|
|
||||||
|
## Auth
|
||||||
|
|
||||||
|
Off by default — existing installs see no gate. **Settings → Authentication →
|
||||||
|
Enable** flips to a first-user setup screen (username/email + password,
|
||||||
|
bcrypt). Sessions are SQLite-backed 7-day HttpOnly cookies with rotation;
|
||||||
|
login is rate-limited per IP. Disable only works from an authenticated
|
||||||
|
session. Public status pages, badges, `/api/metrics`, `/api/push/*`, and
|
||||||
|
agent ingest stay open under auth.
|
||||||
|
|
||||||
|
## Boards
|
||||||
|
|
||||||
|
The header switcher manages multiple boards — each gets a slug and a
|
||||||
|
`/b/:slug` route with its own sections/items. Monitors, domains, systems, and
|
||||||
|
status pages stay global. Exports carry all boards.
|
||||||
|
|
||||||
|
## Appearance & language
|
||||||
|
|
||||||
|
Theme (dark/light), accent color, wallpaper URL, and custom CSS in Settings;
|
||||||
|
the language picker switches the UI between English and Czech. A PWA manifest
|
||||||
|
and service worker make the app installable.
|
||||||
|
|
||||||
## Operations
|
## Operations
|
||||||
|
|
||||||
- **Incidents** — open/ack/resolve/close, with per-incident update timeline.
|
- **Incidents** — open/ack/resolve/close, with per-incident update timeline.
|
||||||
|
|||||||
@@ -3,7 +3,16 @@
|
|||||||
Dash ships as a single container image or a single static binary. All state
|
Dash ships as a single container image or a single static binary. All state
|
||||||
lives in one directory: a SQLite database and uploaded icons.
|
lives in one directory: a SQLite database and uploaded icons.
|
||||||
|
|
||||||
## Docker (recommended)
|
## One-line install
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -fsSL https://raw.githubusercontent.com/Dvorinka/Dash/main/install.sh | sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Creates `./dash`, pulls the image, and starts on port 3000. Override with
|
||||||
|
`DASH_DIR` / `DASH_PORT` environment variables.
|
||||||
|
|
||||||
|
## Docker (manual)
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
docker run -d --name dash \
|
docker run -d --name dash \
|
||||||
|
|||||||
|
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
|
type: string
|
||||||
caProvider:
|
caProvider:
|
||||||
type: string
|
type: string
|
||||||
|
records:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/DNSRecord"
|
||||||
|
providers:
|
||||||
|
$ref: "#/components/schemas/Providers"
|
||||||
headers:
|
headers:
|
||||||
type: object
|
type: object
|
||||||
additionalProperties:
|
additionalProperties:
|
||||||
@@ -1807,6 +1813,51 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
alerts:
|
alerts:
|
||||||
$ref: "#/components/schemas/AlertRules"
|
$ref: "#/components/schemas/AlertRules"
|
||||||
|
Provider:
|
||||||
|
type: object
|
||||||
|
description: Detected vendor behind a record or role
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
domain:
|
||||||
|
type: string
|
||||||
|
description: brand domain the favicon icon was derived from
|
||||||
|
icon:
|
||||||
|
type: string
|
||||||
|
Providers:
|
||||||
|
type: object
|
||||||
|
description: Detected vendor strip — registrar, DNS, hosting, email, CA
|
||||||
|
properties:
|
||||||
|
registrar:
|
||||||
|
$ref: "#/components/schemas/Provider"
|
||||||
|
nullable: true
|
||||||
|
dns:
|
||||||
|
$ref: "#/components/schemas/Provider"
|
||||||
|
nullable: true
|
||||||
|
email:
|
||||||
|
$ref: "#/components/schemas/Provider"
|
||||||
|
nullable: true
|
||||||
|
hosting:
|
||||||
|
$ref: "#/components/schemas/Provider"
|
||||||
|
nullable: true
|
||||||
|
ca:
|
||||||
|
$ref: "#/components/schemas/Provider"
|
||||||
|
nullable: true
|
||||||
|
DNSRecord:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
type:
|
||||||
|
type: string
|
||||||
|
enum: [A, AAAA, CNAME, NS, MX, TXT]
|
||||||
|
value:
|
||||||
|
type: string
|
||||||
|
priority:
|
||||||
|
type: integer
|
||||||
|
nullable: true
|
||||||
|
description: MX preference
|
||||||
|
provider:
|
||||||
|
$ref: "#/components/schemas/Provider"
|
||||||
|
nullable: true
|
||||||
DomainView:
|
DomainView:
|
||||||
allOf:
|
allOf:
|
||||||
- $ref: "#/components/schemas/Domain"
|
- $ref: "#/components/schemas/Domain"
|
||||||
|
|||||||
@@ -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"
|
||||||