mirror of
https://github.com/Dvorinka/Dash.git
synced 2026-10-28 07:41:27 +00:00
Merge pull request #2 from Dvorinka/feat/monitoring-merge
feat: monitoring merge — importers, monitors, domains, systems, ops, launch prep
This commit is contained in:
77 files changed
+10309
-123
No files matched your search
@@ -0,0 +1,31 @@
|
|||||||
|
name: Bug report
|
||||||
|
description: Something is broken
|
||||||
|
labels: [bug]
|
||||||
|
body:
|
||||||
|
- type: textarea
|
||||||
|
id: what
|
||||||
|
attributes:
|
||||||
|
label: What happened
|
||||||
|
description: What you did, what you expected, what happened instead.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: logs
|
||||||
|
attributes:
|
||||||
|
label: Logs
|
||||||
|
description: Relevant server logs or browser console output.
|
||||||
|
render: text
|
||||||
|
- type: input
|
||||||
|
id: version
|
||||||
|
attributes:
|
||||||
|
label: Dash version
|
||||||
|
description: Image tag or release version.
|
||||||
|
placeholder: v0.7.0
|
||||||
|
- type: dropdown
|
||||||
|
id: install
|
||||||
|
attributes:
|
||||||
|
label: Install method
|
||||||
|
options:
|
||||||
|
- Docker
|
||||||
|
- Binary
|
||||||
|
- Built from source
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
name: Feature request
|
||||||
|
description: Suggest an addition
|
||||||
|
labels: [enhancement]
|
||||||
|
body:
|
||||||
|
- type: textarea
|
||||||
|
id: problem
|
||||||
|
attributes:
|
||||||
|
label: What problem does this solve
|
||||||
|
description: Concrete use case, not just "add X".
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: idea
|
||||||
|
attributes:
|
||||||
|
label: Proposed shape
|
||||||
|
description: Optional. How it might look or work.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
## Summary
|
||||||
|
|
||||||
|
<!-- What does this change and why? -->
|
||||||
|
|
||||||
|
## Test plan
|
||||||
|
|
||||||
|
- [ ] `npm run typecheck` + `npm run lint` + `npm run build` green
|
||||||
|
- [ ] `go build ./...` + `go vet ./...` + `go test ./...` green
|
||||||
|
- [ ] Smoke-tested the affected flow manually
|
||||||
|
|
||||||
|
<!-- Screenshots for UI changes. -->
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
name: release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags: ["v*"]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
binaries:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
cache: npm
|
||||||
|
- run: npm ci
|
||||||
|
- run: npm run build
|
||||||
|
|
||||||
|
# go:embed all:dist reads internal/web/dist at compile time.
|
||||||
|
- run: rm -rf apps/backend/internal/web/dist && cp -r apps/frontend/dist apps/backend/internal/web/dist
|
||||||
|
|
||||||
|
- uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version-file: apps/backend/go.mod
|
||||||
|
cache-dependency-path: apps/backend/go.sum
|
||||||
|
|
||||||
|
- uses: goreleaser/goreleaser-action@v6
|
||||||
|
with:
|
||||||
|
distribution: goreleaser
|
||||||
|
version: "~> v2"
|
||||||
|
args: release --clean
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
docker:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- uses: docker/metadata-action@v5
|
||||||
|
id: meta
|
||||||
|
with:
|
||||||
|
images: ghcr.io/${{ github.repository }}
|
||||||
|
tags: |
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
|
type=raw,value=latest
|
||||||
|
|
||||||
|
- uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: infra/Dockerfile
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
@@ -20,6 +20,8 @@ data/*
|
|||||||
# misc
|
# misc
|
||||||
.DS_Store
|
.DS_Store
|
||||||
coverage/
|
coverage/
|
||||||
|
dist/
|
||||||
|
|
||||||
# local tooling artifacts
|
# local tooling artifacts
|
||||||
.playwright-mcp/
|
.playwright-mcp/
|
||||||
|
.playwright-cli/
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
version: 2
|
||||||
|
|
||||||
|
project_name: dash
|
||||||
|
|
||||||
|
before:
|
||||||
|
hooks:
|
||||||
|
# The embedded UI must be built and copied into internal/web/dist before
|
||||||
|
# any Go build. The release workflow does this; fail loudly here if the
|
||||||
|
# stub is still in place.
|
||||||
|
- sh -c 'test -d apps/backend/internal/web/dist/assets'
|
||||||
|
|
||||||
|
builds:
|
||||||
|
- id: dash
|
||||||
|
dir: apps/backend
|
||||||
|
binary: dash
|
||||||
|
main: ./cmd/dash
|
||||||
|
env: [CGO_ENABLED=0]
|
||||||
|
goos: [linux, darwin, windows]
|
||||||
|
goarch: [amd64, arm64]
|
||||||
|
ldflags: ["-s", "-w", "-X main.version={{.Version}}"]
|
||||||
|
|
||||||
|
- id: dash-agent
|
||||||
|
dir: apps/backend
|
||||||
|
binary: dash-agent
|
||||||
|
main: ./cmd/dash-agent
|
||||||
|
env: [CGO_ENABLED=0]
|
||||||
|
goos: [linux]
|
||||||
|
goarch: [amd64, arm64, arm]
|
||||||
|
goarm: ["7"]
|
||||||
|
ldflags: ["-s", "-w", "-X main.version={{.Version}}"]
|
||||||
|
|
||||||
|
archives:
|
||||||
|
- id: dash
|
||||||
|
ids: [dash]
|
||||||
|
formats: [tar.gz]
|
||||||
|
name_template: "{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}{{ with .Arm }}v{{ . }}{{ end }}"
|
||||||
|
format_overrides:
|
||||||
|
- goos: windows
|
||||||
|
formats: [zip]
|
||||||
|
files: [README.md, LICENSE]
|
||||||
|
- id: dash-agent
|
||||||
|
ids: [dash-agent]
|
||||||
|
formats: [tar.gz]
|
||||||
|
name_template: "{{ .ProjectName }}-agent_{{ .Os }}_{{ .Arch }}{{ with .Arm }}v{{ . }}{{ end }}"
|
||||||
|
files: [README.md, LICENSE]
|
||||||
|
|
||||||
|
checksum:
|
||||||
|
name_template: "checksums.txt"
|
||||||
|
|
||||||
|
changelog:
|
||||||
|
use: github
|
||||||
|
sort: asc
|
||||||
|
filters:
|
||||||
|
exclude:
|
||||||
|
- "^docs:"
|
||||||
|
- "^chore:"
|
||||||
|
- "^test:"
|
||||||
|
|
||||||
|
release:
|
||||||
|
footer: |
|
||||||
|
Docker: `docker pull ghcr.io/dvorinka/dash:{{ .Tag }}`
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to Dash. Format follows
|
||||||
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow
|
||||||
|
[Semantic Versioning](https://semver.org/).
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.7.0] — 2026-09-21
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Incidents with open/ack/resolve/close lifecycle and per-incident update
|
||||||
|
timeline; monitors auto-open and auto-resolve incidents on transitions
|
||||||
|
- Maintenance windows that suppress monitor alerts
|
||||||
|
- Public status pages at `/status/:slug` (unauthenticated, per-page monitor
|
||||||
|
and system selection)
|
||||||
|
- SVG badges: `/api/badge/monitor/:id.svg`, `/api/badge/domain/:id.svg`
|
||||||
|
- Prometheus metrics at `/api/metrics`
|
||||||
|
- CSV bulk import (`/api/import/csv`) and monitors/domains/systems in JSON
|
||||||
|
export
|
||||||
|
- Generic webhook notifications (`notify_webhook` setting): monitor up/down
|
||||||
|
and domain events, Slack/Discord/ntfy-compatible payload, `/api/notify/test`
|
||||||
|
|
||||||
|
## [0.6.0] — 2026-09-21
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- System monitoring: `systems` + `system_stats`, bearer-token push ingest
|
||||||
|
(`/api/systems/ingest`), offline detection
|
||||||
|
- `dash-agent` binary: Linux collector for CPU, memory, swap, disk, network,
|
||||||
|
load, uptime, temperatures, and Docker container state
|
||||||
|
- Systems list and detail pages with Recharts graphs; system board widget
|
||||||
|
|
||||||
|
## [0.5.0] — 2026-09-21
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Domain watch: RDAP + TCP-WHOIS lookup, WHOIS parsing, DNS records
|
||||||
|
(NS/MX/TXT/A/AAAA), TLS certificate details, host geo, DNS/email/hosting/
|
||||||
|
certificate provider detection
|
||||||
|
- Daily refresh scheduler, manual refresh, check history
|
||||||
|
- Domain list and detail pages, expiry and SSL countdowns, domain board widget
|
||||||
|
|
||||||
|
## [0.4.0] — 2026-09-21
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Uptime monitors: HTTP, TCP, ping, DNS, keyword, JSON-path, and push types;
|
||||||
|
interval/timeout/retries config, heartbeat history, 24h/30d uptime stats
|
||||||
|
- Monitor scheduler, check-now endpoint, push ingest (`/api/push/:token`)
|
||||||
|
- Monitor list and detail pages, monitor board widget, service-to-monitor
|
||||||
|
creation checkbox
|
||||||
|
- Importers: Homepage `services.yml`, Homarr JSON, Dashy `conf.yml`
|
||||||
|
|
||||||
|
## [0.3.0] — 2026-09-17
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Index and Console board renderers (four total), command palette (⌘K),
|
||||||
|
icon CDN suggestions
|
||||||
|
|
||||||
|
## [0.2.0] — 2026-09-17
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Widget layer: Go fetcher registry, config-field metadata, 30s TTL cache
|
||||||
|
- Pi-hole, AdGuard Home, Immich, and clock widgets; per-widget config UI
|
||||||
|
|
||||||
|
## [0.1.0] — 2026-09-17
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Board MVP: sections, items, multi-URL services, drag-drop, collapse state
|
||||||
|
- Bento and Cards renderers, dark/light themes, icon upload + letter fallback
|
||||||
|
- Server-side status pings with up/down chips
|
||||||
|
- Single Go binary serving the embedded React UI; SQLite via goose migrations;
|
||||||
|
Docker image on distroless
|
||||||
|
|
||||||
|
[Unreleased]: https://github.com/Dvorinka/Dash/compare/v0.7.0...HEAD
|
||||||
|
[0.7.0]: https://github.com/Dvorinka/Dash/compare/v0.6.0...v0.7.0
|
||||||
|
[0.6.0]: https://github.com/Dvorinka/Dash/compare/v0.5.0...v0.6.0
|
||||||
|
[0.5.0]: https://github.com/Dvorinka/Dash/compare/v0.4.0...v0.5.0
|
||||||
|
[0.4.0]: https://github.com/Dvorinka/Dash/compare/v0.3.0...v0.4.0
|
||||||
|
[0.3.0]: https://github.com/Dvorinka/Dash/compare/v0.2.0...v0.3.0
|
||||||
|
[0.2.0]: https://github.com/Dvorinka/Dash/compare/v0.1.0...v0.2.0
|
||||||
|
[0.1.0]: https://github.com/Dvorinka/Dash/releases/tag/v0.1.0
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# Code of Conduct
|
||||||
|
|
||||||
|
This project follows the [Contributor Covenant](https://www.contributor-covenant.org/),
|
||||||
|
version 2.1.
|
||||||
|
|
||||||
|
## Our pledge
|
||||||
|
|
||||||
|
We pledge to make participation in this project a harassment-free experience
|
||||||
|
for everyone, regardless of age, body size, visible or invisible disability,
|
||||||
|
ethnicity, sex characteristics, gender identity and expression, level of
|
||||||
|
experience, education, socio-economic status, nationality, personal appearance,
|
||||||
|
race, religion, or sexual identity and orientation.
|
||||||
|
|
||||||
|
## Our standards
|
||||||
|
|
||||||
|
Examples of behavior that contributes to a positive environment:
|
||||||
|
|
||||||
|
- Demonstrating empathy and kindness toward other people
|
||||||
|
- Being respectful of differing opinions, viewpoints, and experiences
|
||||||
|
- Giving and gracefully accepting constructive feedback
|
||||||
|
- Accepting responsibility and apologizing to those affected by our mistakes
|
||||||
|
- Focusing on what is best for the community
|
||||||
|
|
||||||
|
Examples of unacceptable behavior:
|
||||||
|
|
||||||
|
- The use of sexualized language or imagery, and sexual attention or advances
|
||||||
|
- Trolling, insulting or derogatory comments, and personal or political attacks
|
||||||
|
- Public or private harassment
|
||||||
|
- Publishing others' private information without explicit permission
|
||||||
|
- Other conduct which could reasonably be considered inappropriate in a
|
||||||
|
professional setting
|
||||||
|
|
||||||
|
## Enforcement
|
||||||
|
|
||||||
|
Instances of unacceptable behavior may be reported to the maintainer via
|
||||||
|
GitHub (@Dvorinka). All complaints will be reviewed and investigated promptly
|
||||||
|
and fairly. The full enforcement guidelines are at
|
||||||
|
https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# Contributing
|
||||||
|
|
||||||
|
Thanks for considering a contribution. Dash is a small, deliberate codebase —
|
||||||
|
read [AGENTS.md](AGENTS.md) and [ROADMAP.md](ROADMAP.md) first; the roadmap is
|
||||||
|
canonical for what gets built and when.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
Requires Go 1.24+ and Node 20+.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm install
|
||||||
|
npm run generate # regenerate the TS client from openapi.yaml
|
||||||
|
npm run dev # vite dev server on :5173, proxies /api to :8080
|
||||||
|
```
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd apps/backend
|
||||||
|
DASH_DEV=1 go run ./cmd/dash # API on :8080
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rules of the house
|
||||||
|
|
||||||
|
- `openapi.yaml` is the API contract. Change it first, then
|
||||||
|
`npm run generate`. Never hand-write API types in the frontend.
|
||||||
|
- All schema changes go through goose migrations in
|
||||||
|
`apps/backend/internal/db/migrations` — `NNNN_name.sql` with `-- +goose Up`
|
||||||
|
and `-- +goose Down`.
|
||||||
|
- Board renderers share state, drag-drop, and popover primitives. Never
|
||||||
|
reimplement them per renderer.
|
||||||
|
- No auth, no YAML-as-source-of-truth, no external database. These are
|
||||||
|
non-goals for v1.
|
||||||
|
- Standard library before dependencies; existing helpers before new ones.
|
||||||
|
- User-visible strings go through `t("key")` from `src/i18n` — add the key to
|
||||||
|
`en.ts` rather than inlining literals in new UI.
|
||||||
|
|
||||||
|
## Before pushing
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run typecheck
|
||||||
|
npm run lint
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
cd apps/backend
|
||||||
|
go build ./... && go vet ./... && go test ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
Plus a real smoke test of whatever you changed. All gates green or no push.
|
||||||
|
|
||||||
|
## Commits and PRs
|
||||||
|
|
||||||
|
- Conventional-ish subjects: `feat:`, `fix:`, `docs:`, `refactor:` — keep them
|
||||||
|
short and describe the why.
|
||||||
|
- Small, reviewable PRs. Fill in the template; a screenshot of UI changes is
|
||||||
|
worth more than a paragraph.
|
||||||
|
|
||||||
|
## Reporting bugs / requesting features
|
||||||
|
|
||||||
|
Use the issue templates. For security issues, see
|
||||||
|
[SECURITY.md](SECURITY.md) — do not open a public issue.
|
||||||
@@ -3,21 +3,69 @@
|
|||||||
A self-hosted homelab dashboard. CasaOS-grade ease of use, Vercel-grade design.
|
A self-hosted homelab dashboard. CasaOS-grade ease of use, Vercel-grade design.
|
||||||
Single container, UI-managed, no YAML, no auth.
|
Single container, UI-managed, no YAML, no auth.
|
||||||
|
|
||||||
**Status:** pre-release. See [ROADMAP.md](ROADMAP.md).
|

|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Service board** — sections, drag-and-drop, multi-URL services, icon upload,
|
||||||
|
four board renderers (Bento, Cards, Index, Console), dark/light themes
|
||||||
|
- **Uptime monitors** — HTTP, TCP, ping, DNS, keyword, JSON-path, and push
|
||||||
|
checks on a schedule, with heartbeat history and uptime stats
|
||||||
|
- **Domain watch** — WHOIS/RDAP, DNS records, TLS certificate expiry,
|
||||||
|
registrar and provider detection
|
||||||
|
- **System monitoring** — lightweight `dash-agent` pushes CPU, memory, disk,
|
||||||
|
network, load, temperatures, and Docker container state
|
||||||
|
- **Incidents & status pages** — manual and automatic incidents, maintenance
|
||||||
|
windows, public `/status/:slug` pages, SVG badges
|
||||||
|
- **Alerts** — generic webhook notifications (Slack, Discord, ntfy compatible)
|
||||||
|
- **Observability** — Prometheus `/api/metrics`, JSON export, CSV bulk import,
|
||||||
|
importers for Homepage, Homarr, and Dashy configs
|
||||||
|
|
||||||
## Run
|
## Run
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
docker run -v ./data:/data -p 3000:3000 dash
|
docker run -d -v ./data:/data -p 3000:3000 ghcr.io/dvorinka/dash:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
or
|
or with compose:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
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.
|
||||||
|
|
||||||
|
## System agent
|
||||||
|
|
||||||
|
Monitor a Linux host: create a system in the UI, copy the token, then:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
docker compose up
|
dash-agent -url http://dash:3000 -token <token>
|
||||||
```
|
```
|
||||||
|
|
||||||
Then open http://localhost:3000.
|
One static binary, no dependencies. See
|
||||||
|
[docs/installation.md](docs/installation.md) for systemd and Docker setups.
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
|  |  |
|
||||||
|
|  |  |
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
|||||||
+83
-6
@@ -34,6 +34,10 @@ or popover logic.
|
|||||||
| `v0.1.0` | Phase 0 + 1 — working MVP, single image | manual smoke test ✅ |
|
| `v0.1.0` | Phase 0 + 1 — working MVP, single image | manual smoke test ✅ |
|
||||||
| `v0.2.0` | Phase 2 — widget layer + first integrations | Pi-hole widget live ✅ |
|
| `v0.2.0` | Phase 2 — widget layer + first integrations | Pi-hole widget live ✅ |
|
||||||
| `v0.3.0` | Phase 3 — four renderers, ⌘K palette, icon suggest | palette + switcher live ✅ |
|
| `v0.3.0` | Phase 3 — four renderers, ⌘K palette, icon suggest | palette + switcher live ✅ |
|
||||||
|
| `v0.4.0` | Phase M1 — uptime monitors + alerts backbone | monitor lifecycle live |
|
||||||
|
| `v0.5.0` | Phase M2 — domain intelligence | domain lookup + expiry alerts |
|
||||||
|
| `v0.6.0` | Phase M3 — system monitoring + agent | agent ingest + charts |
|
||||||
|
| `v0.7.0` | Phase M4 — incidents, status pages, badges, metrics | public status page live |
|
||||||
| `v1.0.0` | Phase 4 — OSS polish, docs, importers, CI releases | public repo launch |
|
| `v1.0.0` | Phase 4 — OSS polish, docs, importers, CI releases | public repo launch |
|
||||||
| `v1.x` | Phase 5 — demand-driven extras only | per-feature |
|
| `v1.x` | Phase 5 — demand-driven extras only | per-feature |
|
||||||
|
|
||||||
@@ -113,14 +117,87 @@ The product's spine: sections, services, drag-drop, multi-URL.
|
|||||||
|
|
||||||
**Exit:** all four renderers work off the same board state; palette navigates everything. — verified: both new renderers render/persist/dnd/collapse, palette filters and executes, icon suggest resolves real CDN assets, 360px + light theme, docker image 43.9 MB.
|
**Exit:** all four renderers work off the same board state; palette navigates everything. — verified: both new renderers render/persist/dnd/collapse, palette filters and executes, icon suggest resolves real CDN assets, 360px + light theme, docker image 43.9 MB.
|
||||||
|
|
||||||
|
## Merge program — Beszel fork → Dash
|
||||||
|
|
||||||
|
Source: the local Beszel fork (`~/Desktop/PROG+HTML/Beszel` — PocketBase hub
|
||||||
|
with custom monitors/domains/status-pages) plus lissy93/domain-locker as
|
||||||
|
design inspiration. Upstream references cloned to `../_ref/beszel` and
|
||||||
|
`../_ref/domain-locker` for consultation only.
|
||||||
|
|
||||||
|
**No code moves verbatim.** The fork is PocketBase collections + its own
|
||||||
|
auth + a WebSocket agent; Dash is Gin + SQLite + goose with no auth. We port
|
||||||
|
data models, check logic, and lookup pipelines — DB, API, and UI layers are
|
||||||
|
re-implemented on Dash conventions.
|
||||||
|
|
||||||
|
**Deployment reality.** Monitoring needs a persistent process (schedulers),
|
||||||
|
non-HTTP egress (WHOIS :43, ICMP, SMTP, raw TCP), and a writable DB. None of
|
||||||
|
that exists on serverless — Vercel cannot host this product, full stop. The
|
||||||
|
deploy story stays `docker run` + GHCR image + goreleaser binaries; agents
|
||||||
|
are a second small binary.
|
||||||
|
|
||||||
|
**UI model.** The app becomes routed: `/` board (default), `/monitors`,
|
||||||
|
`/domains`, `/systems`, `/status/:slug` (public, no board chrome). Router:
|
||||||
|
`wouter` (~2 kB — every route we need, nothing more). Charts: `recharts`.
|
||||||
|
Board widgets surface monitoring entities through the existing widget
|
||||||
|
registry — monitor uptime, domain expiry, system gauges — so the board
|
||||||
|
becomes the summary layer over the monitoring pages.
|
||||||
|
|
||||||
|
### Phase M1 — Monitors → v0.4.0
|
||||||
|
|
||||||
|
Uptime checking: the backbone everything else hangs off.
|
||||||
|
|
||||||
|
- [ ] Schema: `monitors` (name, type, url/hostname/port, method, keyword, json_query, expected_value, interval_s, timeout_s, retries, active, status, tags, notes), `heartbeats` (monitor_id, status, ping_ms, msg, cert_expiry, checked_at — 30-day retention prune)
|
||||||
|
- [ ] In-process scheduler: due-monitor sweep, bounded worker pool, heartbeat write, status transitions
|
||||||
|
- [ ] Checkers: `http`/`https`, `tcp`, `ping` (unprivileged ICMP datagram via `x/net/icmp` — distroless has no ping binary), `dns`, `keyword`, `json-query`, `push` (caller-generated ingest URL)
|
||||||
|
- [ ] API: `/api/monitors` CRUD, `/:id/heartbeats?range=`, pause/resume, check-now
|
||||||
|
- [ ] Monitors page (status, uptime 24h/30d, ping, interval) + detail (response chart, heartbeat log)
|
||||||
|
- [ ] Board widget type `monitor` bound to a monitor id
|
||||||
|
- [ ] ServiceDialog checkbox: create matching http monitor from a service URL
|
||||||
|
- [ ] The fork's ~30 exotic monitor types were TCP stubs — not ported. Real types only.
|
||||||
|
|
||||||
|
### Phase M2 — Domains → v0.5.0
|
||||||
|
|
||||||
|
Domain-locker-grade domain intelligence.
|
||||||
|
|
||||||
|
- [ ] Schema: `domains` (core columns + `extra` JSON for the long tail), `domain_history` (domain_id, change_type, field, old, new, at)
|
||||||
|
- [ ] Lookup pipeline ported from fork `hub/domains/whois/lookup.go`, trimmed: RDAP over HTTPS → native WHOIS TCP:43 + parser; SSL chain via `crypto/tls`; DNS (NS/MX/TXT/A/AAAA) via `net.Resolver`; host geo via ip-api; provider detection via fork `detect/providers.go`; favicon
|
||||||
|
- [ ] Daily scheduler + manual refresh; field diffs recorded to `domain_history`
|
||||||
|
- [ ] Alerts: `alert_rules` + `notifications` tables; dispatchers — generic webhook (JSON POST), SMTP via `net/smtp`, Discord/Slack presets. Triggers: domain expiry ≤ N days, SSL expiry ≤ N days, monitor down/recovered (M1), system offline (M3)
|
||||||
|
- [ ] Domains page + detail (expiry countdown, registrar, SSL, DNS, subdomains)
|
||||||
|
- [ ] Board widget `domain` (expiry countdown)
|
||||||
|
- [ ] Subdomain discovery: port `subdomain_discovery.go`, opt-in per domain
|
||||||
|
- [ ] Not ported (v1): whoisxml API, EURid web scraping, SEO/robots parsing, valuation estimates — demand only
|
||||||
|
|
||||||
|
### Phase M3 — Systems → v0.6.0
|
||||||
|
|
||||||
|
Beszel-style server monitoring, push-based.
|
||||||
|
|
||||||
|
- [x] Schema: `systems` (name, token, host, os/arch, last_seen, status, `latest` JSON snapshot), `system_stats` (system_id, ts, payload JSON — one column, no sub-field queries; 7d retention prune)
|
||||||
|
- [x] Ingest: `POST /api/systems/ingest` with per-system bearer token; offline when silent > max(3×interval, 30s); `system.up`/`system.down` webhook events
|
||||||
|
- [x] `cmd/dash-agent`: Linux collector — `/proc` (cpu/mem/net/load/uptime), `/sys` hwmon temps, statfs disk, docker.sock container list; POST every `-interval` (default 10s); `-once` debug mode
|
||||||
|
- [x] Agent packaging: systemd unit (`packaging/dash-agent.service`) + `infra/Dockerfile.agent` + goreleaser binaries (Phase 4)
|
||||||
|
- [x] Systems page (status cards, cpu/mem/disk bars, uptime) + detail (recharts: cpu%, mem%, net rx/tx, load; temps + containers tables)
|
||||||
|
- [x] Board widget `system` (cpu/mem/disk mini bars)
|
||||||
|
- [x] Not ported: the beszel agent protocol (SSH/WS into PocketBase) — our agent is push-JSON. SMART/ZFS/GPU metrics + per-container cpu/mem deferred to demand.
|
||||||
|
|
||||||
|
### Phase M4 — Ops layer → v0.7.0
|
||||||
|
|
||||||
|
- [x] Incidents: `incidents` + `incident_updates`; manual CRUD + auto-open on monitor down, auto-resolve on recovery; severity + status flow (open → ack → resolved → closed)
|
||||||
|
- [x] Maintenance windows: `maintenance_windows` (monitor_ids empty = all); suppress alerts + auto-incidents, status pages show `maintenance` state while active
|
||||||
|
- [x] Status pages: `status_pages` (monitor_ids/system_ids JSON columns — a join table for ≤ dozens of ids is ceremony); admin `/status`, public `/status/:slug` + `GET /api/status-pages/:slug/public`
|
||||||
|
- [x] Badges: `GET /api/badge/:kind/:id.svg` — stateless SVG from live data (monitor status+uptime, domain days-left, system status)
|
||||||
|
- [x] `GET /api/metrics` Prometheus exposition
|
||||||
|
- [x] Bulk: `POST /api/import/csv?kind=monitors|domains` header-driven CSV; monitors/domains/systems rows folded into `/api/export` (v2, additive)
|
||||||
|
- [x] ⌘K palette + header nav wired for new pages
|
||||||
|
|
||||||
## Phase 4 — Open-source launch → v1.0.0
|
## Phase 4 — Open-source launch → v1.0.0
|
||||||
|
|
||||||
- [ ] Docs: install, configuration, widget development guide (`docs/`)
|
- [x] Docs: install, configuration, widget development guide (`docs/`)
|
||||||
- [ ] Importers: Homepage `services.yml`, Homarr JSON, Dashy `conf.yml` → sections/items/urls
|
- [x] Importers: Homepage `services.yml`, Homarr JSON, Dashy `conf.yml` → sections/items/urls
|
||||||
- [ ] `README.md` hero: screenshots (4 themes), demo GIF, `docker run` one-liner
|
- [x] `README.md` hero: screenshots, `docker run` one-liner (demo GIF deferred — pngs suffice for v1.0.0)
|
||||||
- [ ] goreleaser → binaries; GHCR image on tag; semver + `CHANGELOG.md`
|
- [x] goreleaser → binaries; GHCR image on tag; semver + `CHANGELOG.md`
|
||||||
- [ ] `CONTRIBUTING.md`, issue/PR templates, code of conduct, security policy
|
- [x] `CONTRIBUTING.md`, issue/PR templates, code of conduct, security policy
|
||||||
- [ ] i18n scaffolding (en default)
|
- [x] i18n scaffolding (`src/i18n/` — typed `t()` + `en` dict; full string extraction deferred to first real locale)
|
||||||
- [ ] Public repo flip + announcement assets
|
- [ ] Public repo flip + announcement assets
|
||||||
|
|
||||||
**Exit:** a stranger can `docker run`, import their Homepage config, and have a working board in under two minutes.
|
**Exit:** a stranger can `docker run`, import their Homepage config, and have a working board in under two minutes.
|
||||||
|
|||||||
+34
@@ -0,0 +1,34 @@
|
|||||||
|
# Security Policy
|
||||||
|
|
||||||
|
## Supported versions
|
||||||
|
|
||||||
|
| Version | Supported |
|
||||||
|
|---|---|
|
||||||
|
| latest release | yes |
|
||||||
|
| older releases | no |
|
||||||
|
|
||||||
|
Dash is pre-1.0; only the latest release receives fixes.
|
||||||
|
|
||||||
|
## Reporting a vulnerability
|
||||||
|
|
||||||
|
Use GitHub's private vulnerability reporting:
|
||||||
|
**Security → Advisories → Report a vulnerability** on this repository.
|
||||||
|
|
||||||
|
Do not open a public issue for security problems.
|
||||||
|
|
||||||
|
Please include:
|
||||||
|
|
||||||
|
- affected version/commit
|
||||||
|
- steps to reproduce or a proof of concept
|
||||||
|
- impact assessment
|
||||||
|
|
||||||
|
You will get an acknowledgement within a few days. If the report is accepted,
|
||||||
|
a fix ships in the next release and you are credited in the advisory (unless
|
||||||
|
you prefer otherwise).
|
||||||
|
|
||||||
|
## Scope notes
|
||||||
|
|
||||||
|
Dash is designed for trusted private networks — it ships **without
|
||||||
|
authentication** by design. Exposure to the public internet is a deployment
|
||||||
|
choice outside the project's control, but issues that remain exploitable on a
|
||||||
|
LAN (SSRF in checkers, injection, token leakage, unsafe schemes) are in scope.
|
||||||
@@ -0,0 +1,362 @@
|
|||||||
|
// Command dash-agent collects host metrics and pushes them to a Dash server.
|
||||||
|
// Linux-first: /proc for cpu/mem/net/load/uptime, /sys hwmon for temps,
|
||||||
|
// statfs for disk, and the Docker socket for container liveness when present.
|
||||||
|
//
|
||||||
|
// env DASH_URL=http://dash:8080 DASH_TOKEN=agent_xxx dash-agent
|
||||||
|
//
|
||||||
|
// Flags override env: -url, -token, -interval (seconds), -once.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"runtime"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// version is stamped by goreleaser via -X main.version.
|
||||||
|
var version = "dev"
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
url := flag.String("url", os.Getenv("DASH_URL"), "dash server base URL")
|
||||||
|
token := flag.String("token", os.Getenv("DASH_TOKEN"), "system token")
|
||||||
|
interval := flag.Int("interval", envInt("DASH_INTERVAL", 10), "push interval seconds")
|
||||||
|
once := flag.Bool("once", false, "collect and print one sample, no push")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
c := &collector{}
|
||||||
|
c.host, _ = os.Hostname()
|
||||||
|
c.os = runtime.GOOS
|
||||||
|
c.arch = runtime.GOARCH
|
||||||
|
c.cpuModel, c.cores = cpuInfo()
|
||||||
|
c.prevCPU = readCPUTicks()
|
||||||
|
c.prevNet = readNetBytes()
|
||||||
|
c.prevAt = time.Now()
|
||||||
|
|
||||||
|
if *once {
|
||||||
|
// Rates need a delta window — idle briefly so cpu/net are real.
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
s := c.sample(*interval)
|
||||||
|
out, _ := json.MarshalIndent(s, "", " ")
|
||||||
|
fmt.Println(string(out))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if *url == "" || *token == "" {
|
||||||
|
log.Fatal("dash-agent: -url and -token (or DASH_URL/DASH_TOKEN) required")
|
||||||
|
}
|
||||||
|
log.Printf("dash-agent %s: pushing to %s every %ds", version, *url, *interval)
|
||||||
|
for {
|
||||||
|
s := c.sample(*interval)
|
||||||
|
if err := push(*url, *token, s); err != nil {
|
||||||
|
log.Printf("dash-agent: push: %v", err)
|
||||||
|
}
|
||||||
|
time.Sleep(time.Duration(*interval) * time.Second)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func envInt(k string, def int) int {
|
||||||
|
if v, err := strconv.Atoi(os.Getenv(k)); err == nil && v > 0 {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
|
// sample mirrors api.StatSample — duplicated here so the agent stays a
|
||||||
|
// standalone binary with zero imports from the server module.
|
||||||
|
type sample struct {
|
||||||
|
Host string `json:"host"`
|
||||||
|
OS string `json:"os"`
|
||||||
|
Arch string `json:"arch"`
|
||||||
|
CPUModel string `json:"cpuModel"`
|
||||||
|
Cores int `json:"cores"`
|
||||||
|
IntervalS int `json:"intervalS"`
|
||||||
|
UptimeS float64 `json:"uptimeS"`
|
||||||
|
CPU float64 `json:"cpu"`
|
||||||
|
MemTotal float64 `json:"memTotal"`
|
||||||
|
MemUsed float64 `json:"memUsed"`
|
||||||
|
SwapTotal float64 `json:"swapTotal"`
|
||||||
|
SwapUsed float64 `json:"swapUsed"`
|
||||||
|
DiskTotal float64 `json:"diskTotal"`
|
||||||
|
DiskUsed float64 `json:"diskUsed"`
|
||||||
|
NetRx float64 `json:"netRx"`
|
||||||
|
NetTx float64 `json:"netTx"`
|
||||||
|
Load1 float64 `json:"load1"`
|
||||||
|
Load5 float64 `json:"load5"`
|
||||||
|
Load15 float64 `json:"load15"`
|
||||||
|
Temps map[string]float64 `json:"temps,omitempty"`
|
||||||
|
Containers []containerStat `json:"containers,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type containerStat struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
State string `json:"state"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type collector struct {
|
||||||
|
host, os, arch, cpuModel string
|
||||||
|
cores int
|
||||||
|
prevCPU cpuTicks
|
||||||
|
prevNet [2]uint64 // rx, tx bytes
|
||||||
|
prevAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type cpuTicks struct{ idle, total uint64 }
|
||||||
|
|
||||||
|
func (c *collector) sample(interval int) sample {
|
||||||
|
s := sample{
|
||||||
|
Host: c.host, OS: c.os, Arch: c.arch,
|
||||||
|
CPUModel: c.cpuModel, Cores: c.cores, IntervalS: interval,
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
dt := now.Sub(c.prevAt).Seconds()
|
||||||
|
if dt <= 0 {
|
||||||
|
dt = float64(interval)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cur := readCPUTicks(); cur.total > c.prevCPU.total {
|
||||||
|
busy := float64(cur.total-cur.idle) - float64(c.prevCPU.total-c.prevCPU.idle)
|
||||||
|
s.CPU = busy / float64(cur.total-c.prevCPU.total) * 100
|
||||||
|
c.prevCPU = cur
|
||||||
|
}
|
||||||
|
s.MemTotal, s.MemUsed, s.SwapTotal, s.SwapUsed = readMem()
|
||||||
|
s.DiskTotal, s.DiskUsed = diskUsage("/")
|
||||||
|
if cur := readNetBytes(); cur[0] >= c.prevNet[0] {
|
||||||
|
s.NetRx = float64(cur[0]-c.prevNet[0]) / dt
|
||||||
|
s.NetTx = float64(cur[1]-c.prevNet[1]) / dt
|
||||||
|
c.prevNet = cur
|
||||||
|
}
|
||||||
|
c.prevAt = now
|
||||||
|
s.Load1, s.Load5, s.Load15 = readLoad()
|
||||||
|
s.UptimeS = readUptime()
|
||||||
|
s.Temps = readTemps()
|
||||||
|
s.Containers = readContainers()
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func push(url, token string, s sample) error {
|
||||||
|
body, _ := json.Marshal(s)
|
||||||
|
req, _ := http.NewRequest(http.MethodPost,
|
||||||
|
strings.TrimRight(url, "/")+"/api/systems/ingest", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
res, err := (&http.Client{Timeout: 10 * time.Second}).Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer res.Body.Close()
|
||||||
|
io.Copy(io.Discard, res.Body)
|
||||||
|
if res.StatusCode >= 300 {
|
||||||
|
return fmt.Errorf("server returned %d", res.StatusCode)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- /proc + /sys readers (Linux; all no-op-safe elsewhere) ----------
|
||||||
|
|
||||||
|
// readCPUTicks parses the aggregate cpu line: fields are user nice system
|
||||||
|
// idle iowait irq softirq steal — everything but idle+iowait counts busy.
|
||||||
|
func readCPUTicks() cpuTicks {
|
||||||
|
b, err := os.ReadFile("/proc/stat")
|
||||||
|
if err != nil {
|
||||||
|
return cpuTicks{}
|
||||||
|
}
|
||||||
|
var t cpuTicks
|
||||||
|
for _, line := range strings.Split(string(b), "\n") {
|
||||||
|
if !strings.HasPrefix(line, "cpu ") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
f := strings.Fields(line)[1:]
|
||||||
|
var vals []uint64
|
||||||
|
for _, v := range f {
|
||||||
|
n, _ := strconv.ParseUint(v, 10, 64)
|
||||||
|
vals = append(vals, n)
|
||||||
|
t.total += n
|
||||||
|
}
|
||||||
|
if len(vals) >= 5 {
|
||||||
|
t.idle = vals[3] + vals[4]
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
func cpuInfo() (model string, cores int) {
|
||||||
|
b, err := os.ReadFile("/proc/cpuinfo")
|
||||||
|
if err != nil {
|
||||||
|
return "", runtime.NumCPU()
|
||||||
|
}
|
||||||
|
for _, line := range strings.Split(string(b), "\n") {
|
||||||
|
if strings.HasPrefix(line, "processor") {
|
||||||
|
cores++
|
||||||
|
}
|
||||||
|
if model == "" && strings.HasPrefix(line, "model name") {
|
||||||
|
model = strings.TrimSpace(strings.SplitN(line, ":", 2)[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cores == 0 {
|
||||||
|
cores = runtime.NumCPU()
|
||||||
|
}
|
||||||
|
return model, cores
|
||||||
|
}
|
||||||
|
|
||||||
|
// readMem returns total/used bytes for RAM and swap from /proc/meminfo.
|
||||||
|
func readMem() (memTotal, memUsed, swapTotal, swapUsed float64) {
|
||||||
|
b, err := os.ReadFile("/proc/meminfo")
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
kb := map[string]float64{}
|
||||||
|
for _, line := range strings.Split(string(b), "\n") {
|
||||||
|
f := strings.Fields(line)
|
||||||
|
if len(f) >= 2 {
|
||||||
|
kb[strings.TrimSuffix(f[0], ":")], _ = strconv.ParseFloat(f[1], 64)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
memTotal = kb["MemTotal"] * 1024
|
||||||
|
memUsed = (kb["MemTotal"] - kb["MemAvailable"]) * 1024
|
||||||
|
swapTotal = kb["SwapTotal"] * 1024
|
||||||
|
swapUsed = (kb["SwapTotal"] - kb["SwapFree"]) * 1024
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func diskUsage(path string) (total, used float64) {
|
||||||
|
var st syscall.Statfs_t
|
||||||
|
if err := syscall.Statfs(path, &st); err != nil {
|
||||||
|
return 0, 0
|
||||||
|
}
|
||||||
|
total = float64(st.Blocks) * float64(st.Bsize)
|
||||||
|
used = float64(st.Blocks-st.Bfree) * float64(st.Bsize)
|
||||||
|
return total, used
|
||||||
|
}
|
||||||
|
|
||||||
|
// readNetBytes sums rx/tx across interfaces, skipping loopback.
|
||||||
|
func readNetBytes() [2]uint64 {
|
||||||
|
b, err := os.ReadFile("/proc/net/dev")
|
||||||
|
if err != nil {
|
||||||
|
return [2]uint64{}
|
||||||
|
}
|
||||||
|
var rx, tx uint64
|
||||||
|
for _, line := range strings.Split(string(b), "\n") {
|
||||||
|
i := strings.Index(line, ":")
|
||||||
|
if i < 0 || strings.Contains(line[:i], "lo") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
f := strings.Fields(line[i+1:])
|
||||||
|
if len(f) >= 9 {
|
||||||
|
r, _ := strconv.ParseUint(f[0], 10, 64)
|
||||||
|
t, _ := strconv.ParseUint(f[8], 10, 64)
|
||||||
|
rx += r
|
||||||
|
tx += t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [2]uint64{rx, tx}
|
||||||
|
}
|
||||||
|
|
||||||
|
func readLoad() (l1, l5, l15 float64) {
|
||||||
|
b, err := os.ReadFile("/proc/loadavg")
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
f := strings.Fields(string(b))
|
||||||
|
if len(f) >= 3 {
|
||||||
|
l1, _ = strconv.ParseFloat(f[0], 64)
|
||||||
|
l5, _ = strconv.ParseFloat(f[1], 64)
|
||||||
|
l15, _ = strconv.ParseFloat(f[2], 64)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func readUptime() float64 {
|
||||||
|
b, err := os.ReadFile("/proc/uptime")
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
f := strings.Fields(string(b))
|
||||||
|
if len(f) > 0 {
|
||||||
|
v, _ := strconv.ParseFloat(f[0], 64)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// readTemps walks /sys/class/hwmon for temp*_input sensors.
|
||||||
|
func readTemps() map[string]float64 {
|
||||||
|
out := map[string]float64{}
|
||||||
|
hwmons, _ := os.ReadDir("/sys/class/hwmon")
|
||||||
|
for _, hw := range hwmons {
|
||||||
|
dir := "/sys/class/hwmon/" + hw.Name()
|
||||||
|
chip, _ := os.ReadFile(dir + "/name")
|
||||||
|
entries, _ := os.ReadDir(dir)
|
||||||
|
for _, e := range entries {
|
||||||
|
name := e.Name()
|
||||||
|
if !strings.HasPrefix(name, "temp") || !strings.HasSuffix(name, "_input") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b, err := os.ReadFile(dir + "/" + name)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
millideg, err := strconv.ParseFloat(strings.TrimSpace(string(b)), 64)
|
||||||
|
if err != nil || millideg <= 0 || millideg > 150000 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
label := strings.TrimSpace(string(chip))
|
||||||
|
if lb, err := os.ReadFile(dir + "/" + strings.TrimSuffix(name, "_input") + "_label"); err == nil {
|
||||||
|
if l := strings.TrimSpace(string(lb)); l != "" {
|
||||||
|
label = l
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, dup := out[label]; !dup {
|
||||||
|
out[label] = millideg / 1000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// readContainers lists Docker containers via the unix socket when present.
|
||||||
|
func readContainers() []containerStat {
|
||||||
|
if _, err := os.Stat("/var/run/docker.sock"); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
client := &http.Client{
|
||||||
|
Timeout: 3 * time.Second,
|
||||||
|
Transport: &http.Transport{
|
||||||
|
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
|
||||||
|
return (&net.Dialer{}).DialContext(ctx, "unix", "/var/run/docker.sock")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
res, err := client.Get("http://d/containers/json?all=1")
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
defer res.Body.Close()
|
||||||
|
var raw []struct {
|
||||||
|
Names []string `json:"Names"`
|
||||||
|
State string `json:"State"`
|
||||||
|
}
|
||||||
|
if json.NewDecoder(res.Body).Decode(&raw) != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]containerStat, 0, len(raw))
|
||||||
|
for _, r := range raw {
|
||||||
|
name := ""
|
||||||
|
if len(r.Names) > 0 {
|
||||||
|
name = strings.TrimPrefix(r.Names[0], "/")
|
||||||
|
}
|
||||||
|
out = append(out, containerStat{Name: name, State: r.State})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -19,6 +19,9 @@ import (
|
|||||||
"github.com/tdvorak/dash/internal/web"
|
"github.com/tdvorak/dash/internal/web"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// version is stamped by goreleaser via -X main.version.
|
||||||
|
var version = "dev"
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
dev := os.Getenv("DASH_DEV") == "1"
|
dev := os.Getenv("DASH_DEV") == "1"
|
||||||
|
|
||||||
@@ -42,8 +45,11 @@ func main() {
|
|||||||
}
|
}
|
||||||
defer func() { _ = sqlDB.Close() }()
|
defer func() { _ = sqlDB.Close() }()
|
||||||
|
|
||||||
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
defer stop()
|
||||||
|
|
||||||
iconsDir := filepath.Join(dataDir, "icons")
|
iconsDir := filepath.Join(dataDir, "icons")
|
||||||
router := api.NewRouter(logger, sqlDB, iconsDir)
|
router := api.NewRouter(ctx, logger, sqlDB, iconsDir)
|
||||||
if err := web.Register(router); err != nil {
|
if err := web.Register(router); err != nil {
|
||||||
logger.Fatal("register embedded UI", zap.Error(err))
|
logger.Fatal("register embedded UI", zap.Error(err))
|
||||||
}
|
}
|
||||||
@@ -51,11 +57,8 @@ func main() {
|
|||||||
addr := ":" + envOr("DASH_PORT", "8080")
|
addr := ":" + envOr("DASH_PORT", "8080")
|
||||||
srv := &http.Server{Addr: addr, Handler: router, ReadHeaderTimeout: 10 * time.Second}
|
srv := &http.Server{Addr: addr, Handler: router, ReadHeaderTimeout: 10 * time.Second}
|
||||||
|
|
||||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
|
||||||
defer stop()
|
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
logger.Info("listening", zap.String("addr", addr), zap.String("data", dataDir))
|
logger.Info("listening", zap.String("addr", addr), zap.String("data", dataDir), zap.String("version", version))
|
||||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
logger.Fatal("serve", zap.Error(err))
|
logger.Fatal("serve", zap.Error(err))
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -4,8 +4,10 @@ go 1.27
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/gin-gonic/gin v1.12.0
|
github.com/gin-gonic/gin v1.12.0
|
||||||
|
github.com/goccy/go-yaml v1.19.2
|
||||||
github.com/pressly/goose/v3 v3.28.0
|
github.com/pressly/goose/v3 v3.28.0
|
||||||
go.uber.org/zap v1.28.0
|
go.uber.org/zap v1.28.0
|
||||||
|
golang.org/x/net v0.58.0
|
||||||
modernc.org/sqlite v1.59.0
|
modernc.org/sqlite v1.59.0
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -21,7 +23,6 @@ require (
|
|||||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
||||||
github.com/goccy/go-json v0.10.5 // indirect
|
github.com/goccy/go-json v0.10.5 // indirect
|
||||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||||
@@ -42,7 +43,6 @@ require (
|
|||||||
go.uber.org/multierr v1.11.0 // indirect
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
golang.org/x/arch v0.22.0 // indirect
|
golang.org/x/arch v0.22.0 // indirect
|
||||||
golang.org/x/crypto v0.55.0 // indirect
|
golang.org/x/crypto v0.55.0 // indirect
|
||||||
golang.org/x/net v0.58.0 // indirect
|
|
||||||
golang.org/x/sync v0.22.0 // indirect
|
golang.org/x/sync v0.22.0 // indirect
|
||||||
golang.org/x/sys v0.47.0 // indirect
|
golang.org/x/sys v0.47.0 // indirect
|
||||||
golang.org/x/text v0.41.0 // indirect
|
golang.org/x/text v0.41.0 // indirect
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
@@ -12,28 +13,51 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/tdvorak/dash/internal/domain"
|
||||||
|
"github.com/tdvorak/dash/internal/widget"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Server holds handler dependencies: DB, icon dir, status + widget caches.
|
// Server holds handler dependencies: DB, icon dir, logger, caches.
|
||||||
type Server struct {
|
type Server struct {
|
||||||
db *sql.DB
|
db *sql.DB
|
||||||
iconsDir string
|
iconsDir string
|
||||||
|
log *zap.Logger
|
||||||
status *statusCache
|
status *statusCache
|
||||||
widgets *widgetCache
|
widgets *widgetCache
|
||||||
|
// lookup performs domain refreshes; swappable so tests don't hit the
|
||||||
|
// real WHOIS/RDAP/DNS path.
|
||||||
|
lookup func(ctx context.Context, name string) *domain.Result
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewRouter builds the HTTP handler: zap access log, recovery, /api routes.
|
func newServer(logger *zap.Logger, db *sql.DB, iconsDir string) *Server {
|
||||||
func NewRouter(logger *zap.Logger, db *sql.DB, iconsDir string) *gin.Engine {
|
|
||||||
s := &Server{
|
s := &Server{
|
||||||
db: db,
|
db: db,
|
||||||
iconsDir: iconsDir,
|
iconsDir: iconsDir,
|
||||||
|
log: logger,
|
||||||
status: newStatusCache(60 * time.Second),
|
status: newStatusCache(60 * time.Second),
|
||||||
widgets: newWidgetCache(30 * time.Second),
|
widgets: newWidgetCache(30 * time.Second),
|
||||||
}
|
}
|
||||||
|
s.lookup = domain.NewLookup().Run
|
||||||
|
widget.Register(&monitorWidget{db: db})
|
||||||
|
widget.Register(&domainWidget{db: db})
|
||||||
|
widget.Register(&systemWidget{db: db})
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRouter builds the HTTP handler: zap access log, recovery, /api routes.
|
||||||
|
// A non-nil ctx starts the monitor scheduler; nil keeps it off (tests).
|
||||||
|
func NewRouter(ctx context.Context, logger *zap.Logger, db *sql.DB, iconsDir string) *gin.Engine {
|
||||||
|
s := newServer(logger, db, iconsDir)
|
||||||
|
if ctx != nil {
|
||||||
|
go s.runScheduler(ctx.Done())
|
||||||
|
}
|
||||||
|
return s.routes()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) routes() *gin.Engine {
|
||||||
r := gin.New()
|
r := gin.New()
|
||||||
r.Use(accessLog(logger), gin.Recovery())
|
r.Use(accessLog(s.log), gin.Recovery())
|
||||||
|
|
||||||
v1 := r.Group("/api")
|
v1 := r.Group("/api")
|
||||||
v1.GET("/healthz", s.healthz)
|
v1.GET("/healthz", s.healthz)
|
||||||
@@ -51,6 +75,52 @@ func NewRouter(logger *zap.Logger, db *sql.DB, iconsDir string) *gin.Engine {
|
|||||||
v1.POST("/items/:id/icon", s.uploadIcon)
|
v1.POST("/items/:id/icon", s.uploadIcon)
|
||||||
v1.GET("/icons/:file", s.getIcon)
|
v1.GET("/icons/:file", s.getIcon)
|
||||||
|
|
||||||
|
v1.GET("/monitors", s.listMonitorsH)
|
||||||
|
v1.POST("/monitors", s.createMonitor)
|
||||||
|
v1.GET("/monitors/:id", s.getMonitor)
|
||||||
|
v1.PATCH("/monitors/:id", s.patchMonitor)
|
||||||
|
v1.DELETE("/monitors/:id", s.deleteMonitor)
|
||||||
|
v1.GET("/monitors/:id/heartbeats", s.monitorHeartbeats)
|
||||||
|
v1.POST("/monitors/:id/check", s.checkNow)
|
||||||
|
v1.Any("/push/:token", s.pushIngest)
|
||||||
|
|
||||||
|
v1.GET("/domains", s.listDomainsH)
|
||||||
|
v1.POST("/domains", s.createDomain)
|
||||||
|
v1.GET("/domains/:id", s.getDomain)
|
||||||
|
v1.PATCH("/domains/:id", s.patchDomain)
|
||||||
|
v1.DELETE("/domains/:id", s.deleteDomain)
|
||||||
|
v1.POST("/domains/:id/refresh", s.refreshDomainH)
|
||||||
|
v1.GET("/domains/:id/checks", s.domainChecks)
|
||||||
|
v1.POST("/notify/test", s.notifyTest)
|
||||||
|
|
||||||
|
v1.GET("/systems", s.listSystemsH)
|
||||||
|
v1.POST("/systems", s.createSystem)
|
||||||
|
v1.GET("/systems/:id", s.getSystem)
|
||||||
|
v1.PATCH("/systems/:id", s.patchSystem)
|
||||||
|
v1.DELETE("/systems/:id", s.deleteSystem)
|
||||||
|
v1.GET("/systems/:id/stats", s.systemStats)
|
||||||
|
v1.POST("/systems/ingest", s.ingestSystem)
|
||||||
|
|
||||||
|
v1.GET("/incidents", s.listIncidents)
|
||||||
|
v1.POST("/incidents", s.createIncident)
|
||||||
|
v1.PATCH("/incidents/:id", s.patchIncident)
|
||||||
|
v1.DELETE("/incidents/:id", s.deleteIncident)
|
||||||
|
v1.POST("/incidents/:id/updates", s.addIncidentUpdate)
|
||||||
|
|
||||||
|
v1.GET("/maintenance", s.listWindows)
|
||||||
|
v1.POST("/maintenance", s.createWindow)
|
||||||
|
v1.DELETE("/maintenance/:id", s.deleteWindow)
|
||||||
|
|
||||||
|
v1.GET("/status-pages", s.listStatusPages)
|
||||||
|
v1.POST("/status-pages", s.createStatusPage)
|
||||||
|
v1.PATCH("/status-pages/:id", s.patchStatusPage)
|
||||||
|
v1.DELETE("/status-pages/:id", s.deleteStatusPage)
|
||||||
|
v1.GET("/status-pages/:slug/public", s.publicStatus)
|
||||||
|
|
||||||
|
v1.GET("/badge/:kind/:file", s.badge)
|
||||||
|
v1.GET("/metrics", s.metrics)
|
||||||
|
v1.POST("/import/csv", s.importCSV)
|
||||||
|
|
||||||
v1.GET("/status", s.getStatus)
|
v1.GET("/status", s.getStatus)
|
||||||
v1.GET("/widgets/types", s.widgetTypes)
|
v1.GET("/widgets/types", s.widgetTypes)
|
||||||
v1.GET("/widgets/:id/data", s.widgetData)
|
v1.GET("/widgets/:id/data", s.widgetData)
|
||||||
@@ -58,6 +128,7 @@ func NewRouter(logger *zap.Logger, db *sql.DB, iconsDir string) *gin.Engine {
|
|||||||
v1.PUT("/settings", s.putSettings)
|
v1.PUT("/settings", s.putSettings)
|
||||||
v1.GET("/export", s.exportBoard)
|
v1.GET("/export", s.exportBoard)
|
||||||
v1.POST("/import", s.importBoard)
|
v1.POST("/import", s.importBoard)
|
||||||
|
v1.POST("/import/external", s.importExternal)
|
||||||
|
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -11,6 +13,7 @@ import (
|
|||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/tdvorak/dash/internal/db"
|
"github.com/tdvorak/dash/internal/db"
|
||||||
|
"github.com/tdvorak/dash/internal/domain"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -22,7 +25,22 @@ func testServer(t *testing.T) *httptest.Server {
|
|||||||
}
|
}
|
||||||
t.Cleanup(func() { d.Close() })
|
t.Cleanup(func() { d.Close() })
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
return httptest.NewServer(NewRouter(zap.NewNop(), d, t.TempDir()))
|
s := newServer(zap.NewNop(), d, t.TempDir())
|
||||||
|
// Stub the domain lookup — real RDAP/WHOIS/DNS would make tests slow and
|
||||||
|
// network-dependent.
|
||||||
|
s.lookup = func(_ context.Context, name string) *domain.Result {
|
||||||
|
exp := time.Now().Add(90 * 24 * time.Hour).UTC()
|
||||||
|
return &domain.Result{
|
||||||
|
Name: name,
|
||||||
|
RegistrarName: "Test Registrar",
|
||||||
|
ExpiryDate: &exp,
|
||||||
|
IPv4: []string{"93.184.216.34"},
|
||||||
|
NameServers: []string{"ns1.example.com"},
|
||||||
|
Headers: map[string]string{},
|
||||||
|
FaviconURL: "https://www.google.com/s2/favicons?domain=" + name,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return httptest.NewServer(s.routes())
|
||||||
}
|
}
|
||||||
|
|
||||||
func do(t *testing.T, method, url, body string) (*http.Response, map[string]any) {
|
func do(t *testing.T, method, url, body string) (*http.Response, map[string]any) {
|
||||||
@@ -178,6 +196,568 @@ func TestWidgetEndpoints(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestImportExternalHomepage(t *testing.T) {
|
||||||
|
srv := testServer(t)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
body := `- Media:
|
||||||
|
- Plex:
|
||||||
|
href: http://plex.local:32400
|
||||||
|
icon: plex
|
||||||
|
- NoLink:
|
||||||
|
description: widget-only entry
|
||||||
|
- Apps:
|
||||||
|
- Sonarr:
|
||||||
|
href: https://sonarr.example.com
|
||||||
|
icon: https://icons.example.com/sonarr.png
|
||||||
|
`
|
||||||
|
res, _ := do(t, "POST", srv.URL+"/api/import/external", body)
|
||||||
|
if res.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("homepage import: %d", res.StatusCode)
|
||||||
|
}
|
||||||
|
var secs []Section
|
||||||
|
resp, err := http.Get(srv.URL + "/api/sections")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get sections: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&secs); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if len(secs) != 2 {
|
||||||
|
t.Fatalf("expected 2 sections, got %+v", secs)
|
||||||
|
}
|
||||||
|
media := secs[0]
|
||||||
|
if media.Name != "Media" || len(media.Items) != 1 {
|
||||||
|
t.Fatalf("bad Media section: %+v", media)
|
||||||
|
}
|
||||||
|
if media.Items[0].Name != "Plex" || len(media.Items[0].URLs) != 1 {
|
||||||
|
t.Fatalf("bad Plex item: %+v", media.Items[0])
|
||||||
|
}
|
||||||
|
if media.Items[0].Icon == "" {
|
||||||
|
t.Fatal("expected resolved CDN icon")
|
||||||
|
}
|
||||||
|
apps := secs[1]
|
||||||
|
if apps.Items[0].Icon != "https://icons.example.com/sonarr.png" {
|
||||||
|
t.Fatalf("icon passthrough failed: %+v", apps.Items[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImportExternalDashy(t *testing.T) {
|
||||||
|
srv := testServer(t)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
body := `appConfig:
|
||||||
|
title: test
|
||||||
|
sections:
|
||||||
|
- name: Media
|
||||||
|
items:
|
||||||
|
- title: Plex
|
||||||
|
url: http://plex.local
|
||||||
|
icon: favicon
|
||||||
|
- title: Broken
|
||||||
|
url: "notaurl"
|
||||||
|
`
|
||||||
|
res, _ := do(t, "POST", srv.URL+"/api/import/external", body)
|
||||||
|
if res.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("dashy import: %d", res.StatusCode)
|
||||||
|
}
|
||||||
|
var secs []Section
|
||||||
|
resp, err := http.Get(srv.URL + "/api/sections")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get sections: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&secs); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if len(secs) != 1 || len(secs[0].Items) != 1 {
|
||||||
|
t.Fatalf("bad sections: %+v", secs)
|
||||||
|
}
|
||||||
|
if secs[0].Items[0].Icon != "http://plex.local/favicon.ico" {
|
||||||
|
t.Fatalf("favicon resolution failed: %+v", secs[0].Items[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImportExternalHomarr(t *testing.T) {
|
||||||
|
srv := testServer(t)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
body := `{"apps":[{"name":"Plex","url":"http://plex.local","icon":"hl-plex","categoryId":"c1"},{"name":"Sonarr","url":"http://sonarr.local","categoryId":"c1"}],"categories":[{"id":"c1","name":"Media"}]}`
|
||||||
|
res, _ := do(t, "POST", srv.URL+"/api/import/external", body)
|
||||||
|
if res.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("homarr import: %d", res.StatusCode)
|
||||||
|
}
|
||||||
|
var secs []Section
|
||||||
|
resp, err := http.Get(srv.URL + "/api/sections")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get sections: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&secs); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if len(secs) != 1 || secs[0].Name != "Media" || len(secs[0].Items) != 2 {
|
||||||
|
t.Fatalf("bad sections: %+v", secs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImportExternalRejectsGarbage(t *testing.T) {
|
||||||
|
srv := testServer(t)
|
||||||
|
defer srv.Close()
|
||||||
|
res, body := do(t, "POST", srv.URL+"/api/import/external", `{"foo": 1}`)
|
||||||
|
if res.StatusCode != http.StatusBadRequest {
|
||||||
|
t.Fatalf("expected 400, got %d: %v", res.StatusCode, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMonitorLifecycle(t *testing.T) {
|
||||||
|
srv := testServer(t)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
// Target that returns 200.
|
||||||
|
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
defer up.Close()
|
||||||
|
|
||||||
|
res, mv := do(t, "POST", srv.URL+"/api/monitors", `{"name":"Site","type":"http","url":"`+up.URL+`","intervalS":60}`)
|
||||||
|
if res.StatusCode != http.StatusCreated {
|
||||||
|
t.Fatalf("create: %d %v", res.StatusCode, mv)
|
||||||
|
}
|
||||||
|
id := mv["id"].(string)
|
||||||
|
|
||||||
|
// check-now -> up, heartbeat recorded.
|
||||||
|
res, mv = do(t, "POST", srv.URL+"/api/monitors/"+id+"/check", "")
|
||||||
|
if res.StatusCode != http.StatusOK || mv["status"] != "up" {
|
||||||
|
t.Fatalf("check-now: %d %v", res.StatusCode, mv)
|
||||||
|
}
|
||||||
|
resp, err := http.Get(srv.URL + "/api/monitors/" + id + "/heartbeats")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("heartbeats: %v", err)
|
||||||
|
}
|
||||||
|
var hbs []map[string]any
|
||||||
|
_ = json.NewDecoder(resp.Body).Decode(&hbs)
|
||||||
|
resp.Body.Close()
|
||||||
|
if len(hbs) != 1 || hbs[0]["status"] != "up" {
|
||||||
|
t.Fatalf("expected 1 up heartbeat, got %v", hbs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// pause -> paused + inactive
|
||||||
|
res, mv = do(t, "PATCH", srv.URL+"/api/monitors/"+id, `{"active":false}`)
|
||||||
|
if res.StatusCode != http.StatusOK || mv["status"] != "paused" || mv["active"] != false {
|
||||||
|
t.Fatalf("pause: %v", mv)
|
||||||
|
}
|
||||||
|
|
||||||
|
// delete
|
||||||
|
res, _ = do(t, "DELETE", srv.URL+"/api/monitors/"+id, "")
|
||||||
|
if res.StatusCode != http.StatusNoContent {
|
||||||
|
t.Fatalf("delete: %d", res.StatusCode)
|
||||||
|
}
|
||||||
|
res, _ = do(t, "GET", srv.URL+"/api/monitors/"+id, "")
|
||||||
|
if res.StatusCode != http.StatusNotFound {
|
||||||
|
t.Fatalf("expected 404 after delete, got %d", res.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMonitorCheckDown(t *testing.T) {
|
||||||
|
srv := testServer(t)
|
||||||
|
defer srv.Close()
|
||||||
|
res, mv := do(t, "POST", srv.URL+"/api/monitors", `{"name":"Dead","type":"http","url":"http://127.0.0.1:1","timeoutS":1}`)
|
||||||
|
if res.StatusCode != http.StatusCreated {
|
||||||
|
t.Fatalf("create: %v", mv)
|
||||||
|
}
|
||||||
|
id := mv["id"].(string)
|
||||||
|
res, mv = do(t, "POST", srv.URL+"/api/monitors/"+id+"/check", "")
|
||||||
|
if res.StatusCode != http.StatusOK || mv["status"] != "down" {
|
||||||
|
t.Fatalf("expected down, got %v", mv)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPushMonitor(t *testing.T) {
|
||||||
|
srv := testServer(t)
|
||||||
|
defer srv.Close()
|
||||||
|
res, mv := do(t, "POST", srv.URL+"/api/monitors", `{"name":"Backup","type":"push","intervalS":60}`)
|
||||||
|
if res.StatusCode != http.StatusCreated {
|
||||||
|
t.Fatalf("create: %v", mv)
|
||||||
|
}
|
||||||
|
token, _ := mv["pushToken"].(string)
|
||||||
|
if token == "" {
|
||||||
|
t.Fatal("no push token issued")
|
||||||
|
}
|
||||||
|
res, _ = do(t, "GET", srv.URL+"/api/push/"+token+"?msg=done", "")
|
||||||
|
if res.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("push: %d", res.StatusCode)
|
||||||
|
}
|
||||||
|
res, mv = do(t, "GET", srv.URL+"/api/monitors/"+mv["id"].(string), "")
|
||||||
|
if mv["status"] != "up" {
|
||||||
|
t.Fatalf("expected up after push, got %v", mv["status"])
|
||||||
|
}
|
||||||
|
res, _ = do(t, "GET", srv.URL+"/api/push/bogus", "")
|
||||||
|
if res.StatusCode != http.StatusNotFound {
|
||||||
|
t.Fatalf("bad token: %d", res.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDomainLifecycle(t *testing.T) {
|
||||||
|
srv := testServer(t)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
// Garbage name -> 400 before any lookup happens.
|
||||||
|
res, body := do(t, "POST", srv.URL+"/api/domains", `{"name":"not a domain!!"}`)
|
||||||
|
if res.StatusCode != http.StatusBadRequest {
|
||||||
|
t.Fatalf("expected 400, got %d: %v", res.StatusCode, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Valid name creates even when lookups fail (unresolvable TLD).
|
||||||
|
res, dv := do(t, "POST", srv.URL+"/api/domains", `{"name":"nonexistent.invalid","alertDaysBefore":14}`)
|
||||||
|
if res.StatusCode != http.StatusCreated {
|
||||||
|
t.Fatalf("create: %d %v", res.StatusCode, dv)
|
||||||
|
}
|
||||||
|
if dv["tld"] != "invalid" || dv["name"] != "nonexistent.invalid" {
|
||||||
|
t.Fatalf("bad domain row: %v", dv)
|
||||||
|
}
|
||||||
|
// Stubbed lookup populated registration data + computed countdown.
|
||||||
|
if dv["registrarName"] != "Test Registrar" || dv["daysUntilExpiry"] == nil {
|
||||||
|
t.Fatalf("lookup not applied: %v", dv)
|
||||||
|
}
|
||||||
|
id := dv["id"].(string)
|
||||||
|
|
||||||
|
// Duplicate name -> 409.
|
||||||
|
res, _ = do(t, "POST", srv.URL+"/api/domains", `{"name":"nonexistent.invalid"}`)
|
||||||
|
if res.StatusCode != http.StatusConflict {
|
||||||
|
t.Fatalf("expected 409, got %d", res.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Patch alert threshold round-trips.
|
||||||
|
res, dv = do(t, "PATCH", srv.URL+"/api/domains/"+id, `{"alertDaysBefore":7,"autoRenew":true}`)
|
||||||
|
if res.StatusCode != http.StatusOK || dv["alertDaysBefore"] != float64(7) || dv["autoRenew"] != true {
|
||||||
|
t.Fatalf("patch: %v", dv)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh endpoint answers (lookup result content is network-dependent).
|
||||||
|
res, dv = do(t, "POST", srv.URL+"/api/domains/"+id+"/refresh", "")
|
||||||
|
if res.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("refresh: %d %v", res.StatusCode, dv)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Checks history endpoint responds with a list.
|
||||||
|
resp, err := http.Get(srv.URL + "/api/domains/" + id + "/checks")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("checks: %v", err)
|
||||||
|
}
|
||||||
|
var checks []map[string]any
|
||||||
|
_ = json.NewDecoder(resp.Body).Decode(&checks)
|
||||||
|
resp.Body.Close()
|
||||||
|
|
||||||
|
res, _ = do(t, "DELETE", srv.URL+"/api/domains/"+id, "")
|
||||||
|
if res.StatusCode != http.StatusNoContent {
|
||||||
|
t.Fatalf("delete: %d", res.StatusCode)
|
||||||
|
}
|
||||||
|
res, _ = do(t, "GET", srv.URL+"/api/domains/"+id, "")
|
||||||
|
if res.StatusCode != http.StatusNotFound {
|
||||||
|
t.Fatalf("expected 404 after delete, got %d", res.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDomainWidget(t *testing.T) {
|
||||||
|
srv := testServer(t)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
res, dv := do(t, "POST", srv.URL+"/api/domains", `{"name":"widget.invalid"}`)
|
||||||
|
if res.StatusCode != http.StatusCreated {
|
||||||
|
t.Fatalf("create: %d %v", res.StatusCode, dv)
|
||||||
|
}
|
||||||
|
_, a := do(t, "POST", srv.URL+"/api/sections", `{"name":"A"}`)
|
||||||
|
_, w := do(t, "POST", srv.URL+"/api/items", `{"sectionId":"`+a["id"].(string)+`","kind":"widget","name":"D","config":{"type":"domain","domainId":"`+dv["id"].(string)+`"}}`)
|
||||||
|
res, data := do(t, "GET", srv.URL+"/api/widgets/"+w["id"].(string)+"/data", "")
|
||||||
|
inner, _ := data["data"].(map[string]any)
|
||||||
|
if res.StatusCode != http.StatusOK || inner["name"] != "widget.invalid" {
|
||||||
|
t.Fatalf("domain widget: %d %v", res.StatusCode, data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNotifyWebhook(t *testing.T) {
|
||||||
|
srv := testServer(t)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
// Receiver captures the webhook payload.
|
||||||
|
got := make(chan map[string]any, 1)
|
||||||
|
hook := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var body map[string]any
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||||
|
got <- body
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
defer hook.Close()
|
||||||
|
|
||||||
|
// Unconfigured -> 400.
|
||||||
|
res, _ := do(t, "POST", srv.URL+"/api/notify/test", "")
|
||||||
|
if res.StatusCode != http.StatusBadRequest {
|
||||||
|
t.Fatalf("expected 400 without webhook, got %d", res.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configure + test.
|
||||||
|
if res, _ := do(t, "PUT", srv.URL+"/api/settings", `{"notify_webhook":"`+hook.URL+`"}`); res.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("put settings: %d", res.StatusCode)
|
||||||
|
}
|
||||||
|
res, _ = do(t, "POST", srv.URL+"/api/notify/test", "")
|
||||||
|
if res.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("notify test: %d", res.StatusCode)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case body := <-got:
|
||||||
|
if body["event"] != "test" || body["text"] == "" {
|
||||||
|
t.Fatalf("bad payload: %v", body)
|
||||||
|
}
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
t.Fatal("webhook never received the test notification")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Monitor transition fires monitor.down through the same webhook.
|
||||||
|
res, mv := do(t, "POST", srv.URL+"/api/monitors", `{"name":"Flip","type":"http","url":"http://127.0.0.1:1","timeoutS":1}`)
|
||||||
|
if res.StatusCode != http.StatusCreated {
|
||||||
|
t.Fatalf("create: %v", mv)
|
||||||
|
}
|
||||||
|
id := mv["id"].(string)
|
||||||
|
// pending -> down (no fire), then up -> down needs an up first.
|
||||||
|
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) }))
|
||||||
|
defer up.Close()
|
||||||
|
res, _ = do(t, "PATCH", srv.URL+"/api/monitors/"+id, `{"url":"`+up.URL+`"}`)
|
||||||
|
if res.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("patch: %d", res.StatusCode)
|
||||||
|
}
|
||||||
|
do(t, "POST", srv.URL+"/api/monitors/"+id+"/check", "") // -> up
|
||||||
|
do(t, "PATCH", srv.URL+"/api/monitors/"+id, `{"url":"http://127.0.0.1:1","timeoutS":1}`)
|
||||||
|
do(t, "POST", srv.URL+"/api/monitors/"+id+"/check", "") // -> down, fires webhook
|
||||||
|
select {
|
||||||
|
case body := <-got:
|
||||||
|
if body["event"] != "monitor.down" {
|
||||||
|
t.Fatalf("expected monitor.down, got %v", body)
|
||||||
|
}
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
t.Fatal("webhook never received monitor.down")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSystemIngest(t *testing.T) {
|
||||||
|
srv := testServer(t)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
// No token -> 401.
|
||||||
|
res, _ := do(t, "POST", srv.URL+"/api/systems/ingest", `{"cpu":1}`)
|
||||||
|
if res.StatusCode != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("expected 401, got %d", res.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
res, sys := do(t, "POST", srv.URL+"/api/systems", `{"name":"nas"}`)
|
||||||
|
if res.StatusCode != http.StatusCreated {
|
||||||
|
t.Fatalf("create: %v", sys)
|
||||||
|
}
|
||||||
|
id, token := sys["id"].(string), sys["token"].(string)
|
||||||
|
if token == "" {
|
||||||
|
t.Fatal("no agent token issued")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bad token -> 401.
|
||||||
|
req, _ := http.NewRequest("POST", srv.URL+"/api/systems/ingest",
|
||||||
|
strings.NewReader(`{"cpu":50}`))
|
||||||
|
req.Header.Set("Authorization", "Bearer wrong")
|
||||||
|
if r, err := http.DefaultClient.Do(req); err != nil || r.StatusCode != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("bad token: %v %v", r.StatusCode, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Real push -> system goes up, latest + stats stored.
|
||||||
|
push := func() (*http.Response, map[string]any) {
|
||||||
|
t.Helper()
|
||||||
|
req, _ := http.NewRequest("POST", srv.URL+"/api/systems/ingest",
|
||||||
|
strings.NewReader(`{"host":"nas.local","os":"linux","arch":"amd64",
|
||||||
|
"cpuModel":"Test CPU","cores":4,"cpu":42.5,"memTotal":8000,"memUsed":4000,
|
||||||
|
"diskTotal":100000,"diskUsed":50000,"netRx":100,"netTx":50,"load1":1.5}`))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
res, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("push: %v", err)
|
||||||
|
}
|
||||||
|
defer res.Body.Close()
|
||||||
|
var out map[string]any
|
||||||
|
_ = json.NewDecoder(res.Body).Decode(&out)
|
||||||
|
return res, out
|
||||||
|
}
|
||||||
|
if res, out := push(); res.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("ingest: %v", out)
|
||||||
|
}
|
||||||
|
|
||||||
|
res, sys = do(t, "GET", srv.URL+"/api/systems/"+id, "")
|
||||||
|
if res.StatusCode != http.StatusOK || sys["status"] != "up" {
|
||||||
|
t.Fatalf("get: %v", sys)
|
||||||
|
}
|
||||||
|
if sys["host"] != "nas.local" || sys["cores"].(float64) != 4 {
|
||||||
|
t.Fatalf("host fields not stored: %v", sys)
|
||||||
|
}
|
||||||
|
latest := sys["latest"].(map[string]any)
|
||||||
|
if latest["cpu"].(float64) != 42.5 {
|
||||||
|
t.Fatalf("latest not stored: %v", latest)
|
||||||
|
}
|
||||||
|
|
||||||
|
res, list := do(t, "GET", srv.URL+"/api/systems/"+id+"/stats?hours=1", "")
|
||||||
|
if res.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("stats: %v", list)
|
||||||
|
}
|
||||||
|
// stats returns an array — decode separately.
|
||||||
|
req2, _ := http.NewRequest("GET", srv.URL+"/api/systems/"+id+"/stats?hours=1", nil)
|
||||||
|
r2, _ := http.DefaultClient.Do(req2)
|
||||||
|
var arr []map[string]any
|
||||||
|
_ = json.NewDecoder(r2.Body).Decode(&arr)
|
||||||
|
r2.Body.Close()
|
||||||
|
if len(arr) != 1 || arr[0]["cpu"].(float64) != 42.5 {
|
||||||
|
t.Fatalf("stats rows: %v", arr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Widget fetcher serves the latest sample.
|
||||||
|
_, a := do(t, "POST", srv.URL+"/api/sections", `{"name":"A"}`)
|
||||||
|
_, w := do(t, "POST", srv.URL+"/api/items", `{"sectionId":"`+a["id"].(string)+`","kind":"widget","name":"S","config":{"type":"system","systemId":"`+id+`"}}`)
|
||||||
|
res, data := do(t, "GET", srv.URL+"/api/widgets/"+w["id"].(string)+"/data", "")
|
||||||
|
inner, _ := data["data"].(map[string]any)
|
||||||
|
if res.StatusCode != http.StatusOK || inner["name"] != "nas" || inner["cpu"].(float64) != 42.5 {
|
||||||
|
t.Fatalf("system widget: %d %v", res.StatusCode, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rename + token rotation.
|
||||||
|
res, sys = do(t, "PATCH", srv.URL+"/api/systems/"+id, `{"name":"nas2","rotateToken":true}`)
|
||||||
|
if sys["name"] != "nas2" || sys["token"] == token {
|
||||||
|
t.Fatalf("patch: %v", sys)
|
||||||
|
}
|
||||||
|
|
||||||
|
res, _ = do(t, "DELETE", srv.URL+"/api/systems/"+id, "")
|
||||||
|
if res.StatusCode != http.StatusNoContent {
|
||||||
|
t.Fatalf("delete: %d", res.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpsLayer(t *testing.T) {
|
||||||
|
srv := testServer(t)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
// Monitor that flips up->down should auto-open an incident, and
|
||||||
|
// recovery should auto-resolve it.
|
||||||
|
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) }))
|
||||||
|
defer up.Close()
|
||||||
|
_, mv := do(t, "POST", srv.URL+"/api/monitors",
|
||||||
|
`{"name":"Flip","type":"http","url":"`+up.URL+`","timeoutS":1}`)
|
||||||
|
id := mv["id"].(string)
|
||||||
|
do(t, "POST", srv.URL+"/api/monitors/"+id+"/check", "") // up
|
||||||
|
do(t, "PATCH", srv.URL+"/api/monitors/"+id, `{"url":"http://127.0.0.1:1"}`)
|
||||||
|
do(t, "POST", srv.URL+"/api/monitors/"+id+"/check", "") // down -> incident
|
||||||
|
|
||||||
|
res, _ := do(t, "GET", srv.URL+"/api/incidents?status=open", "")
|
||||||
|
if res.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("incidents: %d", res.StatusCode)
|
||||||
|
}
|
||||||
|
req, _ := http.NewRequest("GET", srv.URL+"/api/incidents?status=open", nil)
|
||||||
|
r, _ := http.DefaultClient.Do(req)
|
||||||
|
var incs []map[string]any
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&incs)
|
||||||
|
r.Body.Close()
|
||||||
|
if len(incs) != 1 || incs[0]["monitorId"] != id {
|
||||||
|
t.Fatalf("auto incident: %v", incs)
|
||||||
|
}
|
||||||
|
incID := incs[0]["id"].(string)
|
||||||
|
|
||||||
|
do(t, "PATCH", srv.URL+"/api/monitors/"+id, `{"url":"`+up.URL+`"}`)
|
||||||
|
do(t, "POST", srv.URL+"/api/monitors/"+id+"/check", "") // up -> resolved
|
||||||
|
res, inc := do(t, "GET", srv.URL+"/api/incidents?status=open", "")
|
||||||
|
req, _ = http.NewRequest("GET", srv.URL+"/api/incidents?status=open", nil)
|
||||||
|
r, _ = http.DefaultClient.Do(req)
|
||||||
|
incs = nil
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&incs)
|
||||||
|
r.Body.Close()
|
||||||
|
if len(incs) != 0 {
|
||||||
|
t.Fatalf("incident should auto-resolve, got %v", incs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manual incident lifecycle: create -> update -> resolve.
|
||||||
|
res, inc = do(t, "POST", srv.URL+"/api/incidents",
|
||||||
|
`{"title":"disk full","severity":"critical","message":"nas raid degraded"}`)
|
||||||
|
if res.StatusCode != http.StatusCreated || inc["status"] != "open" {
|
||||||
|
t.Fatalf("create incident: %v", inc)
|
||||||
|
}
|
||||||
|
incID = inc["id"].(string)
|
||||||
|
res, inc = do(t, "PATCH", srv.URL+"/api/incidents/"+incID, `{"status":"resolved","message":"swapped drive"}`)
|
||||||
|
if inc["status"] != "resolved" {
|
||||||
|
t.Fatalf("resolve: %v", inc)
|
||||||
|
}
|
||||||
|
ups, _ := inc["updates"].([]any)
|
||||||
|
if len(ups) < 2 {
|
||||||
|
t.Fatalf("updates timeline: %v", inc["updates"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Maintenance window suppresses a transition's side-effects.
|
||||||
|
_, mw := do(t, "POST", srv.URL+"/api/maintenance",
|
||||||
|
`{"title":"upgrade","startsAt":"`+time.Now().Add(-time.Hour).UTC().Format(time.RFC3339)+
|
||||||
|
`","endsAt":"`+time.Now().Add(time.Hour).UTC().Format(time.RFC3339)+`"}`)
|
||||||
|
if mw["active"] != true {
|
||||||
|
t.Fatalf("window should be active: %v", mw)
|
||||||
|
}
|
||||||
|
do(t, "PATCH", srv.URL+"/api/monitors/"+id, `{"url":"http://127.0.0.1:1"}`)
|
||||||
|
do(t, "POST", srv.URL+"/api/monitors/"+id+"/check", "") // down under maintenance
|
||||||
|
req, _ = http.NewRequest("GET", srv.URL+"/api/incidents?status=open", nil)
|
||||||
|
r, _ = http.DefaultClient.Do(req)
|
||||||
|
incs = nil
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&incs)
|
||||||
|
r.Body.Close()
|
||||||
|
if len(incs) != 0 {
|
||||||
|
t.Fatalf("maintenance must suppress auto-incidents: %v", incs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status page + public view shows maintenance state.
|
||||||
|
res, sp := do(t, "POST", srv.URL+"/api/status-pages", `{"title":"Ops","slug":"ops"}`)
|
||||||
|
if res.StatusCode != http.StatusCreated || sp["slug"] != "ops" {
|
||||||
|
t.Fatalf("status page: %v", sp)
|
||||||
|
}
|
||||||
|
res, pub := do(t, "GET", srv.URL+"/api/status-pages/ops/public", "")
|
||||||
|
if res.StatusCode != http.StatusOK || pub["overall"] != "maintenance" {
|
||||||
|
t.Fatalf("public status: %v", pub)
|
||||||
|
}
|
||||||
|
mons, _ := pub["monitors"].([]any)
|
||||||
|
if len(mons) != 1 {
|
||||||
|
t.Fatalf("public monitors: %v", pub["monitors"])
|
||||||
|
}
|
||||||
|
if m0, _ := mons[0].(map[string]any); m0["status"] != "maintenance" {
|
||||||
|
t.Fatalf("monitor should read maintenance: %v", m0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Badge + metrics smoke.
|
||||||
|
res, _ = do(t, "GET", srv.URL+"/api/badge/monitor/"+id+".svg", "")
|
||||||
|
if res.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("badge: %d", res.StatusCode)
|
||||||
|
}
|
||||||
|
res, _ = do(t, "GET", srv.URL+"/api/metrics", "")
|
||||||
|
if res.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("metrics: %d", res.StatusCode)
|
||||||
|
}
|
||||||
|
req, _ = http.NewRequest("GET", srv.URL+"/api/metrics", nil)
|
||||||
|
r, _ = http.DefaultClient.Do(req)
|
||||||
|
body, _ := io.ReadAll(r.Body)
|
||||||
|
r.Body.Close()
|
||||||
|
if !strings.Contains(string(body), "dash_monitors_total") {
|
||||||
|
t.Fatalf("metrics body: %s", body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CSV import.
|
||||||
|
req, _ = http.NewRequest("POST", srv.URL+"/api/import/csv?kind=monitors",
|
||||||
|
strings.NewReader("name,type,url\nA,http,https://a.example\nB,tcp,https://b.example:9\n"))
|
||||||
|
req.Header.Set("Content-Type", "text/csv")
|
||||||
|
r, _ = http.DefaultClient.Do(req)
|
||||||
|
var out map[string]any
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&out)
|
||||||
|
r.Body.Close()
|
||||||
|
if out["created"].(float64) != 2 {
|
||||||
|
t.Fatalf("csv import: %v", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestMidpoint(t *testing.T) {
|
func TestMidpoint(t *testing.T) {
|
||||||
f := func(v float64) *float64 { return &v }
|
f := func(v float64) *float64 { return &v }
|
||||||
if got := midpoint(nil, nil); got != 1024 {
|
if got := midpoint(nil, nil); got != 1024 {
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Stateless SVG shields rendered from live rows: /api/badge/<kind>/<id>.svg
|
||||||
|
// kinds: monitor (status+uptime), domain (days to expiry), system (status+cpu).
|
||||||
|
|
||||||
|
var badgeColors = map[string]string{
|
||||||
|
"up": "#3fb950",
|
||||||
|
"down": "#f85149",
|
||||||
|
"pending": "#8b949e",
|
||||||
|
"expired": "#f85149",
|
||||||
|
"expiring": "#d29922",
|
||||||
|
"ok": "#3fb950",
|
||||||
|
"unknown": "#8b949e",
|
||||||
|
}
|
||||||
|
|
||||||
|
func badgeSVG(c *gin.Context, label, value, color string) {
|
||||||
|
// Fixed-width font assumption is deliberate — shields embed anywhere.
|
||||||
|
lw := 6*len(label) + 12
|
||||||
|
vw := 6*len(value) + 12
|
||||||
|
c.Header("Content-Type", "image/svg+xml")
|
||||||
|
c.Header("Cache-Control", "no-cache")
|
||||||
|
c.String(http.StatusOK, fmt.Sprintf(`<svg xmlns="http://www.w3.org/2000/svg" width="%d" height="20" role="img" aria-label="%s: %s">
|
||||||
|
<linearGradient id="s" x2="0" y2="100%%"><stop offset="0" stop-color="#fff" stop-opacity=".7"/><stop offset="1" stop-opacity=".7"/></linearGradient>
|
||||||
|
<clipPath id="r"><rect width="%d" height="20" rx="3"/></clipPath>
|
||||||
|
<g clip-path="url(#r)">
|
||||||
|
<rect width="%d" height="20" fill="#3a3f46"/>
|
||||||
|
<rect x="%d" width="%d" height="20" fill="%s"/>
|
||||||
|
<rect width="%d" height="20" fill="url(#s)" fill-opacity=".1"/>
|
||||||
|
</g>
|
||||||
|
<g fill="#fff" font-family="DejaVu Sans,Verdana,sans-serif" font-size="11">
|
||||||
|
<text x="%d" y="14">%s</text>
|
||||||
|
<text x="%d" y="14">%s</text>
|
||||||
|
</g></svg>`,
|
||||||
|
lw+vw, label, value, lw+vw, lw, lw, vw, color, lw+vw, 6, label, lw+6, value))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) badge(c *gin.Context) {
|
||||||
|
kind := c.Param("kind")
|
||||||
|
// Route is /badge/<kind>/<file>; the conventional suffix is optional.
|
||||||
|
id := strings.TrimSuffix(c.Param("file"), ".svg")
|
||||||
|
|
||||||
|
switch kind {
|
||||||
|
case "monitor":
|
||||||
|
var name, status string
|
||||||
|
err := s.db.QueryRow(`SELECT name, status FROM monitors WHERE id = ?`, id).Scan(&name, &status)
|
||||||
|
if err != nil {
|
||||||
|
badgeSVG(c, "monitor", "unknown", badgeColors["unknown"])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
uptime := s.monitorStats(id).Uptime24h
|
||||||
|
badgeSVG(c, name, fmt.Sprintf("%s · %.1f%%", status, uptime), badgeColors[status])
|
||||||
|
case "domain":
|
||||||
|
var name string
|
||||||
|
var expiry *string
|
||||||
|
err := s.db.QueryRow(`SELECT name, expiry_date FROM domains WHERE id = ?`, id).Scan(&name, &expiry)
|
||||||
|
if err != nil {
|
||||||
|
badgeSVG(c, "domain", "unknown", badgeColors["unknown"])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
label, color := "unknown", badgeColors["unknown"]
|
||||||
|
if days := daysUntil(expiry); days != nil {
|
||||||
|
switch {
|
||||||
|
case *days < 0:
|
||||||
|
label, color = "expired", badgeColors["expired"]
|
||||||
|
case *days <= 30:
|
||||||
|
label, color = strconv.Itoa(*days)+"d left", badgeColors["expiring"]
|
||||||
|
default:
|
||||||
|
label, color = strconv.Itoa(*days)+"d left", badgeColors["ok"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
badgeSVG(c, name, label, color)
|
||||||
|
case "system":
|
||||||
|
var name, status string
|
||||||
|
err := s.db.QueryRow(`SELECT name, status FROM systems WHERE id = ?`, id).Scan(&name, &status)
|
||||||
|
if err != nil {
|
||||||
|
badgeSVG(c, "system", "unknown", badgeColors["unknown"])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
badgeSVG(c, name, status, badgeColors[status])
|
||||||
|
default:
|
||||||
|
fail(c, http.StatusNotFound, "unknown badge kind")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/csv"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// POST /api/import/csv?kind=monitors|domains — header-driven CSV bulk import.
|
||||||
|
// Monitors: name,type,url,hostname,port,interval — name + (url|hostname) needed.
|
||||||
|
// Domains: name — one per row.
|
||||||
|
|
||||||
|
func (s *Server) importCSV(c *gin.Context) {
|
||||||
|
kind := c.DefaultQuery("kind", "monitors")
|
||||||
|
if kind != "monitors" && kind != "domains" {
|
||||||
|
fail(c, http.StatusBadRequest, "kind must be monitors or domains")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rd := csv.NewReader(c.Request.Body)
|
||||||
|
rd.FieldsPerRecord = -1 // tolerate ragged rows; header drives positions
|
||||||
|
recs, err := rd.ReadAll()
|
||||||
|
if err != nil || len(recs) < 2 {
|
||||||
|
fail(c, http.StatusBadRequest, "csv with a header row and at least one entry required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
head := map[string]int{}
|
||||||
|
for i, h := range recs[0] {
|
||||||
|
head[strings.ToLower(strings.TrimSpace(h))] = i
|
||||||
|
}
|
||||||
|
cell := func(rec []string, col string) string {
|
||||||
|
if i, ok := head[col]; ok && i < len(rec) {
|
||||||
|
return strings.TrimSpace(rec[i])
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var created, skipped int
|
||||||
|
var errs []string
|
||||||
|
for i, rec := range recs[1:] {
|
||||||
|
row := i + 2 // header is row 1
|
||||||
|
name := cell(rec, "name")
|
||||||
|
if name == "" {
|
||||||
|
skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if kind == "domains" {
|
||||||
|
if _, err := s.db.Exec(`INSERT INTO domains (id, name) VALUES (?, ?)`,
|
||||||
|
newID("d"), strings.ToLower(name)); err != nil {
|
||||||
|
if strings.Contains(err.Error(), "UNIQUE") {
|
||||||
|
skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
errs = append(errs, fmt.Sprintf("row %d: %v", row, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
created++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// monitors
|
||||||
|
typ := orDefault(cell(rec, "type"), "http")
|
||||||
|
if !validMonitorType(typ) {
|
||||||
|
errs = append(errs, fmt.Sprintf("row %d: bad type %q", row, typ))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
url, host := cell(rec, "url"), cell(rec, "hostname")
|
||||||
|
if url == "" && host == "" {
|
||||||
|
errs = append(errs, fmt.Sprintf("row %d: url or hostname required", row))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
port, _ := strconv.Atoi(cell(rec, "port"))
|
||||||
|
interval, _ := strconv.Atoi(cell(rec, "interval"))
|
||||||
|
if interval <= 0 {
|
||||||
|
interval = 60
|
||||||
|
}
|
||||||
|
token := ""
|
||||||
|
if typ == "push" {
|
||||||
|
token = newID("push")
|
||||||
|
}
|
||||||
|
if _, err := s.db.Exec(`INSERT INTO monitors
|
||||||
|
(id, name, type, url, hostname, port, interval_s, push_token, position)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?)`,
|
||||||
|
newID("m"), name, typ, url, host, port, interval, token,
|
||||||
|
float64(time.Now().UnixNano())/1e9); err != nil {
|
||||||
|
errs = append(errs, fmt.Sprintf("row %d: %v", row, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
created++
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"created": created, "skipped": skipped, "errors": errs})
|
||||||
|
}
|
||||||
@@ -0,0 +1,517 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/tdvorak/dash/internal/domain"
|
||||||
|
"github.com/tdvorak/dash/internal/widget"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---------- model ----------
|
||||||
|
|
||||||
|
const domainCols = `id, name, tld, active, auto_renew, alert_days_before, tags, notes,
|
||||||
|
expiry_date, creation_date, updated_date, registrar_name, registrar_id,
|
||||||
|
registrar_url, registry_domain_id, dnssec, statuses, privacy_enabled,
|
||||||
|
transfer_lock, registrant_name, registrant_org, registrant_country,
|
||||||
|
abuse_email, name_servers, mx_records, txt_records, cname, ipv4, ipv6,
|
||||||
|
ssl_issuer, ssl_valid_from, ssl_valid_to, ssl_subject, ssl_fingerprint,
|
||||||
|
ssl_key_size, ssl_sig_algo, ssl_alt_names, host_country, host_country_code,
|
||||||
|
host_region, host_city, host_isp, host_org, host_as, host_lat, host_lon,
|
||||||
|
dns_provider, email_provider, hosting_provider, ca_provider, headers,
|
||||||
|
favicon_url, lookup_error, interval_h, last_checked, position, created_at`
|
||||||
|
|
||||||
|
// domainRow mirrors the domains table; JSON text columns decode on scan.
|
||||||
|
type domainRow struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
TLD string `json:"tld"`
|
||||||
|
Active bool `json:"active"`
|
||||||
|
AutoRenew bool `json:"autoRenew"`
|
||||||
|
AlertDaysBefore int `json:"alertDaysBefore"`
|
||||||
|
Tags []string `json:"tags"`
|
||||||
|
Notes string `json:"notes"`
|
||||||
|
ExpiryDate *string `json:"expiryDate"`
|
||||||
|
CreationDate *string `json:"creationDate"`
|
||||||
|
UpdatedDate *string `json:"updatedDate"`
|
||||||
|
RegistrarName string `json:"registrarName"`
|
||||||
|
RegistrarID string `json:"registrarId"`
|
||||||
|
RegistrarURL string `json:"registrarUrl"`
|
||||||
|
RegistryDomainID string `json:"registryDomainId"`
|
||||||
|
DNSSEC string `json:"dnssec"`
|
||||||
|
Statuses []string `json:"statuses"`
|
||||||
|
PrivacyEnabled bool `json:"privacyEnabled"`
|
||||||
|
TransferLock bool `json:"transferLock"`
|
||||||
|
RegistrantName string `json:"registrantName"`
|
||||||
|
RegistrantOrg string `json:"registrantOrg"`
|
||||||
|
RegistrantCountry string `json:"registrantCountry"`
|
||||||
|
AbuseEmail string `json:"abuseEmail"`
|
||||||
|
NameServers []string `json:"nameServers"`
|
||||||
|
MXRecords []string `json:"mxRecords"`
|
||||||
|
TXTRecords []string `json:"txtRecords"`
|
||||||
|
CNAME string `json:"cname"`
|
||||||
|
IPv4 []string `json:"ipv4"`
|
||||||
|
IPv6 []string `json:"ipv6"`
|
||||||
|
SSLIssuer string `json:"sslIssuer"`
|
||||||
|
SSLValidFrom *string `json:"sslValidFrom"`
|
||||||
|
SSLValidTo *string `json:"sslValidTo"`
|
||||||
|
SSLSubject string `json:"sslSubject"`
|
||||||
|
SSLFingerprint string `json:"sslFingerprint"`
|
||||||
|
SSLKeySize int `json:"sslKeySize"`
|
||||||
|
SSLSigAlgo string `json:"sslSigAlgo"`
|
||||||
|
SSLAltNames []string `json:"sslAltNames"`
|
||||||
|
HostCountry string `json:"hostCountry"`
|
||||||
|
HostCountryCode string `json:"hostCountryCode"`
|
||||||
|
HostRegion string `json:"hostRegion"`
|
||||||
|
HostCity string `json:"hostCity"`
|
||||||
|
HostISP string `json:"hostIsp"`
|
||||||
|
HostOrg string `json:"hostOrg"`
|
||||||
|
HostAS string `json:"hostAs"`
|
||||||
|
HostLat float64 `json:"hostLat"`
|
||||||
|
HostLon float64 `json:"hostLon"`
|
||||||
|
DNSProvider string `json:"dnsProvider"`
|
||||||
|
EmailProvider string `json:"emailProvider"`
|
||||||
|
HostingProvider string `json:"hostingProvider"`
|
||||||
|
CAProvider string `json:"caProvider"`
|
||||||
|
Headers map[string]string `json:"headers"`
|
||||||
|
FaviconURL string `json:"faviconUrl"`
|
||||||
|
LookupError string `json:"lookupError"`
|
||||||
|
IntervalH int `json:"intervalH"`
|
||||||
|
LastChecked *string `json:"lastChecked"`
|
||||||
|
Position float64 `json:"position"`
|
||||||
|
CreatedAt string `json:"createdAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanDomain(row interface{ Scan(...any) error }) (*domainRow, error) {
|
||||||
|
var d domainRow
|
||||||
|
var tags, statuses, ns, mx, txt, v4, v6, altNames, headers string
|
||||||
|
var active, autoRenew, privacy, lock int
|
||||||
|
err := row.Scan(&d.ID, &d.Name, &d.TLD, &active, &autoRenew, &d.AlertDaysBefore,
|
||||||
|
&tags, &d.Notes, &d.ExpiryDate, &d.CreationDate, &d.UpdatedDate,
|
||||||
|
&d.RegistrarName, &d.RegistrarID, &d.RegistrarURL, &d.RegistryDomainID,
|
||||||
|
&d.DNSSEC, &statuses, &privacy, &lock, &d.RegistrantName, &d.RegistrantOrg,
|
||||||
|
&d.RegistrantCountry, &d.AbuseEmail, &ns, &mx, &txt, &d.CNAME, &v4, &v6,
|
||||||
|
&d.SSLIssuer, &d.SSLValidFrom, &d.SSLValidTo, &d.SSLSubject, &d.SSLFingerprint,
|
||||||
|
&d.SSLKeySize, &d.SSLSigAlgo, &altNames, &d.HostCountry, &d.HostCountryCode,
|
||||||
|
&d.HostRegion, &d.HostCity, &d.HostISP, &d.HostOrg, &d.HostAS, &d.HostLat,
|
||||||
|
&d.HostLon, &d.DNSProvider, &d.EmailProvider, &d.HostingProvider, &d.CAProvider,
|
||||||
|
&headers, &d.FaviconURL, &d.LookupError, &d.IntervalH, &d.LastChecked,
|
||||||
|
&d.Position, &d.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
d.Active = active != 0
|
||||||
|
d.AutoRenew = autoRenew != 0
|
||||||
|
d.PrivacyEnabled = privacy != 0
|
||||||
|
d.TransferLock = lock != 0
|
||||||
|
d.Tags = decodeList(tags)
|
||||||
|
d.Statuses = decodeList(statuses)
|
||||||
|
d.NameServers = decodeList(ns)
|
||||||
|
d.MXRecords = decodeList(mx)
|
||||||
|
d.TXTRecords = decodeList(txt)
|
||||||
|
d.IPv4 = decodeList(v4)
|
||||||
|
d.IPv6 = decodeList(v6)
|
||||||
|
d.SSLAltNames = decodeList(altNames)
|
||||||
|
d.Headers = map[string]string{}
|
||||||
|
_ = json.Unmarshal([]byte(headers), &d.Headers)
|
||||||
|
return &d, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeList(s string) []string {
|
||||||
|
var out []string
|
||||||
|
_ = json.Unmarshal([]byte(s), &out)
|
||||||
|
if out == nil {
|
||||||
|
out = []string{}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) loadDomain(id string) (*domainRow, error) {
|
||||||
|
return scanDomain(s.db.QueryRow(`SELECT `+domainCols+` FROM domains WHERE id = ?`, id))
|
||||||
|
}
|
||||||
|
|
||||||
|
// domainView adds computed countdowns the cards/detail page render directly.
|
||||||
|
type domainView struct {
|
||||||
|
*domainRow
|
||||||
|
DaysUntilExpiry *int `json:"daysUntilExpiry"`
|
||||||
|
SSLDaysUntilExpiry *int `json:"sslDaysUntilExpiry"`
|
||||||
|
Expiring bool `json:"expiring"`
|
||||||
|
SSLExpiring bool `json:"sslExpiring"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func daysUntil(iso *string) *int {
|
||||||
|
if iso == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
t, err := time.Parse(time.RFC3339, *iso)
|
||||||
|
if err != nil {
|
||||||
|
if t, err = time.Parse("2006-01-02", *iso); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
d := int(time.Until(t).Hours() / 24)
|
||||||
|
return &d
|
||||||
|
}
|
||||||
|
|
||||||
|
func viewOf(d *domainRow) domainView {
|
||||||
|
v := domainView{domainRow: d}
|
||||||
|
v.DaysUntilExpiry = daysUntil(d.ExpiryDate)
|
||||||
|
v.SSLDaysUntilExpiry = daysUntil(d.SSLValidTo)
|
||||||
|
if v.DaysUntilExpiry != nil {
|
||||||
|
v.Expiring = *v.DaysUntilExpiry <= d.AlertDaysBefore
|
||||||
|
}
|
||||||
|
if v.SSLDaysUntilExpiry != nil {
|
||||||
|
v.SSLExpiring = *v.SSLDaysUntilExpiry <= d.AlertDaysBefore
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- handlers ----------
|
||||||
|
|
||||||
|
func (s *Server) listDomainsH(c *gin.Context) {
|
||||||
|
rows, err := s.db.Query(`SELECT ` + domainCols + ` FROM domains ORDER BY position, name`)
|
||||||
|
if err != nil {
|
||||||
|
fail(c, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := []domainView{}
|
||||||
|
for rows.Next() {
|
||||||
|
if d, err := scanDomain(rows); err == nil {
|
||||||
|
out = append(out, viewOf(d))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
type domainInput struct {
|
||||||
|
Name *string `json:"name"`
|
||||||
|
Active *bool `json:"active"`
|
||||||
|
AutoRenew *bool `json:"autoRenew"`
|
||||||
|
AlertDaysBefore *int `json:"alertDaysBefore"`
|
||||||
|
IntervalH *int `json:"intervalH"`
|
||||||
|
Tags []string `json:"tags"`
|
||||||
|
Notes *string `json:"notes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) createDomain(c *gin.Context) {
|
||||||
|
var in domainInput
|
||||||
|
if err := c.ShouldBindJSON(&in); err != nil || in.Name == nil {
|
||||||
|
fail(c, http.StatusBadRequest, "name required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
name := domain.Clean(*in.Name)
|
||||||
|
if !domain.Valid(name) {
|
||||||
|
fail(c, http.StatusBadRequest, "invalid domain name")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id := newID("d")
|
||||||
|
tags, _ := json.Marshal(in.Tags)
|
||||||
|
if in.Tags == nil {
|
||||||
|
tags = []byte("[]")
|
||||||
|
}
|
||||||
|
_, err := s.db.Exec(`INSERT INTO domains
|
||||||
|
(id, name, tld, active, auto_renew, alert_days_before, tags, notes, interval_h, position)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?,?)`,
|
||||||
|
id, name, tldFor(name), boolOr2(in.Active, true), boolOr(in.AutoRenew),
|
||||||
|
intOr2(in.AlertDaysBefore, 30), string(tags), strOr(in.Notes),
|
||||||
|
intOr2(in.IntervalH, 24), float64(time.Now().UnixNano())/1e9)
|
||||||
|
if err != nil {
|
||||||
|
fail(c, http.StatusConflict, "domain already tracked or insert failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// First lookup runs inline so the response carries real data.
|
||||||
|
s.refreshDomain(id)
|
||||||
|
d, err := s.loadDomain(id)
|
||||||
|
if err != nil {
|
||||||
|
fail(c, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusCreated, viewOf(d))
|
||||||
|
}
|
||||||
|
|
||||||
|
func tldFor(name string) string {
|
||||||
|
for i := len(name) - 1; i >= 0; i-- {
|
||||||
|
if name[i] == '.' {
|
||||||
|
return name[i+1:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) getDomain(c *gin.Context) {
|
||||||
|
d, err := s.loadDomain(c.Param("id"))
|
||||||
|
if notFound(err) {
|
||||||
|
fail(c, http.StatusNotFound, "domain not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
fail(c, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, viewOf(d))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) patchDomain(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
if _, err := s.loadDomain(id); notFound(err) {
|
||||||
|
fail(c, http.StatusNotFound, "domain not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var in domainInput
|
||||||
|
if err := c.ShouldBindJSON(&in); err != nil {
|
||||||
|
fail(c, http.StatusBadRequest, "invalid body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if in.Name != nil {
|
||||||
|
name := domain.Clean(*in.Name)
|
||||||
|
if !domain.Valid(name) {
|
||||||
|
fail(c, http.StatusBadRequest, "invalid domain name")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = s.db.Exec(`UPDATE domains SET name = ?, tld = ? WHERE id = ?`, name, tldFor(name), id)
|
||||||
|
}
|
||||||
|
if in.Notes != nil {
|
||||||
|
_, _ = s.db.Exec(`UPDATE domains SET notes = ? WHERE id = ?`, *in.Notes, id)
|
||||||
|
}
|
||||||
|
if in.AlertDaysBefore != nil {
|
||||||
|
_, _ = s.db.Exec(`UPDATE domains SET alert_days_before = ? WHERE id = ?`, *in.AlertDaysBefore, id)
|
||||||
|
}
|
||||||
|
if in.IntervalH != nil {
|
||||||
|
_, _ = s.db.Exec(`UPDATE domains SET interval_h = ? WHERE id = ?`, *in.IntervalH, id)
|
||||||
|
}
|
||||||
|
if in.Active != nil {
|
||||||
|
_, _ = s.db.Exec(`UPDATE domains SET active = ? WHERE id = ?`, *in.Active, id)
|
||||||
|
}
|
||||||
|
if in.AutoRenew != nil {
|
||||||
|
_, _ = s.db.Exec(`UPDATE domains SET auto_renew = ? WHERE id = ?`, *in.AutoRenew, id)
|
||||||
|
}
|
||||||
|
if in.Tags != nil {
|
||||||
|
tags, _ := json.Marshal(in.Tags)
|
||||||
|
_, _ = s.db.Exec(`UPDATE domains SET tags = ? WHERE id = ?`, string(tags), id)
|
||||||
|
}
|
||||||
|
d, err := s.loadDomain(id)
|
||||||
|
if err != nil {
|
||||||
|
fail(c, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, viewOf(d))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) deleteDomain(c *gin.Context) {
|
||||||
|
res, err := s.db.Exec(`DELETE FROM domains WHERE id = ?`, c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
fail(c, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
|
fail(c, http.StatusNotFound, "domain not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// refreshDomain re-runs the full lookup and writes a snapshot row. Expiry
|
||||||
|
// threshold crossings fire notify events on the edge (only when a state
|
||||||
|
// newly becomes true, not on every refresh).
|
||||||
|
func (s *Server) refreshDomain(id string) {
|
||||||
|
row, err := s.loadDomain(id)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
prev := viewOf(row)
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
r := s.lookup(ctx, row.Name)
|
||||||
|
s.applyResult(id, r)
|
||||||
|
|
||||||
|
next, err := s.loadDomain(id)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
nv := viewOf(next)
|
||||||
|
switch {
|
||||||
|
case nv.DaysUntilExpiry != nil && *nv.DaysUntilExpiry < 0 && !(prev.DaysUntilExpiry != nil && *prev.DaysUntilExpiry < 0):
|
||||||
|
s.notify("domain.expired", next.Name, fmt.Sprintf("Domain %s has expired", next.Name))
|
||||||
|
case nv.Expiring && !prev.Expiring:
|
||||||
|
s.notify("domain.expiring", next.Name,
|
||||||
|
fmt.Sprintf("Domain %s expires in %d days", next.Name, *nv.DaysUntilExpiry))
|
||||||
|
}
|
||||||
|
if nv.SSLExpiring && !prev.SSLExpiring && nv.SSLDaysUntilExpiry != nil {
|
||||||
|
s.notify("domain.sslExpiring", next.Name,
|
||||||
|
fmt.Sprintf("TLS certificate for %s expires in %d days", next.Name, *nv.SSLDaysUntilExpiry))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) refreshDomainH(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
if _, err := s.loadDomain(id); notFound(err) {
|
||||||
|
fail(c, http.StatusNotFound, "domain not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.refreshDomain(id)
|
||||||
|
d, err := s.loadDomain(id)
|
||||||
|
if err != nil {
|
||||||
|
fail(c, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, viewOf(d))
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyResult persists a lookup Result and appends a domain_checks snapshot.
|
||||||
|
func (s *Server) applyResult(id string, r *domain.Result) {
|
||||||
|
enc := func(v []string) string {
|
||||||
|
b, _ := json.Marshal(v)
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
var expiry, created, updated, sslFrom, sslTo *string
|
||||||
|
fmtT := func(t *time.Time) *string {
|
||||||
|
if t == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
s := t.UTC().Format(time.RFC3339)
|
||||||
|
return &s
|
||||||
|
}
|
||||||
|
expiry, created, updated = fmtT(r.ExpiryDate), fmtT(r.CreationDate), fmtT(r.UpdatedDate)
|
||||||
|
sslFrom, sslTo = fmtT(r.SSLValidFrom), fmtT(r.SSLValidTo)
|
||||||
|
hdr, _ := json.Marshal(r.Headers)
|
||||||
|
|
||||||
|
_, err := s.db.Exec(`UPDATE domains SET
|
||||||
|
expiry_date=?, creation_date=?, updated_date=?, registrar_name=?,
|
||||||
|
registrar_id=?, registrar_url=?, registry_domain_id=?, dnssec=?,
|
||||||
|
statuses=?, privacy_enabled=?, transfer_lock=?, registrant_name=?,
|
||||||
|
registrant_org=?, registrant_country=?, abuse_email=?, name_servers=?,
|
||||||
|
mx_records=?, txt_records=?, cname=?, ipv4=?, ipv6=?, ssl_issuer=?,
|
||||||
|
ssl_valid_from=?, ssl_valid_to=?, ssl_subject=?, ssl_fingerprint=?,
|
||||||
|
ssl_key_size=?, ssl_sig_algo=?, ssl_alt_names=?, host_country=?,
|
||||||
|
host_country_code=?, host_region=?, host_city=?, host_isp=?, host_org=?,
|
||||||
|
host_as=?, host_lat=?, host_lon=?, dns_provider=?, email_provider=?,
|
||||||
|
hosting_provider=?, ca_provider=?, headers=?, favicon_url=?,
|
||||||
|
lookup_error=?, last_checked=strftime('%Y-%m-%dT%H:%M:%fZ','now')
|
||||||
|
WHERE id=?`,
|
||||||
|
expiry, created, updated, r.RegistrarName, r.RegistrarID, r.RegistrarURL,
|
||||||
|
r.RegistryDomainID, r.DNSSEC, enc(r.Statuses), r.PrivacyEnabled,
|
||||||
|
r.TransferLock, r.RegistrantName, r.RegistrantOrg, r.RegistrantCountry,
|
||||||
|
r.AbuseEmail, enc(r.NameServers), enc(r.MXRecords), enc(r.TXTRecords),
|
||||||
|
r.CNAME, enc(r.IPv4), enc(r.IPv6), r.SSLIssuer, sslFrom, sslTo,
|
||||||
|
r.SSLSubject, r.SSLFingerprint, r.SSLKeySize, r.SSLSigAlgo,
|
||||||
|
enc(r.SSLAltNames), r.HostCountry, r.HostCountryCode, r.HostRegion,
|
||||||
|
r.HostCity, r.HostISP, r.HostOrg, r.HostAS, r.HostLat, r.HostLon,
|
||||||
|
r.DNSProvider, r.EmailProvider, r.HostingProvider, r.CAProvider,
|
||||||
|
string(hdr), r.FaviconURL, truncate(r.Error, 300), id)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Error("domain apply", zap.Error(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = s.db.Exec(`INSERT INTO domain_checks (domain_id, expiry_date, ssl_valid_to, ipv4, name_servers)
|
||||||
|
VALUES (?,?,?,?,?)`, id, expiry, sslTo, enc(r.IPv4), enc(r.NameServers))
|
||||||
|
}
|
||||||
|
|
||||||
|
// domainChecks returns the snapshot history — powers change timelines later.
|
||||||
|
func (s *Server) domainChecks(c *gin.Context) {
|
||||||
|
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
|
||||||
|
if limit <= 0 || limit > 500 {
|
||||||
|
limit = 50
|
||||||
|
}
|
||||||
|
rows, err := s.db.Query(`SELECT id, expiry_date, ssl_valid_to, ipv4, name_servers, checked_at
|
||||||
|
FROM domain_checks WHERE domain_id = ? ORDER BY checked_at DESC LIMIT ?`,
|
||||||
|
c.Param("id"), limit)
|
||||||
|
if err != nil {
|
||||||
|
fail(c, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
type chk struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
ExpiryDate *string `json:"expiryDate"`
|
||||||
|
SSLValidTo *string `json:"sslValidTo"`
|
||||||
|
IPv4 []string `json:"ipv4"`
|
||||||
|
NameServers []string `json:"nameServers"`
|
||||||
|
CheckedAt string `json:"checkedAt"`
|
||||||
|
}
|
||||||
|
out := []chk{}
|
||||||
|
for rows.Next() {
|
||||||
|
var k chk
|
||||||
|
var v4, ns string
|
||||||
|
if err := rows.Scan(&k.ID, &k.ExpiryDate, &k.SSLValidTo, &v4, &ns, &k.CheckedAt); err != nil {
|
||||||
|
fail(c, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
k.IPv4, k.NameServers = decodeList(v4), decodeList(ns)
|
||||||
|
out = append(out, k)
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- scheduler ----------
|
||||||
|
|
||||||
|
// sweepDomains refreshes domains whose interval_h has elapsed. Runs inside
|
||||||
|
// the monitor tick — the SQL gate keeps it near-free when nothing is due.
|
||||||
|
func (s *Server) sweepDomains() {
|
||||||
|
rows, err := s.db.Query(`SELECT ` + domainCols + ` FROM domains
|
||||||
|
WHERE active = 1
|
||||||
|
AND (last_checked IS NULL OR datetime(last_checked, '+' || interval_h || ' hours') <= datetime('now'))`)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Error("domain sweep", zap.Error(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var due []domainRow
|
||||||
|
for rows.Next() {
|
||||||
|
if d, err := scanDomain(rows); err == nil {
|
||||||
|
due = append(due, *d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
for i := range due {
|
||||||
|
checkSem <- struct{}{}
|
||||||
|
go func(id string) {
|
||||||
|
defer func() { <-checkSem }()
|
||||||
|
s.refreshDomain(id)
|
||||||
|
}(due[i].ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- widget fetcher ----------
|
||||||
|
|
||||||
|
// domainWidget serves board tiles bound to a domain via config.domainId.
|
||||||
|
type domainWidget struct{ db *sql.DB }
|
||||||
|
|
||||||
|
func (w *domainWidget) Meta() widget.Type {
|
||||||
|
return widget.Type{
|
||||||
|
Type: "domain", Name: "Domain",
|
||||||
|
Description: "Expiry countdown for a tracked domain",
|
||||||
|
Fields: []widget.Field{
|
||||||
|
{Key: "domainId", Label: "Domain", Required: true},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *domainWidget) Fetch(_ context.Context, cfg json.RawMessage) (any, error) {
|
||||||
|
var c struct {
|
||||||
|
DomainID string `json:"domainId"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(cfg, &c); err != nil || c.DomainID == "" {
|
||||||
|
return nil, errors.New("config.domainId required")
|
||||||
|
}
|
||||||
|
d, err := scanDomain(w.db.QueryRow(`SELECT `+domainCols+` FROM domains WHERE id = ?`, c.DomainID))
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("domain not found")
|
||||||
|
}
|
||||||
|
v := viewOf(d)
|
||||||
|
return map[string]any{
|
||||||
|
"name": d.Name,
|
||||||
|
"faviconUrl": d.FaviconURL,
|
||||||
|
"daysUntilExpiry": v.DaysUntilExpiry,
|
||||||
|
"sslDaysUntilExpiry": v.SSLDaysUntilExpiry,
|
||||||
|
"expiring": v.Expiring,
|
||||||
|
"registrar": d.RegistrarName,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,322 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/goccy/go-yaml"
|
||||||
|
)
|
||||||
|
|
||||||
|
// diBase mirrors the frontend's dashboard-icons CDN root (ServiceDialog).
|
||||||
|
const diBase = "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png"
|
||||||
|
|
||||||
|
// importExternal ingests a foreign dashboard config — Homepage services.yml,
|
||||||
|
// Homarr JSON export, or Dashy conf.yml — and replaces the board with it.
|
||||||
|
// Format is sniffed from the body; settings are left untouched.
|
||||||
|
func (s *Server) importExternal(c *gin.Context) {
|
||||||
|
body, err := io.ReadAll(io.LimitReader(c.Request.Body, 8<<20))
|
||||||
|
if err != nil || len(body) == 0 {
|
||||||
|
fail(c, http.StatusBadRequest, "empty or unreadable body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sections, err := parseExternal(body)
|
||||||
|
if err != nil {
|
||||||
|
fail(c, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.replaceBoard(sections, nil); err != nil {
|
||||||
|
failImport(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
board, err := s.board()
|
||||||
|
if err != nil {
|
||||||
|
fail(c, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, board)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseExternal sniffs the config format: JSON decodes as Homarr; a YAML
|
||||||
|
// sequence is Homepage's grouped services list; a YAML mapping carrying
|
||||||
|
// `sections` is a Dashy conf.yml.
|
||||||
|
func parseExternal(body []byte) ([]Section, error) {
|
||||||
|
var j any
|
||||||
|
if json.Unmarshal(body, &j) == nil {
|
||||||
|
return parseHomarr(j)
|
||||||
|
}
|
||||||
|
var y any
|
||||||
|
if err := yaml.Unmarshal(body, &y); err != nil {
|
||||||
|
return nil, errors.New("unreadable file — expected JSON or YAML")
|
||||||
|
}
|
||||||
|
switch root := y.(type) {
|
||||||
|
case []any:
|
||||||
|
return parseHomepage(root)
|
||||||
|
case map[string]any:
|
||||||
|
if _, ok := root["sections"]; ok {
|
||||||
|
return parseDashy(body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, errors.New("unrecognized config — expected a Homepage services.yml, Homarr JSON export, or Dashy conf.yml")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Homepage ----------
|
||||||
|
|
||||||
|
// parseHomepage walks a services.yml: a sequence of single-key maps
|
||||||
|
// (group name -> sequence of single-key service maps). Bare single-key
|
||||||
|
// entries at the top level land in an "Imported" section.
|
||||||
|
func parseHomepage(groups []any) ([]Section, error) {
|
||||||
|
var secs []Section
|
||||||
|
loose := Section{Name: "Imported"}
|
||||||
|
for _, g := range groups {
|
||||||
|
gm, ok := g.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for name, v := range gm {
|
||||||
|
switch val := v.(type) {
|
||||||
|
case []any:
|
||||||
|
sec := Section{Name: name, Items: homepageItems(val)}
|
||||||
|
if len(sec.Items) > 0 {
|
||||||
|
secs = append(secs, sec)
|
||||||
|
}
|
||||||
|
case map[string]any:
|
||||||
|
if it, ok := homepageService(name, val); ok {
|
||||||
|
loose.Items = append(loose.Items, it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(loose.Items) > 0 {
|
||||||
|
secs = append([]Section{loose}, secs...)
|
||||||
|
}
|
||||||
|
if len(secs) == 0 {
|
||||||
|
return nil, errors.New("homepage: no importable services found")
|
||||||
|
}
|
||||||
|
return assignIDs(secs), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func homepageItems(list []any) []Item {
|
||||||
|
var items []Item
|
||||||
|
for _, raw := range list {
|
||||||
|
m, ok := raw.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for name, fields := range m {
|
||||||
|
fm, _ := fields.(map[string]any)
|
||||||
|
if it, ok := homepageService(name, fm); ok {
|
||||||
|
items = append(items, it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
// homepageService maps one service entry. Entries without a reachable URL
|
||||||
|
// (pure widget config, bare ping targets) are skipped rather than failing
|
||||||
|
// the whole import.
|
||||||
|
func homepageService(name string, fields map[string]any) (Item, bool) {
|
||||||
|
u := strOf(fields, "href", "siteMonitor", "ping")
|
||||||
|
if strings.TrimSpace(name) == "" || !validURL(u) {
|
||||||
|
return Item{}, false
|
||||||
|
}
|
||||||
|
return importedItem(name, resolveIcon(strOf(fields, "icon"), u), u), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Homarr ----------
|
||||||
|
|
||||||
|
// parseHomarr tolerates Homarr's shifting export shapes: `categories[].apps`,
|
||||||
|
// a flat `apps` array grouped by category id, or board `sections[].items`.
|
||||||
|
func parseHomarr(root any) ([]Section, error) {
|
||||||
|
m, ok := root.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return nil, errors.New("homarr: expected a JSON object")
|
||||||
|
}
|
||||||
|
var secs []Section
|
||||||
|
seen := map[string]bool{}
|
||||||
|
catNames := map[string]string{}
|
||||||
|
|
||||||
|
for _, c := range arrOf(m["categories"]) {
|
||||||
|
cm, _ := c.(map[string]any)
|
||||||
|
if name := strOf(cm, "name"); name != "" {
|
||||||
|
catNames[strOf(cm, "id")] = name
|
||||||
|
if s := sectionFromApps(name, arrOf(cm["apps"]), seen); len(s.Items) > 0 {
|
||||||
|
secs = append(secs, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if apps := arrOf(m["apps"]); len(apps) > 0 {
|
||||||
|
var order []string
|
||||||
|
byCat := map[string][]any{}
|
||||||
|
for _, a := range apps {
|
||||||
|
am, _ := a.(map[string]any)
|
||||||
|
cat := strOf(am, "categoryId", "category")
|
||||||
|
if _, ok := byCat[cat]; !ok {
|
||||||
|
order = append(order, cat)
|
||||||
|
}
|
||||||
|
byCat[cat] = append(byCat[cat], a)
|
||||||
|
}
|
||||||
|
for _, cat := range order {
|
||||||
|
name := catNames[cat]
|
||||||
|
if name == "" {
|
||||||
|
name = "Imported"
|
||||||
|
}
|
||||||
|
if s := sectionFromApps(name, byCat[cat], seen); len(s.Items) > 0 {
|
||||||
|
secs = append(secs, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, raw := range arrOf(m["sections"]) {
|
||||||
|
sm, _ := raw.(map[string]any)
|
||||||
|
if s := sectionFromApps(strOf(sm, "name"), arrOf(sm["items"]), seen); len(s.Items) > 0 {
|
||||||
|
secs = append(secs, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(secs) == 0 {
|
||||||
|
return nil, errors.New("homarr: no apps found")
|
||||||
|
}
|
||||||
|
return assignIDs(secs), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sectionFromApps(name string, apps []any, seen map[string]bool) Section {
|
||||||
|
sec := Section{Name: orDefault(name, "Imported")}
|
||||||
|
for _, a := range apps {
|
||||||
|
am, _ := a.(map[string]any)
|
||||||
|
it, ok := homarrItem(am)
|
||||||
|
if !ok || seen[it.Name+"|"+it.URLs[0].URL] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[it.Name+"|"+it.URLs[0].URL] = true
|
||||||
|
sec.Items = append(sec.Items, it)
|
||||||
|
}
|
||||||
|
return sec
|
||||||
|
}
|
||||||
|
|
||||||
|
func homarrItem(m map[string]any) (Item, bool) {
|
||||||
|
name := strOf(m, "name", "title", "label")
|
||||||
|
u := strOf(m, "url", "href", "appUrl", "externalUrl", "internalUrl")
|
||||||
|
if name == "" || !validURL(u) {
|
||||||
|
return Item{}, false
|
||||||
|
}
|
||||||
|
return importedItem(name, resolveIcon(strOf(m, "icon", "iconUrl"), u), u), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Dashy ----------
|
||||||
|
|
||||||
|
type dashyConf struct {
|
||||||
|
Sections []struct {
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
Items []struct {
|
||||||
|
Title string `yaml:"title"`
|
||||||
|
URL string `yaml:"url"`
|
||||||
|
Icon string `yaml:"icon"`
|
||||||
|
} `yaml:"items"`
|
||||||
|
} `yaml:"sections"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseDashy(body []byte) ([]Section, error) {
|
||||||
|
var conf dashyConf
|
||||||
|
if err := yaml.Unmarshal(body, &conf); err != nil {
|
||||||
|
return nil, fmt.Errorf("dashy: malformed conf.yml")
|
||||||
|
}
|
||||||
|
var secs []Section
|
||||||
|
for _, ds := range conf.Sections {
|
||||||
|
sec := Section{Name: orDefault(ds.Name, "Imported")}
|
||||||
|
for _, di := range ds.Items {
|
||||||
|
if di.Title == "" || !validURL(di.URL) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sec.Items = append(sec.Items, importedItem(di.Title, resolveIcon(di.Icon, di.URL), di.URL))
|
||||||
|
}
|
||||||
|
secs = append(secs, sec)
|
||||||
|
}
|
||||||
|
if len(secs) == 0 {
|
||||||
|
return nil, errors.New("dashy: no sections found")
|
||||||
|
}
|
||||||
|
return assignIDs(secs), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- shared ----------
|
||||||
|
|
||||||
|
// assignIDs stamps fresh ids and 1024-spaced positions on an imported tree.
|
||||||
|
func assignIDs(secs []Section) []Section {
|
||||||
|
for si := range secs {
|
||||||
|
secs[si].ID = newID("s")
|
||||||
|
secs[si].Position = float64(si+1) * 1024
|
||||||
|
for ii := range secs[si].Items {
|
||||||
|
it := &secs[si].Items[ii]
|
||||||
|
it.ID = newID("i")
|
||||||
|
it.SectionID = secs[si].ID
|
||||||
|
it.Kind = orDefault(it.Kind, "service")
|
||||||
|
it.Position = float64(ii+1) * 1024
|
||||||
|
for ui := range it.URLs {
|
||||||
|
it.URLs[ui].ID = newID("u")
|
||||||
|
it.URLs[ui].Position = float64(ui+1) * 1024
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return secs
|
||||||
|
}
|
||||||
|
|
||||||
|
func importedItem(name, icon, u string) Item {
|
||||||
|
return Item{Kind: "service", Name: name, Icon: icon, URLs: []URL{{URL: u}}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveIcon maps a foreign icon reference to a usable URL: passthrough for
|
||||||
|
// absolute URLs, favicon derivation for Dashy's `favicon`, the dashboard-icons
|
||||||
|
// CDN for prefixed or bare slugs, and "" (letter tile) for local file paths
|
||||||
|
// that only existed on the old dashboard.
|
||||||
|
func resolveIcon(icon, itemURL string) string {
|
||||||
|
icon = strings.TrimSpace(icon)
|
||||||
|
switch {
|
||||||
|
case icon == "":
|
||||||
|
return ""
|
||||||
|
case strings.HasPrefix(icon, "http://"), strings.HasPrefix(icon, "https://"):
|
||||||
|
return icon
|
||||||
|
case icon == "favicon", icon == "generalfavicon":
|
||||||
|
return faviconOf(itemURL)
|
||||||
|
case strings.ContainsAny(icon, "/.\\"):
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
lower := strings.ToLower(icon)
|
||||||
|
for _, p := range []string{"si-", "sh-", "hl-", "di-", "mdi-"} {
|
||||||
|
if rest, ok := strings.CutPrefix(lower, p); ok {
|
||||||
|
if rest == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return diBase + "/" + rest + ".png"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return diBase + "/" + lower + ".png"
|
||||||
|
}
|
||||||
|
|
||||||
|
func faviconOf(raw string) string {
|
||||||
|
u, err := url.Parse(raw)
|
||||||
|
if err != nil || u.Host == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return u.Scheme + "://" + u.Host + "/favicon.ico"
|
||||||
|
}
|
||||||
|
|
||||||
|
func arrOf(v any) []any {
|
||||||
|
a, _ := v.([]any)
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
|
||||||
|
func strOf(m map[string]any, keys ...string) string {
|
||||||
|
for _, k := range keys {
|
||||||
|
if s, ok := m[k].(string); ok && s != "" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// /api/metrics — Prometheus text exposition over the tables we already have.
|
||||||
|
// No registry, no deps: the counts are one-line SQL each.
|
||||||
|
|
||||||
|
func promEscape(s string) string {
|
||||||
|
return strings.NewReplacer(`\`, `\\`, `"`, `\"`, "\n", `\n`).Replace(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) metrics(c *gin.Context) {
|
||||||
|
var b strings.Builder
|
||||||
|
count := func(q string) int {
|
||||||
|
var n int
|
||||||
|
_ = s.db.QueryRow(q).Scan(&n)
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
gauge := func(name, help string, v int) {
|
||||||
|
fmt.Fprintf(&b, "# HELP %s %s\n# TYPE %s gauge\n%s %d\n", name, help, name, name, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
gauge("dash_monitors_total", "Configured monitors", count(`SELECT COUNT(*) FROM monitors`))
|
||||||
|
gauge("dash_monitors_up", "Monitors currently up", count(`SELECT COUNT(*) FROM monitors WHERE status='up'`))
|
||||||
|
gauge("dash_monitors_down", "Monitors currently down", count(`SELECT COUNT(*) FROM monitors WHERE status='down'`))
|
||||||
|
gauge("dash_domains_total", "Tracked domains", count(`SELECT COUNT(*) FROM domains`))
|
||||||
|
gauge("dash_domains_expired", "Domains past expiry", count(`SELECT COUNT(*) FROM domains WHERE expiry_date IS NOT NULL AND datetime(expiry_date) < datetime('now')`))
|
||||||
|
gauge("dash_domains_expiring_30d", "Domains expiring within 30 days",
|
||||||
|
count(`SELECT COUNT(*) FROM domains WHERE expiry_date IS NOT NULL
|
||||||
|
AND datetime(expiry_date) >= datetime('now')
|
||||||
|
AND datetime(expiry_date) <= datetime('now','+30 days')`))
|
||||||
|
gauge("dash_systems_total", "Registered systems", count(`SELECT COUNT(*) FROM systems`))
|
||||||
|
gauge("dash_systems_up", "Systems currently reporting", count(`SELECT COUNT(*) FROM systems WHERE status='up'`))
|
||||||
|
gauge("dash_systems_down", "Systems gone silent", count(`SELECT COUNT(*) FROM systems WHERE status='down'`))
|
||||||
|
gauge("dash_incidents_open", "Open or acknowledged incidents", count(`SELECT COUNT(*) FROM incidents WHERE status IN ('open','ack')`))
|
||||||
|
gauge("dash_heartbeats_total", "Recorded monitor heartbeats", count(`SELECT COUNT(*) FROM heartbeats`))
|
||||||
|
|
||||||
|
// Per-monitor up/down gauge — label cardinality is user-controlled and small.
|
||||||
|
b.WriteString("# TYPE dash_monitor_up gauge\n")
|
||||||
|
if rows, err := s.db.Query(`SELECT name, status FROM monitors`); err == nil {
|
||||||
|
for rows.Next() {
|
||||||
|
var name, st string
|
||||||
|
if rows.Scan(&name, &st) == nil {
|
||||||
|
v := 0
|
||||||
|
if st == "up" {
|
||||||
|
v = 1
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, "dash_monitor_up{name=%q} %d\n", promEscape(name), v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
}
|
||||||
|
b.WriteString("# TYPE dash_system_up gauge\n")
|
||||||
|
if rows, err := s.db.Query(`SELECT name, status FROM systems`); err == nil {
|
||||||
|
for rows.Next() {
|
||||||
|
var name, st string
|
||||||
|
if rows.Scan(&name, &st) == nil {
|
||||||
|
v := 0
|
||||||
|
if st == "up" {
|
||||||
|
v = 1
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, "dash_system_up{name=%q} %d\n", promEscape(name), v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Data(http.StatusOK, "text/plain; version=0.0.4; charset=utf-8", []byte(b.String()))
|
||||||
|
}
|
||||||
@@ -0,0 +1,569 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/tdvorak/dash/internal/monitor"
|
||||||
|
"github.com/tdvorak/dash/internal/widget"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---------- model ----------
|
||||||
|
|
||||||
|
const monitorCols = `id, name, type, url, hostname, port, method, headers, body,
|
||||||
|
keyword, keyword_invert, json_query, expected, dns_type, interval_s, timeout_s,
|
||||||
|
retries, active, status, push_token, tags, notes, position, last_check, created_at`
|
||||||
|
|
||||||
|
func scanMonitor(row interface{ Scan(...any) error }) (*monitor.Monitor, error) {
|
||||||
|
var m monitor.Monitor
|
||||||
|
var tags string
|
||||||
|
var invert, active int
|
||||||
|
err := row.Scan(&m.ID, &m.Name, &m.Type, &m.URL, &m.Hostname, &m.Port, &m.Method,
|
||||||
|
&m.Headers, &m.Body, &m.Keyword, &invert, &m.JSONQuery, &m.Expected, &m.DNSType,
|
||||||
|
&m.IntervalS, &m.TimeoutS, &m.Retries, &active, &m.Status, &m.PushToken,
|
||||||
|
&tags, &m.Notes, &m.Position, &m.LastCheck, &m.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
m.KeywordInvert = invert != 0
|
||||||
|
m.Active = active != 0
|
||||||
|
_ = json.Unmarshal([]byte(tags), &m.Tags)
|
||||||
|
if m.Tags == nil {
|
||||||
|
m.Tags = []string{}
|
||||||
|
}
|
||||||
|
return &m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) loadMonitor(id string) (*monitor.Monitor, error) {
|
||||||
|
row := s.db.QueryRow(`SELECT `+monitorCols+` FROM monitors WHERE id = ?`, id)
|
||||||
|
return scanMonitor(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) loadMonitorByToken(token string) (*monitor.Monitor, error) {
|
||||||
|
row := s.db.QueryRow(`SELECT `+monitorCols+` FROM monitors WHERE push_token = ? AND push_token != ''`, token)
|
||||||
|
return scanMonitor(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) listMonitors() ([]monitor.Monitor, error) {
|
||||||
|
rows, err := s.db.Query(`SELECT ` + monitorCols + ` FROM monitors ORDER BY position, name`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []monitor.Monitor
|
||||||
|
for rows.Next() {
|
||||||
|
m, err := scanMonitor(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, *m)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- uptime stats ----------
|
||||||
|
|
||||||
|
type uptimeStats struct {
|
||||||
|
Up int `json:"up"`
|
||||||
|
Down int `json:"down"`
|
||||||
|
Uptime24h float64 `json:"uptime24h"`
|
||||||
|
Uptime30d float64 `json:"uptime30d"`
|
||||||
|
AvgPing24h int `json:"avgPing24h"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) monitorStats(id string) uptimeStats {
|
||||||
|
var st uptimeStats
|
||||||
|
pct := func(hours int) float64 {
|
||||||
|
var up, down int
|
||||||
|
_ = s.db.QueryRow(`SELECT COALESCE(SUM(status='up'),0), COALESCE(SUM(status='down'),0)
|
||||||
|
FROM heartbeats WHERE monitor_id = ? AND checked_at >= datetime('now', ?)`,
|
||||||
|
id, "-"+strconv.Itoa(hours)+" hours").Scan(&up, &down)
|
||||||
|
if up+down == 0 {
|
||||||
|
return 100
|
||||||
|
}
|
||||||
|
return float64(up) / float64(up+down) * 100
|
||||||
|
}
|
||||||
|
st.Uptime24h = pct(24)
|
||||||
|
st.Uptime30d = pct(720)
|
||||||
|
_ = s.db.QueryRow(`SELECT COALESCE(AVG(NULLIF(ping_ms,0)),0) FROM heartbeats
|
||||||
|
WHERE monitor_id = ? AND checked_at >= datetime('now','-24 hours')`, id).Scan(&st.AvgPing24h)
|
||||||
|
_ = s.db.QueryRow(`SELECT COALESCE(SUM(status='up'),0), COALESCE(SUM(status='down'),0)
|
||||||
|
FROM heartbeats WHERE monitor_id = ?`, id).Scan(&st.Up, &st.Down)
|
||||||
|
return st
|
||||||
|
}
|
||||||
|
|
||||||
|
// monitorView is the API shape: the row plus live uptime stats.
|
||||||
|
type monitorView struct {
|
||||||
|
*monitor.Monitor
|
||||||
|
Stats uptimeStats `json:"stats"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- handlers ----------
|
||||||
|
|
||||||
|
func (s *Server) listMonitorsH(c *gin.Context) {
|
||||||
|
ms, err := s.listMonitors()
|
||||||
|
if err != nil {
|
||||||
|
fail(c, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out := make([]monitorView, 0, len(ms))
|
||||||
|
for i := range ms {
|
||||||
|
out = append(out, monitorView{Monitor: &ms[i], Stats: s.monitorStats(ms[i].ID)})
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// monitorInput is the create/update payload — all fields optional on PATCH.
|
||||||
|
type monitorInput struct {
|
||||||
|
Name *string `json:"name"`
|
||||||
|
Type *string `json:"type"`
|
||||||
|
URL *string `json:"url"`
|
||||||
|
Hostname *string `json:"hostname"`
|
||||||
|
Port *int `json:"port"`
|
||||||
|
Method *string `json:"method"`
|
||||||
|
Headers *string `json:"headers"`
|
||||||
|
Body *string `json:"body"`
|
||||||
|
Keyword *string `json:"keyword"`
|
||||||
|
KeywordInvert *bool `json:"keywordInvert"`
|
||||||
|
JSONQuery *string `json:"jsonQuery"`
|
||||||
|
Expected *string `json:"expected"`
|
||||||
|
DNSType *string `json:"dnsType"`
|
||||||
|
IntervalS *int `json:"intervalS"`
|
||||||
|
TimeoutS *int `json:"timeoutS"`
|
||||||
|
Retries *int `json:"retries"`
|
||||||
|
Active *bool `json:"active"`
|
||||||
|
Tags []string `json:"tags"`
|
||||||
|
Notes *string `json:"notes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func validMonitorType(t string) bool {
|
||||||
|
for _, v := range monitor.Types() {
|
||||||
|
if v == t {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func strOr(p *string) string {
|
||||||
|
if p == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return *p
|
||||||
|
}
|
||||||
|
|
||||||
|
func strOr2(p *string, def string) string {
|
||||||
|
if p == nil || *p == "" {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return *p
|
||||||
|
}
|
||||||
|
|
||||||
|
func intOr(p *int) int {
|
||||||
|
if p == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return *p
|
||||||
|
}
|
||||||
|
|
||||||
|
func intOr2(p *int, def int) int {
|
||||||
|
if p == nil || *p <= 0 {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return *p
|
||||||
|
}
|
||||||
|
|
||||||
|
func boolOr(p *bool) bool { return p != nil && *p }
|
||||||
|
|
||||||
|
func boolOr2(p *bool, def bool) bool {
|
||||||
|
if p == nil {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return *p
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) createMonitor(c *gin.Context) {
|
||||||
|
var in monitorInput
|
||||||
|
if err := c.ShouldBindJSON(&in); err != nil || in.Name == nil || in.Type == nil {
|
||||||
|
fail(c, http.StatusBadRequest, "name and type required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !validMonitorType(*in.Type) {
|
||||||
|
fail(c, http.StatusBadRequest, "invalid type")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id := newID("m")
|
||||||
|
token := ""
|
||||||
|
if *in.Type == "push" {
|
||||||
|
token = newID("push")
|
||||||
|
}
|
||||||
|
tags, _ := json.Marshal(in.Tags)
|
||||||
|
if in.Tags == nil {
|
||||||
|
tags = []byte("[]")
|
||||||
|
}
|
||||||
|
_, err := s.db.Exec(`INSERT INTO monitors
|
||||||
|
(id, name, type, url, hostname, port, method, headers, body, keyword,
|
||||||
|
keyword_invert, json_query, expected, dns_type, interval_s, timeout_s,
|
||||||
|
retries, active, push_token, tags, notes, position)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||||
|
id, *in.Name, *in.Type, strOr(in.URL), strOr(in.Hostname), intOr(in.Port),
|
||||||
|
strOr2(in.Method, "GET"), strOr2(in.Headers, "{}"), strOr(in.Body),
|
||||||
|
strOr(in.Keyword), boolOr(in.KeywordInvert), strOr(in.JSONQuery),
|
||||||
|
strOr(in.Expected), strOr2(in.DNSType, "A"), intOr2(in.IntervalS, 60),
|
||||||
|
intOr2(in.TimeoutS, 10), intOr(in.Retries), boolOr2(in.Active, true),
|
||||||
|
token, string(tags), strOr(in.Notes), float64(time.Now().UnixNano())/1e9)
|
||||||
|
if err != nil {
|
||||||
|
fail(c, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m, err := s.loadMonitor(id)
|
||||||
|
if err != nil {
|
||||||
|
fail(c, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusCreated, monitorView{Monitor: m, Stats: s.monitorStats(id)})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) getMonitor(c *gin.Context) {
|
||||||
|
m, err := s.loadMonitor(c.Param("id"))
|
||||||
|
if notFound(err) {
|
||||||
|
fail(c, http.StatusNotFound, "monitor not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
fail(c, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, monitorView{Monitor: m, Stats: s.monitorStats(m.ID)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// patchMonitor applies a sparse update; `active` flips pause/resume.
|
||||||
|
func (s *Server) patchMonitor(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
if _, err := s.loadMonitor(id); notFound(err) {
|
||||||
|
fail(c, http.StatusNotFound, "monitor not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var in monitorInput
|
||||||
|
if err := c.ShouldBindJSON(&in); err != nil {
|
||||||
|
fail(c, http.StatusBadRequest, "invalid body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if in.Type != nil && !validMonitorType(*in.Type) {
|
||||||
|
fail(c, http.StatusBadRequest, "invalid type")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setStr := func(col string, p *string) {
|
||||||
|
if p != nil {
|
||||||
|
_, _ = s.db.Exec(`UPDATE monitors SET `+col+` = ? WHERE id = ?`, *p, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setStr("name", in.Name)
|
||||||
|
setStr("type", in.Type)
|
||||||
|
setStr("url", in.URL)
|
||||||
|
setStr("hostname", in.Hostname)
|
||||||
|
setStr("method", in.Method)
|
||||||
|
setStr("headers", in.Headers)
|
||||||
|
setStr("body", in.Body)
|
||||||
|
setStr("keyword", in.Keyword)
|
||||||
|
setStr("json_query", in.JSONQuery)
|
||||||
|
setStr("expected", in.Expected)
|
||||||
|
setStr("dns_type", in.DNSType)
|
||||||
|
setStr("notes", in.Notes)
|
||||||
|
setInt := func(col string, p *int) {
|
||||||
|
if p != nil {
|
||||||
|
_, _ = s.db.Exec(`UPDATE monitors SET `+col+` = ? WHERE id = ?`, *p, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setInt("port", in.Port)
|
||||||
|
setInt("interval_s", in.IntervalS)
|
||||||
|
setInt("timeout_s", in.TimeoutS)
|
||||||
|
setInt("retries", in.Retries)
|
||||||
|
if in.KeywordInvert != nil {
|
||||||
|
_, _ = s.db.Exec(`UPDATE monitors SET keyword_invert = ? WHERE id = ?`, *in.KeywordInvert, id)
|
||||||
|
}
|
||||||
|
if in.Tags != nil {
|
||||||
|
tags, _ := json.Marshal(in.Tags)
|
||||||
|
_, _ = s.db.Exec(`UPDATE monitors SET tags = ? WHERE id = ?`, string(tags), id)
|
||||||
|
}
|
||||||
|
if in.Active != nil {
|
||||||
|
// pause/resume flips the stored status back to pending for a fresh probe
|
||||||
|
status := "pending"
|
||||||
|
if !*in.Active {
|
||||||
|
status = "paused"
|
||||||
|
}
|
||||||
|
_, _ = s.db.Exec(`UPDATE monitors SET active = ?, status = ? WHERE id = ?`, *in.Active, status, id)
|
||||||
|
}
|
||||||
|
m, err := s.loadMonitor(id)
|
||||||
|
if err != nil {
|
||||||
|
fail(c, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, monitorView{Monitor: m, Stats: s.monitorStats(id)})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) deleteMonitor(c *gin.Context) {
|
||||||
|
res, err := s.db.Exec(`DELETE FROM monitors WHERE id = ?`, c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
fail(c, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
|
fail(c, http.StatusNotFound, "monitor not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// heartbeats returns the recent check history for charts/logs.
|
||||||
|
func (s *Server) monitorHeartbeats(c *gin.Context) {
|
||||||
|
hours, _ := strconv.Atoi(c.DefaultQuery("hours", "24"))
|
||||||
|
if hours <= 0 || hours > 24*30 {
|
||||||
|
hours = 24
|
||||||
|
}
|
||||||
|
rows, err := s.db.Query(`SELECT id, status, ping_ms, msg, cert_expiry, checked_at
|
||||||
|
FROM heartbeats WHERE monitor_id = ? AND checked_at >= datetime('now', ?)
|
||||||
|
ORDER BY checked_at`, c.Param("id"), "-"+strconv.Itoa(hours)+" hours")
|
||||||
|
if err != nil {
|
||||||
|
fail(c, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
type hb struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
PingMs int `json:"pingMs"`
|
||||||
|
Msg string `json:"msg"`
|
||||||
|
CertExpiry int `json:"certExpiry"`
|
||||||
|
CheckedAt string `json:"checkedAt"`
|
||||||
|
}
|
||||||
|
out := []hb{}
|
||||||
|
for rows.Next() {
|
||||||
|
var h hb
|
||||||
|
if err := rows.Scan(&h.ID, &h.Status, &h.PingMs, &h.Msg, &h.CertExpiry, &h.CheckedAt); err != nil {
|
||||||
|
fail(c, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out = append(out, h)
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkNow runs one check synchronously and returns the refreshed view.
|
||||||
|
func (s *Server) checkNow(c *gin.Context) {
|
||||||
|
m, err := s.loadMonitor(c.Param("id"))
|
||||||
|
if notFound(err) {
|
||||||
|
fail(c, http.StatusNotFound, "monitor not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
fail(c, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.runCheck(m)
|
||||||
|
m, err = s.loadMonitor(m.ID)
|
||||||
|
if err != nil {
|
||||||
|
fail(c, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, monitorView{Monitor: m, Stats: s.monitorStats(m.ID)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// pushIngest is the push-monitor endpoint: any hit records an up heartbeat.
|
||||||
|
// ?msg= and ?ping= are optional annotations.
|
||||||
|
func (s *Server) pushIngest(c *gin.Context) {
|
||||||
|
m, err := s.loadMonitorByToken(c.Param("token"))
|
||||||
|
if err != nil {
|
||||||
|
fail(c, http.StatusNotFound, "unknown push token")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
msg := c.Query("msg")
|
||||||
|
ping, _ := strconv.Atoi(c.Query("ping"))
|
||||||
|
s.writeHeartbeat(m, &monitor.Result{Up: true, PingMs: ping, Msg: msg, CertExpiry: -1})
|
||||||
|
_, _ = s.db.Exec(`UPDATE monitors SET status = 'up', last_check = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`, m.ID)
|
||||||
|
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- scheduler ----------
|
||||||
|
|
||||||
|
// runScheduler sweeps for due monitors every tick; each check runs in a
|
||||||
|
// bounded worker. Push monitors go stale -> down when silent too long.
|
||||||
|
func (s *Server) runScheduler(done <-chan struct{}) {
|
||||||
|
tick := time.NewTicker(10 * time.Second)
|
||||||
|
prune := time.NewTicker(time.Hour)
|
||||||
|
defer tick.Stop()
|
||||||
|
defer prune.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
return
|
||||||
|
case <-tick.C:
|
||||||
|
s.sweep()
|
||||||
|
case <-prune.C:
|
||||||
|
if _, err := s.db.Exec(`DELETE FROM heartbeats WHERE checked_at < datetime('now','-30 days')`); err != nil {
|
||||||
|
s.log.Error("heartbeat prune", zap.Error(err))
|
||||||
|
}
|
||||||
|
// System samples are high-rate; a week of raw ticks is enough.
|
||||||
|
if _, err := s.db.Exec(`DELETE FROM system_stats WHERE ts < datetime('now','-7 days')`); err != nil {
|
||||||
|
s.log.Error("system stats prune", zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var checkSem = make(chan struct{}, 8)
|
||||||
|
|
||||||
|
func (s *Server) sweep() {
|
||||||
|
rows, err := s.db.Query(`SELECT ` + monitorCols + ` FROM monitors
|
||||||
|
WHERE active = 1 AND type != 'push'
|
||||||
|
AND (last_check IS NULL OR datetime(last_check, '+' || interval_s || ' seconds') <= datetime('now'))`)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Error("monitor sweep", zap.Error(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var due []monitor.Monitor
|
||||||
|
for rows.Next() {
|
||||||
|
if m, err := scanMonitor(rows); err == nil {
|
||||||
|
due = append(due, *m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
|
||||||
|
for i := range due {
|
||||||
|
m := &due[i]
|
||||||
|
checkSem <- struct{}{}
|
||||||
|
go func() {
|
||||||
|
defer func() { <-checkSem }()
|
||||||
|
s.runCheck(m)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push monitors: mark down when no push arrived within 2x interval.
|
||||||
|
if _, err := s.db.Exec(`UPDATE monitors SET status = 'down',
|
||||||
|
last_check = strftime('%Y-%m-%dT%H:%M:%fZ','now')
|
||||||
|
WHERE type = 'push' AND active = 1
|
||||||
|
AND last_check IS NOT NULL
|
||||||
|
AND datetime(last_check, '+' || (2 * interval_s) || ' seconds') <= datetime('now')
|
||||||
|
AND status != 'down'`); err != nil {
|
||||||
|
s.log.Error("push stale sweep", zap.Error(err))
|
||||||
|
}
|
||||||
|
|
||||||
|
s.sweepDomains()
|
||||||
|
s.sweepSystems()
|
||||||
|
}
|
||||||
|
|
||||||
|
// runCheck executes one monitor check (with retries) and persists the result.
|
||||||
|
func (s *Server) runCheck(m *monitor.Monitor) *monitor.Result {
|
||||||
|
var res *monitor.Result
|
||||||
|
for attempt := 0; attempt <= m.Retries; attempt++ {
|
||||||
|
res = monitor.Check(context.Background(), m)
|
||||||
|
if res.Up || attempt == m.Retries {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
}
|
||||||
|
s.writeHeartbeat(m, res)
|
||||||
|
prev := m.Status
|
||||||
|
next := "down"
|
||||||
|
if res.Up {
|
||||||
|
next = "up"
|
||||||
|
}
|
||||||
|
if _, err := s.db.Exec(`UPDATE monitors SET status = ?, last_check = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`,
|
||||||
|
next, m.ID); err != nil {
|
||||||
|
s.log.Error("monitor update", zap.Error(err))
|
||||||
|
}
|
||||||
|
if prev != next && prev != "pending" {
|
||||||
|
s.onMonitorTransition(m, prev, next)
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) writeHeartbeat(m *monitor.Monitor, res *monitor.Result) {
|
||||||
|
status := "down"
|
||||||
|
if res.Up {
|
||||||
|
status = "up"
|
||||||
|
}
|
||||||
|
if _, err := s.db.Exec(`INSERT INTO heartbeats (monitor_id, status, ping_ms, msg, cert_expiry)
|
||||||
|
VALUES (?,?,?,?,?)`, m.ID, status, res.PingMs, truncate(res.Msg, 300), res.CertExpiry); err != nil {
|
||||||
|
s.log.Error("heartbeat write", zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// onMonitorTransition fires on real state flips (pending excluded upstream)
|
||||||
|
// — the notification + auto-incident seam for up/down events. An active
|
||||||
|
// maintenance window covering this monitor suppresses both.
|
||||||
|
func (s *Server) onMonitorTransition(m *monitor.Monitor, prev, next string) {
|
||||||
|
s.log.Info("monitor transition",
|
||||||
|
zap.String("monitor", m.Name), zap.String("from", prev), zap.String("to", next))
|
||||||
|
if s.inMaintenance(m.ID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.autoIncident(m.ID, m.Name, next)
|
||||||
|
target := m.URL
|
||||||
|
if target == "" {
|
||||||
|
target = m.Hostname
|
||||||
|
}
|
||||||
|
s.notify("monitor."+next, m.Name,
|
||||||
|
fmt.Sprintf("Monitor %q is %s (%s)", m.Name, next, target))
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncate(s string, n int) string {
|
||||||
|
if len(s) > n {
|
||||||
|
return s[:n]
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- widget fetcher ----------
|
||||||
|
|
||||||
|
// monitorWidget serves board tiles bound to a monitor via config.monitorId.
|
||||||
|
type monitorWidget struct{ db *sql.DB }
|
||||||
|
|
||||||
|
func (w *monitorWidget) Meta() widget.Type {
|
||||||
|
return widget.Type{
|
||||||
|
Type: "monitor", Name: "Monitor",
|
||||||
|
Description: "Live status of an uptime monitor",
|
||||||
|
Fields: []widget.Field{
|
||||||
|
{Key: "monitorId", Label: "Monitor", Required: true},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *monitorWidget) Fetch(_ context.Context, cfg json.RawMessage) (any, error) {
|
||||||
|
var c struct {
|
||||||
|
MonitorID string `json:"monitorId"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(cfg, &c); err != nil || c.MonitorID == "" {
|
||||||
|
return nil, errors.New("config.monitorId required")
|
||||||
|
}
|
||||||
|
row := w.db.QueryRow(`SELECT `+monitorCols+` FROM monitors WHERE id = ?`, c.MonitorID)
|
||||||
|
m, err := scanMonitor(row)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("monitor not found")
|
||||||
|
}
|
||||||
|
var up, down int
|
||||||
|
var avgPing float64
|
||||||
|
_ = w.db.QueryRow(`SELECT COALESCE(SUM(status='up'),0), COALESCE(SUM(status='down'),0),
|
||||||
|
COALESCE(AVG(NULLIF(ping_ms,0)),0)
|
||||||
|
FROM heartbeats WHERE monitor_id = ? AND checked_at >= datetime('now','-24 hours')`,
|
||||||
|
m.ID).Scan(&up, &down, &avgPing)
|
||||||
|
uptime := 100.0
|
||||||
|
if up+down > 0 {
|
||||||
|
uptime = float64(up) / float64(up+down) * 100
|
||||||
|
}
|
||||||
|
return map[string]any{
|
||||||
|
"name": m.Name,
|
||||||
|
"status": m.Status,
|
||||||
|
"uptime24": uptime,
|
||||||
|
"ping": int(avgPing),
|
||||||
|
"type": m.Type,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Notifications: one outbound webhook, configured via the notify_webhook
|
||||||
|
// settings key. Payload carries event/name/text/content/message so the same
|
||||||
|
// POST satisfies Slack ({text}), Discord ({content}), ntfy, and generic
|
||||||
|
// receivers. SMTP and per-service channels are deliberately out of scope —
|
||||||
|
// a webhook bridge (e.g. ntfy, Gotify, shoutrrr) covers them.
|
||||||
|
|
||||||
|
type notifyPayload struct {
|
||||||
|
Event string `json:"event"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Text string `json:"text"` // Slack-style
|
||||||
|
Content string `json:"content"` // Discord-style
|
||||||
|
Message string `json:"message"` // ntfy/generic
|
||||||
|
At string `json:"at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) webhookURL() string {
|
||||||
|
var raw string
|
||||||
|
if err := s.db.QueryRow(`SELECT value FROM settings WHERE key = 'notify_webhook'`).Scan(&raw); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
// Settings values are stored as JSON — unwrap the quoted string.
|
||||||
|
var u string
|
||||||
|
if err := json.Unmarshal([]byte(raw), &u); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
|
||||||
|
// notify fires the webhook asynchronously; delivery failures are logged, never fatal.
|
||||||
|
func (s *Server) notify(event, name, msg string) {
|
||||||
|
url := s.webhookURL()
|
||||||
|
if url == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if u := parseHTTPURL(url); u == "" {
|
||||||
|
s.log.Warn("notify_webhook is not an http(s) URL", zap.String("event", event))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p := notifyPayload{Event: event, Name: name, Text: msg, Content: msg, Message: msg,
|
||||||
|
At: time.Now().UTC().Format(time.RFC3339)}
|
||||||
|
go s.deliver(url, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) deliver(url string, p notifyPayload) {
|
||||||
|
body, _ := json.Marshal(p)
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Title", p.Text) // ntfy reads the title header
|
||||||
|
req.Header.Set("X-Dash-Event", p.Event)
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Warn("webhook delivery failed", zap.String("event", p.Event), zap.Error(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
s.log.Warn("webhook rejected", zap.String("event", p.Event), zap.Int("status", resp.StatusCode))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseHTTPURL enforces http/https on the configured webhook — same SSRF
|
||||||
|
// posture as monitor targets (user-configured, but no file:// or friends).
|
||||||
|
func parseHTTPURL(raw string) string {
|
||||||
|
if !validURL(raw) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
|
// notifyTest lets the settings UI verify the configured webhook.
|
||||||
|
func (s *Server) notifyTest(c *gin.Context) {
|
||||||
|
if s.webhookURL() == "" {
|
||||||
|
fail(c, http.StatusBadRequest, "notify_webhook not configured")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.notify("test", "dash", "Dash test notification")
|
||||||
|
c.JSON(http.StatusOK, gin.H{"status": "sent"})
|
||||||
|
}
|
||||||
@@ -0,0 +1,717 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---------- incidents ----------
|
||||||
|
|
||||||
|
type Incident struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Severity string `json:"severity"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
MonitorID *string `json:"monitorId"`
|
||||||
|
Updates []IncidentUpdate `json:"updates,omitempty"`
|
||||||
|
CreatedAt string `json:"createdAt"`
|
||||||
|
UpdatedAt string `json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type IncidentUpdate struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
At string `json:"at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var incidentStatuses = map[string]bool{"open": true, "ack": true, "resolved": true, "closed": true}
|
||||||
|
var incidentSeverities = map[string]bool{"minor": true, "major": true, "critical": true}
|
||||||
|
|
||||||
|
func scanIncident(row interface{ Scan(...any) error }) (*Incident, error) {
|
||||||
|
var in Incident
|
||||||
|
err := row.Scan(&in.ID, &in.Title, &in.Severity, &in.Status, &in.MonitorID,
|
||||||
|
&in.CreatedAt, &in.UpdatedAt)
|
||||||
|
return &in, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const incidentCols = `id, title, severity, status, monitor_id, created_at, updated_at`
|
||||||
|
|
||||||
|
func (s *Server) loadIncident(id string) (*Incident, error) {
|
||||||
|
return scanIncident(s.db.QueryRow(`SELECT `+incidentCols+` FROM incidents WHERE id = ?`, id))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) incidentUpdates(id string) []IncidentUpdate {
|
||||||
|
rows, err := s.db.Query(`SELECT id, status, message, at FROM incident_updates
|
||||||
|
WHERE incident_id = ? ORDER BY at`, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := []IncidentUpdate{}
|
||||||
|
for rows.Next() {
|
||||||
|
var u IncidentUpdate
|
||||||
|
if rows.Scan(&u.ID, &u.Status, &u.Message, &u.At) == nil {
|
||||||
|
out = append(out, u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) listIncidents(c *gin.Context) {
|
||||||
|
openOnly := c.Query("status") == "open"
|
||||||
|
q := `SELECT ` + incidentCols + ` FROM incidents`
|
||||||
|
if openOnly {
|
||||||
|
q += ` WHERE status IN ('open','ack')`
|
||||||
|
}
|
||||||
|
q += ` ORDER BY created_at DESC LIMIT 200`
|
||||||
|
rows, err := s.db.Query(q)
|
||||||
|
if err != nil {
|
||||||
|
fail(c, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := []Incident{}
|
||||||
|
for rows.Next() {
|
||||||
|
in, err := scanIncident(rows)
|
||||||
|
if err != nil {
|
||||||
|
fail(c, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
in.Updates = s.incidentUpdates(in.ID)
|
||||||
|
out = append(out, *in)
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
type incidentInput struct {
|
||||||
|
Title *string `json:"title"`
|
||||||
|
Severity *string `json:"severity"`
|
||||||
|
Status *string `json:"status"`
|
||||||
|
MonitorID *string `json:"monitorId"`
|
||||||
|
Message *string `json:"message"` // initial update body / transition note
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) createIncident(c *gin.Context) {
|
||||||
|
var in incidentInput
|
||||||
|
if err := c.ShouldBindJSON(&in); err != nil || in.Title == nil || *in.Title == "" {
|
||||||
|
fail(c, http.StatusBadRequest, "title required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sev := strOr2(in.Severity, "major")
|
||||||
|
st := strOr2(in.Status, "open")
|
||||||
|
if !incidentSeverities[sev] || !incidentStatuses[st] {
|
||||||
|
fail(c, http.StatusBadRequest, "invalid severity or status")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id := newID("inc")
|
||||||
|
_, err := s.db.Exec(`INSERT INTO incidents (id, title, severity, status, monitor_id)
|
||||||
|
VALUES (?,?,?,?,?)`, id, *in.Title, sev, st, in.MonitorID)
|
||||||
|
if err != nil {
|
||||||
|
fail(c, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if msg := strOr(in.Message); msg != "" {
|
||||||
|
_, _ = s.db.Exec(`INSERT INTO incident_updates (incident_id, status, message) VALUES (?,?,?)`,
|
||||||
|
id, st, msg)
|
||||||
|
}
|
||||||
|
inc, err := s.loadIncident(id)
|
||||||
|
if err != nil {
|
||||||
|
fail(c, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
inc.Updates = s.incidentUpdates(id)
|
||||||
|
c.JSON(http.StatusCreated, inc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// patchIncident moves status/severity/title; a status change also appends an
|
||||||
|
// incident_updates row so the timeline stays honest.
|
||||||
|
func (s *Server) patchIncident(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
cur, err := s.loadIncident(id)
|
||||||
|
if notFound(err) {
|
||||||
|
fail(c, http.StatusNotFound, "incident not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var in incidentInput
|
||||||
|
if err := c.ShouldBindJSON(&in); err != nil {
|
||||||
|
fail(c, http.StatusBadRequest, "invalid body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if in.Severity != nil && !incidentSeverities[*in.Severity] {
|
||||||
|
fail(c, http.StatusBadRequest, "invalid severity")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if in.Status != nil && !incidentStatuses[*in.Status] {
|
||||||
|
fail(c, http.StatusBadRequest, "invalid status")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
set := func(col string, p *string) {
|
||||||
|
if p != nil {
|
||||||
|
_, _ = s.db.Exec(`UPDATE incidents SET `+col+` = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`, *p, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
set("title", in.Title)
|
||||||
|
set("severity", in.Severity)
|
||||||
|
set("status", in.Status)
|
||||||
|
if in.Status != nil && *in.Status != cur.Status {
|
||||||
|
_, _ = s.db.Exec(`INSERT INTO incident_updates (incident_id, status, message) VALUES (?,?,?)`,
|
||||||
|
id, *in.Status, strOr(in.Message))
|
||||||
|
}
|
||||||
|
inc, err := s.loadIncident(id)
|
||||||
|
if err != nil {
|
||||||
|
fail(c, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
inc.Updates = s.incidentUpdates(id)
|
||||||
|
c.JSON(http.StatusOK, inc)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) addIncidentUpdate(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
if _, err := s.loadIncident(id); notFound(err) {
|
||||||
|
fail(c, http.StatusNotFound, "incident not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var in incidentInput
|
||||||
|
if err := c.ShouldBindJSON(&in); err != nil {
|
||||||
|
fail(c, http.StatusBadRequest, "invalid body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
st := strOr2(in.Status, "")
|
||||||
|
if st != "" && !incidentStatuses[st] {
|
||||||
|
fail(c, http.StatusBadRequest, "invalid status")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if st == "" {
|
||||||
|
st = "note"
|
||||||
|
}
|
||||||
|
_, err := s.db.Exec(`INSERT INTO incident_updates (incident_id, status, message) VALUES (?,?,?)`,
|
||||||
|
id, st, strOr(in.Message))
|
||||||
|
if err != nil {
|
||||||
|
fail(c, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if in.Status != nil {
|
||||||
|
_, _ = s.db.Exec(`UPDATE incidents SET status = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`, *in.Status, id)
|
||||||
|
}
|
||||||
|
inc, _ := s.loadIncident(id)
|
||||||
|
inc.Updates = s.incidentUpdates(id)
|
||||||
|
c.JSON(http.StatusOK, inc)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) deleteIncident(c *gin.Context) {
|
||||||
|
res, err := s.db.Exec(`DELETE FROM incidents WHERE id = ?`, c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
fail(c, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
|
fail(c, http.StatusNotFound, "incident not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- maintenance windows ----------
|
||||||
|
|
||||||
|
type MaintWindow struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
StartsAt string `json:"startsAt"`
|
||||||
|
EndsAt string `json:"endsAt"`
|
||||||
|
MonitorIDs []string `json:"monitorIds"` // empty = all monitors
|
||||||
|
Active bool `json:"active"`
|
||||||
|
CreatedAt string `json:"createdAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanWindow(row interface{ Scan(...any) error }) (*MaintWindow, error) {
|
||||||
|
var w MaintWindow
|
||||||
|
var ids string
|
||||||
|
err := row.Scan(&w.ID, &w.Title, &w.StartsAt, &w.EndsAt, &ids, &w.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal([]byte(ids), &w.MonitorIDs)
|
||||||
|
if w.MonitorIDs == nil {
|
||||||
|
w.MonitorIDs = []string{}
|
||||||
|
}
|
||||||
|
now := time.Now().UTC()
|
||||||
|
if st, err1 := time.Parse(time.RFC3339Nano, w.StartsAt); err1 == nil {
|
||||||
|
if en, err2 := time.Parse(time.RFC3339Nano, w.EndsAt); err2 == nil {
|
||||||
|
w.Active = !now.Before(st) && now.Before(en)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &w, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const windowCols = `id, title, starts_at, ends_at, monitor_ids, created_at`
|
||||||
|
|
||||||
|
func (s *Server) listWindows(c *gin.Context) {
|
||||||
|
rows, err := s.db.Query(`SELECT ` + windowCols + ` FROM maintenance_windows ORDER BY starts_at DESC LIMIT 100`)
|
||||||
|
if err != nil {
|
||||||
|
fail(c, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := []MaintWindow{}
|
||||||
|
for rows.Next() {
|
||||||
|
w, err := scanWindow(rows)
|
||||||
|
if err != nil {
|
||||||
|
fail(c, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out = append(out, *w)
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
type windowInput struct {
|
||||||
|
Title *string `json:"title"`
|
||||||
|
StartsAt *string `json:"startsAt"`
|
||||||
|
EndsAt *string `json:"endsAt"`
|
||||||
|
MonitorIDs []string `json:"monitorIds"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func validWindow(in *windowInput) bool {
|
||||||
|
if in.Title == nil || in.StartsAt == nil || in.EndsAt == nil || *in.Title == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
st, err1 := time.Parse(time.RFC3339Nano, *in.StartsAt)
|
||||||
|
en, err2 := time.Parse(time.RFC3339Nano, *in.EndsAt)
|
||||||
|
return err1 == nil && err2 == nil && en.After(st)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) createWindow(c *gin.Context) {
|
||||||
|
var in windowInput
|
||||||
|
if err := c.ShouldBindJSON(&in); err != nil || !validWindow(&in) {
|
||||||
|
fail(c, http.StatusBadRequest, "title, startsAt, endsAt (RFC3339, end after start) required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ids, _ := json.Marshal(in.MonitorIDs)
|
||||||
|
if in.MonitorIDs == nil {
|
||||||
|
ids = []byte("[]")
|
||||||
|
}
|
||||||
|
id := newID("mw")
|
||||||
|
if _, err := s.db.Exec(`INSERT INTO maintenance_windows (id, title, starts_at, ends_at, monitor_ids)
|
||||||
|
VALUES (?,?,?,?,?)`, id, *in.Title, *in.StartsAt, *in.EndsAt, string(ids)); err != nil {
|
||||||
|
fail(c, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w, err := scanWindow(s.db.QueryRow(`SELECT `+windowCols+` FROM maintenance_windows WHERE id = ?`, id))
|
||||||
|
if err != nil {
|
||||||
|
fail(c, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusCreated, w)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) deleteWindow(c *gin.Context) {
|
||||||
|
res, err := s.db.Exec(`DELETE FROM maintenance_windows WHERE id = ?`, c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
fail(c, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
|
fail(c, http.StatusNotFound, "window not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// inMaintenance reports whether a monitor is covered by an active window —
|
||||||
|
// suppresses auto-incidents and transition alerts.
|
||||||
|
func (s *Server) inMaintenance(monitorID string) bool {
|
||||||
|
rows, err := s.db.Query(`SELECT monitor_ids FROM maintenance_windows
|
||||||
|
WHERE datetime(starts_at) <= datetime('now') AND datetime(ends_at) > datetime('now')`)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var raw string
|
||||||
|
if rows.Scan(&raw) != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var ids []string
|
||||||
|
_ = json.Unmarshal([]byte(raw), &ids)
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, id := range ids {
|
||||||
|
if id == monitorID {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- status pages ----------
|
||||||
|
|
||||||
|
type StatusPage struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
MonitorIDs []string `json:"monitorIds"`
|
||||||
|
SystemIDs []string `json:"systemIds"`
|
||||||
|
CreatedAt string `json:"createdAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanStatusPage(row interface{ Scan(...any) error }) (*StatusPage, error) {
|
||||||
|
var p StatusPage
|
||||||
|
var mids, sids string
|
||||||
|
err := row.Scan(&p.ID, &p.Slug, &p.Title, &p.Description, &mids, &sids, &p.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal([]byte(mids), &p.MonitorIDs)
|
||||||
|
_ = json.Unmarshal([]byte(sids), &p.SystemIDs)
|
||||||
|
if p.MonitorIDs == nil {
|
||||||
|
p.MonitorIDs = []string{}
|
||||||
|
}
|
||||||
|
if p.SystemIDs == nil {
|
||||||
|
p.SystemIDs = []string{}
|
||||||
|
}
|
||||||
|
return &p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusPageCols = `id, slug, title, description, monitor_ids, system_ids, created_at`
|
||||||
|
|
||||||
|
func (s *Server) listStatusPages(c *gin.Context) {
|
||||||
|
rows, err := s.db.Query(`SELECT ` + statusPageCols + ` FROM status_pages ORDER BY title`)
|
||||||
|
if err != nil {
|
||||||
|
fail(c, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := []StatusPage{}
|
||||||
|
for rows.Next() {
|
||||||
|
p, err := scanStatusPage(rows)
|
||||||
|
if err != nil {
|
||||||
|
fail(c, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out = append(out, *p)
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
type statusPageInput struct {
|
||||||
|
Slug *string `json:"slug"`
|
||||||
|
Title *string `json:"title"`
|
||||||
|
Description *string `json:"description"`
|
||||||
|
MonitorIDs []string `json:"monitorIds"`
|
||||||
|
SystemIDs []string `json:"systemIds"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// validSlug keeps the public URL safe: lowercase letters, digits, dashes.
|
||||||
|
func validSlug(slug string) bool {
|
||||||
|
if slug == "" || len(slug) > 64 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, r := range slug {
|
||||||
|
if !(r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '-') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) createStatusPage(c *gin.Context) {
|
||||||
|
var in statusPageInput
|
||||||
|
if err := c.ShouldBindJSON(&in); err != nil || in.Title == nil || *in.Title == "" {
|
||||||
|
fail(c, http.StatusBadRequest, "title required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slug := strOr(in.Slug)
|
||||||
|
if slug == "" {
|
||||||
|
slug = slugify(*in.Title)
|
||||||
|
}
|
||||||
|
if !validSlug(slug) {
|
||||||
|
fail(c, http.StatusBadRequest, "invalid slug")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mids, _ := json.Marshal(in.MonitorIDs)
|
||||||
|
sids, _ := json.Marshal(in.SystemIDs)
|
||||||
|
id := newID("sp")
|
||||||
|
_, err := s.db.Exec(`INSERT INTO status_pages (id, slug, title, description, monitor_ids, system_ids)
|
||||||
|
VALUES (?,?,?,?,?,?)`, id, slug, *in.Title, strOr(in.Description), string(mids), string(sids))
|
||||||
|
if err != nil {
|
||||||
|
if strings.Contains(err.Error(), "UNIQUE") {
|
||||||
|
fail(c, http.StatusConflict, "slug already in use")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fail(c, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p, err := scanStatusPage(s.db.QueryRow(`SELECT `+statusPageCols+` FROM status_pages WHERE id = ?`, id))
|
||||||
|
if err != nil {
|
||||||
|
fail(c, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusCreated, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) patchStatusPage(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
if _, err := scanStatusPage(s.db.QueryRow(
|
||||||
|
`SELECT `+statusPageCols+` FROM status_pages WHERE id = ?`, id)); notFound(err) {
|
||||||
|
fail(c, http.StatusNotFound, "status page not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var in statusPageInput
|
||||||
|
if err := c.ShouldBindJSON(&in); err != nil {
|
||||||
|
fail(c, http.StatusBadRequest, "invalid body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if in.Slug != nil {
|
||||||
|
if !validSlug(*in.Slug) {
|
||||||
|
fail(c, http.StatusBadRequest, "invalid slug")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = s.db.Exec(`UPDATE status_pages SET slug = ? WHERE id = ?`, *in.Slug, id)
|
||||||
|
}
|
||||||
|
if in.Title != nil && *in.Title != "" {
|
||||||
|
_, _ = s.db.Exec(`UPDATE status_pages SET title = ? WHERE id = ?`, *in.Title, id)
|
||||||
|
}
|
||||||
|
if in.Description != nil {
|
||||||
|
_, _ = s.db.Exec(`UPDATE status_pages SET description = ? WHERE id = ?`, *in.Description, id)
|
||||||
|
}
|
||||||
|
if in.MonitorIDs != nil {
|
||||||
|
b, _ := json.Marshal(in.MonitorIDs)
|
||||||
|
_, _ = s.db.Exec(`UPDATE status_pages SET monitor_ids = ? WHERE id = ?`, string(b), id)
|
||||||
|
}
|
||||||
|
if in.SystemIDs != nil {
|
||||||
|
b, _ := json.Marshal(in.SystemIDs)
|
||||||
|
_, _ = s.db.Exec(`UPDATE status_pages SET system_ids = ? WHERE id = ?`, string(b), id)
|
||||||
|
}
|
||||||
|
p, err := scanStatusPage(s.db.QueryRow(`SELECT `+statusPageCols+` FROM status_pages WHERE id = ?`, id))
|
||||||
|
if err != nil {
|
||||||
|
fail(c, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) deleteStatusPage(c *gin.Context) {
|
||||||
|
res, err := s.db.Exec(`DELETE FROM status_pages WHERE id = ?`, c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
fail(c, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
|
fail(c, http.StatusNotFound, "status page not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func slugify(s string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
for _, r := range strings.ToLower(s) {
|
||||||
|
switch {
|
||||||
|
case r >= 'a' && r <= 'z' || r >= '0' && r <= '9':
|
||||||
|
b.WriteRune(r)
|
||||||
|
case r == ' ' || r == '-' || r == '_':
|
||||||
|
b.WriteRune('-')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Trim(b.String(), "-")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- public status ----------
|
||||||
|
|
||||||
|
// publicStatus renders one status page's live view: monitors with uptime,
|
||||||
|
// systems with status, open incidents, active/upcoming maintenance.
|
||||||
|
func (s *Server) publicStatus(c *gin.Context) {
|
||||||
|
p, err := scanStatusPage(s.db.QueryRow(
|
||||||
|
`SELECT `+statusPageCols+` FROM status_pages WHERE slug = ?`, c.Param("slug")))
|
||||||
|
if notFound(err) {
|
||||||
|
fail(c, http.StatusNotFound, "status page not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
fail(c, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pick := func(ids []string) (string, []any) {
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
marks := strings.TrimSuffix(strings.Repeat("?,", len(ids)), ",")
|
||||||
|
args := make([]any, len(ids))
|
||||||
|
for i, id := range ids {
|
||||||
|
args[i] = id
|
||||||
|
}
|
||||||
|
return marks, args
|
||||||
|
}
|
||||||
|
|
||||||
|
type monStatus struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Status string `json:"status"` // up|down|pending|maintenance
|
||||||
|
Uptime24h float64 `json:"uptime24h"`
|
||||||
|
}
|
||||||
|
mons := []monStatus{}
|
||||||
|
mq := `SELECT id, name, status FROM monitors WHERE active = 1`
|
||||||
|
marks, args := pick(p.MonitorIDs)
|
||||||
|
if marks != "" {
|
||||||
|
mq += ` AND id IN (` + marks + `)`
|
||||||
|
}
|
||||||
|
mq += ` ORDER BY name`
|
||||||
|
if rows, err := s.db.Query(mq, args...); err == nil {
|
||||||
|
for rows.Next() {
|
||||||
|
var m monStatus
|
||||||
|
if rows.Scan(&m.ID, &m.Name, &m.Status) == nil {
|
||||||
|
if s.inMaintenance(m.ID) {
|
||||||
|
m.Status = "maintenance"
|
||||||
|
}
|
||||||
|
m.Uptime24h = s.monitorStats(m.ID).Uptime24h
|
||||||
|
mons = append(mons, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
type sysStatus struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
systems := []sysStatus{}
|
||||||
|
sq := `SELECT id, name, status FROM systems`
|
||||||
|
marks, args = pick(p.SystemIDs)
|
||||||
|
if marks != "" {
|
||||||
|
sq += ` WHERE id IN (` + marks + `)`
|
||||||
|
}
|
||||||
|
sq += ` ORDER BY name`
|
||||||
|
if rows, err := s.db.Query(sq, args...); err == nil {
|
||||||
|
for rows.Next() {
|
||||||
|
var sm sysStatus
|
||||||
|
if rows.Scan(&sm.ID, &sm.Name, &sm.Status) == nil {
|
||||||
|
systems = append(systems, sm)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
incidents := []Incident{}
|
||||||
|
if rows, err := s.db.Query(`SELECT ` + incidentCols + ` FROM incidents
|
||||||
|
WHERE status IN ('open','ack','resolved')
|
||||||
|
ORDER BY created_at DESC LIMIT 20`); err == nil {
|
||||||
|
for rows.Next() {
|
||||||
|
in, err := scanIncident(rows)
|
||||||
|
if err == nil {
|
||||||
|
in.Updates = s.incidentUpdates(in.ID)
|
||||||
|
incidents = append(incidents, *in)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
maintenance := []MaintWindow{}
|
||||||
|
if rows, err := s.db.Query(`SELECT ` + windowCols + ` FROM maintenance_windows
|
||||||
|
WHERE datetime(ends_at) > datetime('now') ORDER BY starts_at LIMIT 10`); err == nil {
|
||||||
|
for rows.Next() {
|
||||||
|
if w, err := scanWindow(rows); err == nil {
|
||||||
|
maintenance = append(maintenance, *w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Overall state: down wins over maintenance wins over pending.
|
||||||
|
overall := "up"
|
||||||
|
for _, m := range mons {
|
||||||
|
switch m.Status {
|
||||||
|
case "down":
|
||||||
|
overall = "down"
|
||||||
|
case "maintenance":
|
||||||
|
if overall == "up" {
|
||||||
|
overall = "maintenance"
|
||||||
|
}
|
||||||
|
case "pending":
|
||||||
|
if overall == "up" {
|
||||||
|
overall = "pending"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, sm := range systems {
|
||||||
|
if sm.Status == "down" {
|
||||||
|
overall = "down"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"title": p.Title,
|
||||||
|
"slug": p.Slug,
|
||||||
|
"description": p.Description,
|
||||||
|
"overall": overall,
|
||||||
|
"monitors": mons,
|
||||||
|
"systems": systems,
|
||||||
|
"incidents": incidents,
|
||||||
|
"maintenance": maintenance,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- auto-incident hook ----------
|
||||||
|
|
||||||
|
// autoIncident opens an incident when a monitor goes down and resolves the
|
||||||
|
// matching open incident when it recovers. Suppressed during maintenance.
|
||||||
|
func (s *Server) autoIncident(monitorID, name, next string) {
|
||||||
|
if s.inMaintenance(monitorID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if next == "down" {
|
||||||
|
// Don't stack duplicates on a flap loop.
|
||||||
|
var open string
|
||||||
|
if err := s.db.QueryRow(`SELECT id FROM incidents
|
||||||
|
WHERE monitor_id = ? AND status IN ('open','ack')`, monitorID).Scan(&open); err == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id := newID("inc")
|
||||||
|
title := fmt.Sprintf("Monitor %q is down", name)
|
||||||
|
if _, err := s.db.Exec(`INSERT INTO incidents (id, title, severity, status, monitor_id)
|
||||||
|
VALUES (?,?,'major','open',?)`, id, title, monitorID); err != nil {
|
||||||
|
s.log.Error("auto incident", zap.Error(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = s.db.Exec(`INSERT INTO incident_updates (incident_id, status, message)
|
||||||
|
VALUES (?,'open','Opened automatically — monitor reported down.')`, id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if next == "up" {
|
||||||
|
res, err := s.db.Exec(`UPDATE incidents SET status = 'resolved',
|
||||||
|
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')
|
||||||
|
WHERE monitor_id = ? AND status IN ('open','ack')`, monitorID)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n > 0 {
|
||||||
|
rows, _ := s.db.Query(`SELECT id FROM incidents WHERE monitor_id = ? AND status = 'resolved'`, monitorID)
|
||||||
|
var ids []string
|
||||||
|
if rows != nil {
|
||||||
|
for rows.Next() {
|
||||||
|
var id string
|
||||||
|
if rows.Scan(&id) == nil {
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
}
|
||||||
|
for _, id := range ids {
|
||||||
|
_, _ = s.db.Exec(`INSERT INTO incident_updates (incident_id, status, message)
|
||||||
|
VALUES (?,'resolved','Resolved automatically — monitor recovered.')`, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,343 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/tdvorak/dash/internal/widget"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---------- model ----------
|
||||||
|
|
||||||
|
// StatSample is one agent payload. Numeric fields are optional-ish: the
|
||||||
|
// agent may omit what a platform cannot provide.
|
||||||
|
type StatSample struct {
|
||||||
|
Host string `json:"host"`
|
||||||
|
OS string `json:"os"`
|
||||||
|
Arch string `json:"arch"`
|
||||||
|
CPUModel string `json:"cpuModel"`
|
||||||
|
Cores int `json:"cores"`
|
||||||
|
IntervalS int `json:"intervalS"`
|
||||||
|
UptimeS float64 `json:"uptimeS"`
|
||||||
|
CPU float64 `json:"cpu"` // percent
|
||||||
|
MemTotal float64 `json:"memTotal"` // bytes
|
||||||
|
MemUsed float64 `json:"memUsed"`
|
||||||
|
SwapTotal float64 `json:"swapTotal"`
|
||||||
|
SwapUsed float64 `json:"swapUsed"`
|
||||||
|
DiskTotal float64 `json:"diskTotal"` // bytes, root fs
|
||||||
|
DiskUsed float64 `json:"diskUsed"`
|
||||||
|
NetRx float64 `json:"netRx"` // bytes/sec
|
||||||
|
NetTx float64 `json:"netTx"`
|
||||||
|
Load1 float64 `json:"load1"`
|
||||||
|
Load5 float64 `json:"load5"`
|
||||||
|
Load15 float64 `json:"load15"`
|
||||||
|
Temps map[string]float64 `json:"temps"`
|
||||||
|
Containers []ContainerStat `json:"containers"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ContainerStat is one Docker container's liveness; per-container CPU/mem
|
||||||
|
// needs cgroup sampling — deferred (v1 ships name+state only).
|
||||||
|
type ContainerStat struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
State string `json:"state"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// System is one monitored host; `latest` is the newest StatSample.
|
||||||
|
type System struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Token string `json:"token"`
|
||||||
|
Host string `json:"host"`
|
||||||
|
OS string `json:"os"`
|
||||||
|
Arch string `json:"arch"`
|
||||||
|
CPUModel string `json:"cpuModel"`
|
||||||
|
Cores int `json:"cores"`
|
||||||
|
IntervalS int `json:"intervalS"`
|
||||||
|
Status string `json:"status"` // pending | up | down
|
||||||
|
LastSeenAt *string `json:"lastSeenAt"`
|
||||||
|
Latest *StatSample `json:"latest"`
|
||||||
|
CreatedAt string `json:"createdAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
const systemCols = `id, name, token, host, os, arch, cpu_model, cores,
|
||||||
|
interval_s, status, last_seen_at, latest, created_at`
|
||||||
|
|
||||||
|
func scanSystem(row interface{ Scan(...any) error }) (*System, error) {
|
||||||
|
var sys System
|
||||||
|
var latest string
|
||||||
|
err := row.Scan(&sys.ID, &sys.Name, &sys.Token, &sys.Host, &sys.OS,
|
||||||
|
&sys.Arch, &sys.CPUModel, &sys.Cores, &sys.IntervalS, &sys.Status,
|
||||||
|
&sys.LastSeenAt, &latest, &sys.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if latest != "" && latest != "{}" {
|
||||||
|
var s StatSample
|
||||||
|
if json.Unmarshal([]byte(latest), &s) == nil {
|
||||||
|
sys.Latest = &s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &sys, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) loadSystem(id string) (*System, error) {
|
||||||
|
return scanSystem(s.db.QueryRow(`SELECT `+systemCols+` FROM systems WHERE id = ?`, id))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) loadSystemByToken(token string) (*System, error) {
|
||||||
|
return scanSystem(s.db.QueryRow(
|
||||||
|
`SELECT `+systemCols+` FROM systems WHERE token = ?`, token))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- handlers ----------
|
||||||
|
|
||||||
|
func (s *Server) listSystemsH(c *gin.Context) {
|
||||||
|
rows, err := s.db.Query(`SELECT ` + systemCols + ` FROM systems ORDER BY name`)
|
||||||
|
if err != nil {
|
||||||
|
fail(c, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := []System{}
|
||||||
|
for rows.Next() {
|
||||||
|
sys, err := scanSystem(rows)
|
||||||
|
if err != nil {
|
||||||
|
fail(c, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out = append(out, *sys)
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) createSystem(c *gin.Context) {
|
||||||
|
var in struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&in); err != nil || in.Name == "" {
|
||||||
|
fail(c, http.StatusBadRequest, "name required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id := newID("sys")
|
||||||
|
if _, err := s.db.Exec(`INSERT INTO systems (id, name, token) VALUES (?,?,?)`,
|
||||||
|
id, in.Name, newID("agent")); err != nil {
|
||||||
|
fail(c, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sys, err := s.loadSystem(id)
|
||||||
|
if err != nil {
|
||||||
|
fail(c, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusCreated, sys)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) getSystem(c *gin.Context) {
|
||||||
|
sys, err := s.loadSystem(c.Param("id"))
|
||||||
|
if notFound(err) {
|
||||||
|
fail(c, http.StatusNotFound, "system not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
fail(c, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, sys)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) patchSystem(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
if _, err := s.loadSystem(id); notFound(err) {
|
||||||
|
fail(c, http.StatusNotFound, "system not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var in struct {
|
||||||
|
Name *string `json:"name"`
|
||||||
|
Rotate *bool `json:"rotateToken"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&in); err != nil {
|
||||||
|
fail(c, http.StatusBadRequest, "invalid body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if in.Name != nil && *in.Name != "" {
|
||||||
|
_, _ = s.db.Exec(`UPDATE systems SET name = ? WHERE id = ?`, *in.Name, id)
|
||||||
|
}
|
||||||
|
if in.Rotate != nil && *in.Rotate {
|
||||||
|
_, _ = s.db.Exec(`UPDATE systems SET token = ? WHERE id = ?`, newID("agent"), id)
|
||||||
|
}
|
||||||
|
sys, err := s.loadSystem(id)
|
||||||
|
if err != nil {
|
||||||
|
fail(c, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, sys)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) deleteSystem(c *gin.Context) {
|
||||||
|
res, err := s.db.Exec(`DELETE FROM systems WHERE id = ?`, c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
fail(c, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
|
fail(c, http.StatusNotFound, "system not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// systemStats returns stored samples newest-last for charting.
|
||||||
|
func (s *Server) systemStats(c *gin.Context) {
|
||||||
|
if _, err := s.loadSystem(c.Param("id")); notFound(err) {
|
||||||
|
fail(c, http.StatusNotFound, "system not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hours, _ := strconv.Atoi(c.DefaultQuery("hours", "24"))
|
||||||
|
if hours <= 0 || hours > 24*7 {
|
||||||
|
hours = 24
|
||||||
|
}
|
||||||
|
rows, err := s.db.Query(`SELECT ts, payload FROM system_stats
|
||||||
|
WHERE system_id = ? AND ts >= datetime('now', ?) ORDER BY ts`,
|
||||||
|
c.Param("id"), "-"+strconv.Itoa(hours)+" hours")
|
||||||
|
if err != nil {
|
||||||
|
fail(c, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
type sample struct {
|
||||||
|
TS string `json:"ts"`
|
||||||
|
StatSample
|
||||||
|
}
|
||||||
|
out := []sample{}
|
||||||
|
for rows.Next() {
|
||||||
|
var ts, payload string
|
||||||
|
if err := rows.Scan(&ts, &payload); err != nil {
|
||||||
|
fail(c, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var sm sample
|
||||||
|
sm.TS = ts
|
||||||
|
if json.Unmarshal([]byte(payload), &sm.StatSample) == nil {
|
||||||
|
out = append(out, sm)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ingestSystem receives one agent push. Auth: Authorization: Bearer <token>.
|
||||||
|
func (s *Server) ingestSystem(c *gin.Context) {
|
||||||
|
token := strings.TrimPrefix(c.GetHeader("Authorization"), "Bearer ")
|
||||||
|
if token == "" {
|
||||||
|
token = c.GetHeader("X-Dash-Token")
|
||||||
|
}
|
||||||
|
if token == "" {
|
||||||
|
fail(c, http.StatusUnauthorized, "token required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sys, err := s.loadSystemByToken(token)
|
||||||
|
if err != nil {
|
||||||
|
fail(c, http.StatusUnauthorized, "unknown token")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var sample StatSample
|
||||||
|
if err := c.ShouldBindJSON(&sample); err != nil {
|
||||||
|
fail(c, http.StatusBadRequest, "invalid body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if sample.IntervalS <= 0 {
|
||||||
|
sample.IntervalS = 10
|
||||||
|
}
|
||||||
|
payload, _ := json.Marshal(&sample)
|
||||||
|
prev := sys.Status
|
||||||
|
_, err = s.db.Exec(`UPDATE systems SET
|
||||||
|
host = ?, os = ?, arch = ?, cpu_model = ?, cores = ?, interval_s = ?,
|
||||||
|
status = 'up', last_seen_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'),
|
||||||
|
latest = ?
|
||||||
|
WHERE id = ?`,
|
||||||
|
sample.Host, sample.OS, sample.Arch, sample.CPUModel, sample.Cores,
|
||||||
|
sample.IntervalS, string(payload), sys.ID)
|
||||||
|
if err != nil {
|
||||||
|
fail(c, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := s.db.Exec(`INSERT INTO system_stats (system_id, payload) VALUES (?,?)`,
|
||||||
|
sys.ID, string(payload)); err != nil {
|
||||||
|
s.log.Error("system stats insert", zap.Error(err))
|
||||||
|
}
|
||||||
|
if prev != "up" {
|
||||||
|
s.notify("system.up", sys.Name,
|
||||||
|
fmt.Sprintf("System %q is back online (%s)", sys.Name, sample.Host))
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// sweepSystems marks agents down when silent for > 3 intervals (min 30s).
|
||||||
|
func (s *Server) sweepSystems() {
|
||||||
|
rows, err := s.db.Query(`UPDATE systems SET status = 'down'
|
||||||
|
WHERE status = 'up' AND last_seen_at IS NOT NULL
|
||||||
|
AND datetime(last_seen_at, '+' || MAX(3 * interval_s, 30) || ' seconds') <= datetime('now')
|
||||||
|
RETURNING id, name, host`)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Error("system sweep", zap.Error(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var id, name, host string
|
||||||
|
if rows.Scan(&id, &name, &host) == nil {
|
||||||
|
s.notify("system.down", name,
|
||||||
|
fmt.Sprintf("System %q stopped reporting (%s)", name, host))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- widget fetcher ----------
|
||||||
|
|
||||||
|
// systemWidget serves board tiles bound to a system via config.systemId.
|
||||||
|
type systemWidget struct{ db *sql.DB }
|
||||||
|
|
||||||
|
func (w *systemWidget) Meta() widget.Type {
|
||||||
|
return widget.Type{
|
||||||
|
Type: "system", Name: "System",
|
||||||
|
Description: "Live CPU/memory/disk of a monitored host",
|
||||||
|
Fields: []widget.Field{
|
||||||
|
{Key: "systemId", Label: "System", Required: true},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *systemWidget) Fetch(_ context.Context, cfg json.RawMessage) (any, error) {
|
||||||
|
var c struct {
|
||||||
|
SystemID string `json:"systemId"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(cfg, &c); err != nil || c.SystemID == "" {
|
||||||
|
return nil, errors.New("config.systemId required")
|
||||||
|
}
|
||||||
|
var name, status, latest string
|
||||||
|
var lastSeen *string
|
||||||
|
err := w.db.QueryRow(`SELECT name, status, last_seen_at, latest
|
||||||
|
FROM systems WHERE id = ?`, c.SystemID).Scan(&name, &status, &lastSeen, &latest)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("system not found")
|
||||||
|
}
|
||||||
|
var sm StatSample
|
||||||
|
_ = json.Unmarshal([]byte(latest), &sm)
|
||||||
|
return map[string]any{
|
||||||
|
"name": name,
|
||||||
|
"status": status,
|
||||||
|
"lastSeen": lastSeen,
|
||||||
|
"cpu": sm.CPU,
|
||||||
|
"memTotal": sm.MemTotal,
|
||||||
|
"memUsed": sm.MemUsed,
|
||||||
|
"diskTotal": sm.DiskTotal,
|
||||||
|
"diskUsed": sm.DiskUsed,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -2,16 +2,70 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// errBadInput marks payload validation failures so import handlers can map
|
||||||
|
// them to 400 while genuine DB failures stay 500.
|
||||||
|
var errBadInput = errors.New("invalid import payload")
|
||||||
|
|
||||||
|
// failImport maps replaceBoard errors: bad input -> 400, the rest -> 500.
|
||||||
|
func failImport(c *gin.Context, err error) {
|
||||||
|
if errors.Is(err, errBadInput) {
|
||||||
|
fail(c, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fail(c, http.StatusInternalServerError, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
// Export is the portable backup shape: version + settings + board tree.
|
// Export is the portable backup shape: version + settings + board tree.
|
||||||
|
// v2 adds monitoring entities alongside — additive, so v1 files still import.
|
||||||
type Export struct {
|
type Export struct {
|
||||||
Version int `json:"version"`
|
Version int `json:"version"`
|
||||||
Settings map[string]json.RawMessage `json:"settings"`
|
Settings map[string]json.RawMessage `json:"settings"`
|
||||||
Sections []Section `json:"sections"`
|
Sections []Section `json:"sections"`
|
||||||
|
Monitors []json.RawMessage `json:"monitors,omitempty"`
|
||||||
|
Domains []json.RawMessage `json:"domains,omitempty"`
|
||||||
|
Systems []json.RawMessage `json:"systems,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// dumpRows serializes each row of a table to JSON via the driver's column
|
||||||
|
// metadata — schema drift can't silently drop columns from the backup.
|
||||||
|
func (s *Server) dumpRows(table string) ([]json.RawMessage, error) {
|
||||||
|
rows, err := s.db.Query(`SELECT * FROM ` + table) // table is a fixed literal
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
cols, err := rows.Columns()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := []json.RawMessage{}
|
||||||
|
for rows.Next() {
|
||||||
|
vals := make([]any, len(cols))
|
||||||
|
ptrs := make([]any, len(cols))
|
||||||
|
for i := range vals {
|
||||||
|
ptrs[i] = &vals[i]
|
||||||
|
}
|
||||||
|
if err := rows.Scan(ptrs...); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
obj := map[string]any{}
|
||||||
|
for i, col := range cols {
|
||||||
|
obj[col] = vals[i]
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(obj)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, b)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) exportBoard(c *gin.Context) {
|
func (s *Server) exportBoard(c *gin.Context) {
|
||||||
@@ -25,7 +79,20 @@ func (s *Server) exportBoard(c *gin.Context) {
|
|||||||
fail(c, http.StatusInternalServerError, err.Error())
|
fail(c, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, Export{Version: 1, Settings: settings, Sections: sections})
|
ex := Export{Version: 2, Settings: settings, Sections: sections}
|
||||||
|
for _, t := range []struct {
|
||||||
|
name string
|
||||||
|
dst *[]json.RawMessage
|
||||||
|
}{
|
||||||
|
{"monitors", &ex.Monitors},
|
||||||
|
{"domains", &ex.Domains},
|
||||||
|
{"systems", &ex.Systems},
|
||||||
|
} {
|
||||||
|
if rows, err := s.dumpRows(t.name); err == nil {
|
||||||
|
*t.dst = rows
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, ex)
|
||||||
}
|
}
|
||||||
|
|
||||||
// importBoard replaces board state inside one transaction. Ids from the
|
// importBoard replaces board state inside one transaction. Ids from the
|
||||||
@@ -36,75 +103,10 @@ func (s *Server) importBoard(c *gin.Context) {
|
|||||||
fail(c, http.StatusBadRequest, "invalid export payload")
|
fail(c, http.StatusBadRequest, "invalid export payload")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if err := s.replaceBoard(in.Sections, in.Settings); err != nil {
|
||||||
tx, err := s.db.Begin()
|
failImport(c, err)
|
||||||
if err != nil {
|
|
||||||
fail(c, http.StatusInternalServerError, err.Error())
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer func() { _ = tx.Rollback() }()
|
|
||||||
|
|
||||||
for _, q := range []string{`DELETE FROM urls`, `DELETE FROM items`, `DELETE FROM sections`} {
|
|
||||||
if _, err := tx.Exec(q); err != nil {
|
|
||||||
fail(c, http.StatusInternalServerError, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, sec := range in.Sections {
|
|
||||||
if sec.ID == "" || sec.Name == "" {
|
|
||||||
fail(c, http.StatusBadRequest, "section missing id or name")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if _, err := tx.Exec(`INSERT INTO sections (id, name, position, collapsed) VALUES (?, ?, ?, ?)`,
|
|
||||||
sec.ID, sec.Name, sec.Position, sec.Collapsed); err != nil {
|
|
||||||
fail(c, http.StatusInternalServerError, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for _, it := range sec.Items {
|
|
||||||
if it.ID == "" || it.Name == "" {
|
|
||||||
fail(c, http.StatusBadRequest, "item missing id or name")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var cfg any
|
|
||||||
if it.Config != nil {
|
|
||||||
cfg = string(*it.Config)
|
|
||||||
}
|
|
||||||
if _, err := tx.Exec(
|
|
||||||
`INSERT INTO items (id, section_id, kind, name, icon, position, config) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
||||||
it.ID, sec.ID, orDefault(it.Kind, "service"), it.Name, it.Icon, it.Position, cfg); err != nil {
|
|
||||||
fail(c, http.StatusInternalServerError, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for _, u := range it.URLs {
|
|
||||||
if !validURL(u.URL) {
|
|
||||||
fail(c, http.StatusBadRequest, "invalid url: "+u.URL)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
uid := u.ID
|
|
||||||
if uid == "" {
|
|
||||||
uid = newID("u")
|
|
||||||
}
|
|
||||||
if _, err := tx.Exec(`INSERT INTO urls (id, item_id, url, label, position) VALUES (?, ?, ?, ?, ?)`,
|
|
||||||
uid, it.ID, u.URL, u.Label, u.Position); err != nil {
|
|
||||||
fail(c, http.StatusInternalServerError, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for k, v := range in.Settings {
|
|
||||||
if _, err := tx.Exec(
|
|
||||||
`INSERT INTO settings (key, value) VALUES (?, ?)
|
|
||||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value`, k, string(v)); err != nil {
|
|
||||||
fail(c, http.StatusInternalServerError, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := tx.Commit(); err != nil {
|
|
||||||
fail(c, http.StatusInternalServerError, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
sections, err := s.board()
|
sections, err := s.board()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fail(c, http.StatusInternalServerError, err.Error())
|
fail(c, http.StatusInternalServerError, err.Error())
|
||||||
@@ -113,6 +115,66 @@ func (s *Server) importBoard(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, sections)
|
c.JSON(http.StatusOK, sections)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// replaceBoard swaps all board rows inside one transaction — shared by the
|
||||||
|
// Dash export import and the external-config importers.
|
||||||
|
func (s *Server) replaceBoard(sections []Section, settings map[string]json.RawMessage) error {
|
||||||
|
tx, err := s.db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
|
||||||
|
for _, q := range []string{`DELETE FROM urls`, `DELETE FROM items`, `DELETE FROM sections`} {
|
||||||
|
if _, err := tx.Exec(q); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, sec := range sections {
|
||||||
|
if sec.ID == "" || sec.Name == "" {
|
||||||
|
return fmt.Errorf("%w: section missing id or name", errBadInput)
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(`INSERT INTO sections (id, name, position, collapsed) VALUES (?, ?, ?, ?)`,
|
||||||
|
sec.ID, sec.Name, sec.Position, sec.Collapsed); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, it := range sec.Items {
|
||||||
|
if it.ID == "" || it.Name == "" {
|
||||||
|
return fmt.Errorf("%w: item missing id or name", errBadInput)
|
||||||
|
}
|
||||||
|
var cfg any
|
||||||
|
if it.Config != nil {
|
||||||
|
cfg = string(*it.Config)
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(
|
||||||
|
`INSERT INTO items (id, section_id, kind, name, icon, position, config) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
it.ID, sec.ID, orDefault(it.Kind, "service"), it.Name, it.Icon, it.Position, cfg); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, u := range it.URLs {
|
||||||
|
if !validURL(u.URL) {
|
||||||
|
return fmt.Errorf("%w: invalid url: %s", errBadInput, u.URL)
|
||||||
|
}
|
||||||
|
uid := u.ID
|
||||||
|
if uid == "" {
|
||||||
|
uid = newID("u")
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(`INSERT INTO urls (id, item_id, url, label, position) VALUES (?, ?, ?, ?, ?)`,
|
||||||
|
uid, it.ID, u.URL, u.Label, u.Position); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for k, v := range settings {
|
||||||
|
if _, err := tx.Exec(
|
||||||
|
`INSERT INTO settings (key, value) VALUES (?, ?)
|
||||||
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value`, k, string(v)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
func orDefault(v, def string) string {
|
func orDefault(v, def string) string {
|
||||||
if v == "" {
|
if v == "" {
|
||||||
return def
|
return def
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
-- +goose Up
|
||||||
|
-- Uptime monitors and their check history (merge phase M1).
|
||||||
|
CREATE TABLE monitors (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
type TEXT NOT NULL CHECK (type IN ('http','tcp','ping','dns','keyword','json','push')),
|
||||||
|
url TEXT NOT NULL DEFAULT '',
|
||||||
|
hostname TEXT NOT NULL DEFAULT '',
|
||||||
|
port INTEGER NOT NULL DEFAULT 0,
|
||||||
|
method TEXT NOT NULL DEFAULT 'GET',
|
||||||
|
headers JSON NOT NULL DEFAULT '{}',
|
||||||
|
body TEXT NOT NULL DEFAULT '',
|
||||||
|
keyword TEXT NOT NULL DEFAULT '',
|
||||||
|
keyword_invert INTEGER NOT NULL DEFAULT 0,
|
||||||
|
json_query TEXT NOT NULL DEFAULT '',
|
||||||
|
expected TEXT NOT NULL DEFAULT '',
|
||||||
|
dns_type TEXT NOT NULL DEFAULT 'A',
|
||||||
|
interval_s INTEGER NOT NULL DEFAULT 60,
|
||||||
|
timeout_s INTEGER NOT NULL DEFAULT 10,
|
||||||
|
retries INTEGER NOT NULL DEFAULT 0,
|
||||||
|
active INTEGER NOT NULL DEFAULT 1,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('up','down','pending','paused','maintenance')),
|
||||||
|
push_token TEXT NOT NULL DEFAULT '',
|
||||||
|
tags JSON NOT NULL DEFAULT '[]',
|
||||||
|
notes TEXT NOT NULL DEFAULT '',
|
||||||
|
position REAL NOT NULL DEFAULT 0,
|
||||||
|
last_check TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||||
|
);
|
||||||
|
CREATE INDEX monitors_due ON monitors(active, last_check);
|
||||||
|
|
||||||
|
CREATE TABLE heartbeats (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
monitor_id TEXT NOT NULL REFERENCES monitors(id) ON DELETE CASCADE,
|
||||||
|
status TEXT NOT NULL CHECK (status IN ('up','down')),
|
||||||
|
ping_ms INTEGER NOT NULL DEFAULT 0,
|
||||||
|
msg TEXT NOT NULL DEFAULT '',
|
||||||
|
cert_expiry INTEGER NOT NULL DEFAULT -1,
|
||||||
|
checked_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||||
|
);
|
||||||
|
CREATE INDEX heartbeats_monitor_time ON heartbeats(monitor_id, checked_at);
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
DROP TABLE heartbeats;
|
||||||
|
DROP TABLE monitors;
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
-- +goose Up
|
||||||
|
-- Domain monitoring (merge phase M2). One wide table mirrors the flat
|
||||||
|
-- lookup Result; domain_checks snapshots each refresh for change history.
|
||||||
|
CREATE TABLE domains (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
tld TEXT NOT NULL DEFAULT '',
|
||||||
|
active INTEGER NOT NULL DEFAULT 1,
|
||||||
|
auto_renew INTEGER NOT NULL DEFAULT 0,
|
||||||
|
alert_days_before INTEGER NOT NULL DEFAULT 30,
|
||||||
|
tags JSON NOT NULL DEFAULT '[]',
|
||||||
|
notes TEXT NOT NULL DEFAULT '',
|
||||||
|
-- whois / rdap
|
||||||
|
expiry_date TEXT,
|
||||||
|
creation_date TEXT,
|
||||||
|
updated_date TEXT,
|
||||||
|
registrar_name TEXT NOT NULL DEFAULT '',
|
||||||
|
registrar_id TEXT NOT NULL DEFAULT '',
|
||||||
|
registrar_url TEXT NOT NULL DEFAULT '',
|
||||||
|
registry_domain_id TEXT NOT NULL DEFAULT '',
|
||||||
|
dnssec TEXT NOT NULL DEFAULT '',
|
||||||
|
statuses JSON NOT NULL DEFAULT '[]',
|
||||||
|
privacy_enabled INTEGER NOT NULL DEFAULT 0,
|
||||||
|
transfer_lock INTEGER NOT NULL DEFAULT 0,
|
||||||
|
registrant_name TEXT NOT NULL DEFAULT '',
|
||||||
|
registrant_org TEXT NOT NULL DEFAULT '',
|
||||||
|
registrant_country TEXT NOT NULL DEFAULT '',
|
||||||
|
abuse_email TEXT NOT NULL DEFAULT '',
|
||||||
|
-- dns
|
||||||
|
name_servers JSON NOT NULL DEFAULT '[]',
|
||||||
|
mx_records JSON NOT NULL DEFAULT '[]',
|
||||||
|
txt_records JSON NOT NULL DEFAULT '[]',
|
||||||
|
cname TEXT NOT NULL DEFAULT '',
|
||||||
|
ipv4 JSON NOT NULL DEFAULT '[]',
|
||||||
|
ipv6 JSON NOT NULL DEFAULT '[]',
|
||||||
|
-- tls leaf cert
|
||||||
|
ssl_issuer TEXT NOT NULL DEFAULT '',
|
||||||
|
ssl_valid_from TEXT,
|
||||||
|
ssl_valid_to TEXT,
|
||||||
|
ssl_subject TEXT NOT NULL DEFAULT '',
|
||||||
|
ssl_fingerprint TEXT NOT NULL DEFAULT '',
|
||||||
|
ssl_key_size INTEGER NOT NULL DEFAULT 0,
|
||||||
|
ssl_sig_algo TEXT NOT NULL DEFAULT '',
|
||||||
|
ssl_alt_names JSON NOT NULL DEFAULT '[]',
|
||||||
|
-- host geo
|
||||||
|
host_country TEXT NOT NULL DEFAULT '',
|
||||||
|
host_country_code TEXT NOT NULL DEFAULT '',
|
||||||
|
host_region TEXT NOT NULL DEFAULT '',
|
||||||
|
host_city TEXT NOT NULL DEFAULT '',
|
||||||
|
host_isp TEXT NOT NULL DEFAULT '',
|
||||||
|
host_org TEXT NOT NULL DEFAULT '',
|
||||||
|
host_as TEXT NOT NULL DEFAULT '',
|
||||||
|
host_lat REAL NOT NULL DEFAULT 0,
|
||||||
|
host_lon REAL NOT NULL DEFAULT 0,
|
||||||
|
-- provider detection
|
||||||
|
dns_provider TEXT NOT NULL DEFAULT '',
|
||||||
|
email_provider TEXT NOT NULL DEFAULT '',
|
||||||
|
hosting_provider TEXT NOT NULL DEFAULT '',
|
||||||
|
ca_provider TEXT NOT NULL DEFAULT '',
|
||||||
|
headers JSON NOT NULL DEFAULT '{}',
|
||||||
|
favicon_url TEXT NOT NULL DEFAULT '',
|
||||||
|
lookup_error TEXT NOT NULL DEFAULT '',
|
||||||
|
-- scheduling
|
||||||
|
interval_h INTEGER NOT NULL DEFAULT 24,
|
||||||
|
last_checked TEXT,
|
||||||
|
position REAL NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||||
|
);
|
||||||
|
CREATE INDEX domains_due ON domains(active, last_checked);
|
||||||
|
|
||||||
|
CREATE TABLE domain_checks (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
domain_id TEXT NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
|
||||||
|
expiry_date TEXT,
|
||||||
|
ssl_valid_to TEXT,
|
||||||
|
ipv4 JSON NOT NULL DEFAULT '[]',
|
||||||
|
name_servers JSON NOT NULL DEFAULT '[]',
|
||||||
|
checked_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||||
|
);
|
||||||
|
CREATE INDEX domain_checks_domain_time ON domain_checks(domain_id, checked_at);
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
DROP TABLE domain_checks;
|
||||||
|
DROP TABLE domains;
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
-- +goose Up
|
||||||
|
CREATE TABLE systems (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
token TEXT NOT NULL UNIQUE,
|
||||||
|
host TEXT NOT NULL DEFAULT '',
|
||||||
|
os TEXT NOT NULL DEFAULT '',
|
||||||
|
arch TEXT NOT NULL DEFAULT '',
|
||||||
|
cpu_model TEXT NOT NULL DEFAULT '',
|
||||||
|
cores INTEGER NOT NULL DEFAULT 0,
|
||||||
|
interval_s INTEGER NOT NULL DEFAULT 10,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
last_seen_at TEXT,
|
||||||
|
latest TEXT NOT NULL DEFAULT '{}',
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Whole agent payload per tick; no sub-field queries, so one JSON column
|
||||||
|
-- instead of a wide row that drifts with every agent version.
|
||||||
|
CREATE TABLE system_stats (
|
||||||
|
system_id TEXT NOT NULL REFERENCES systems(id) ON DELETE CASCADE,
|
||||||
|
ts TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||||
|
payload TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (system_id, ts)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_system_stats_ts ON system_stats(ts);
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
DROP TABLE IF EXISTS system_stats;
|
||||||
|
DROP TABLE IF EXISTS systems;
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
-- +goose Up
|
||||||
|
CREATE TABLE incidents (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
severity TEXT NOT NULL DEFAULT 'major', -- minor | major | critical
|
||||||
|
status TEXT NOT NULL DEFAULT 'open', -- open | ack | resolved | closed
|
||||||
|
monitor_id TEXT REFERENCES monitors(id) ON DELETE SET NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_incidents_status ON incidents(status);
|
||||||
|
|
||||||
|
CREATE TABLE incident_updates (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
incident_id TEXT NOT NULL REFERENCES incidents(id) ON DELETE CASCADE,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
message TEXT NOT NULL DEFAULT '',
|
||||||
|
at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_incident_updates_inc ON incident_updates(incident_id);
|
||||||
|
|
||||||
|
CREATE TABLE maintenance_windows (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
starts_at TEXT NOT NULL,
|
||||||
|
ends_at TEXT NOT NULL,
|
||||||
|
monitor_ids TEXT NOT NULL DEFAULT '[]', -- JSON array; empty covers all
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE status_pages (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
slug TEXT NOT NULL UNIQUE,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
description TEXT NOT NULL DEFAULT '',
|
||||||
|
monitor_ids TEXT NOT NULL DEFAULT '[]', -- JSON array; empty = all monitors
|
||||||
|
system_ids TEXT NOT NULL DEFAULT '[]',
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
DROP TABLE IF EXISTS status_pages;
|
||||||
|
DROP TABLE IF EXISTS maintenance_windows;
|
||||||
|
DROP TABLE IF EXISTS incident_updates;
|
||||||
|
DROP TABLE IF EXISTS incidents;
|
||||||
@@ -0,0 +1,404 @@
|
|||||||
|
// Package domain is the domain-intelligence half of the Beszel/Domain-Locker
|
||||||
|
// merge: given a domain name it gathers WHOIS/RDAP registration data, DNS
|
||||||
|
// records, TLS certificate details, host geolocation, and provider hints.
|
||||||
|
// Pure lookup logic — no DB, no HTTP handlers.
|
||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/ecdsa"
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/tls"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Lookup bundles the shared HTTP client used by the network probes.
|
||||||
|
type Lookup struct {
|
||||||
|
http *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewLookup() *Lookup {
|
||||||
|
return &Lookup{http: &http.Client{Timeout: 10 * time.Second}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Result is everything one refresh learns about a domain. Fields stay flat —
|
||||||
|
// the API layer maps them 1:1 onto the domains table columns.
|
||||||
|
type Result struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
TLD string `json:"tld"`
|
||||||
|
|
||||||
|
// WHOIS / RDAP
|
||||||
|
ExpiryDate *time.Time `json:"expiryDate"`
|
||||||
|
CreationDate *time.Time `json:"creationDate"`
|
||||||
|
UpdatedDate *time.Time `json:"updatedDate"`
|
||||||
|
RegistrarName string `json:"registrarName"`
|
||||||
|
RegistrarID string `json:"registrarId"`
|
||||||
|
RegistrarURL string `json:"registrarUrl"`
|
||||||
|
RegistryDomainID string `json:"registryDomainId"`
|
||||||
|
DNSSEC string `json:"dnssec"`
|
||||||
|
Statuses []string `json:"statuses"`
|
||||||
|
PrivacyEnabled bool `json:"privacyEnabled"`
|
||||||
|
TransferLock bool `json:"transferLock"`
|
||||||
|
RegistrantName string `json:"registrantName"`
|
||||||
|
RegistrantOrg string `json:"registrantOrg"`
|
||||||
|
RegistrantCountry string `json:"registrantCountry"`
|
||||||
|
AbuseEmail string `json:"abuseEmail"`
|
||||||
|
|
||||||
|
// DNS
|
||||||
|
NameServers []string `json:"nameServers"`
|
||||||
|
MXRecords []string `json:"mxRecords"`
|
||||||
|
TXTRecords []string `json:"txtRecords"`
|
||||||
|
CNAME string `json:"cname"`
|
||||||
|
IPv4 []string `json:"ipv4"`
|
||||||
|
IPv6 []string `json:"ipv6"`
|
||||||
|
|
||||||
|
// TLS leaf certificate
|
||||||
|
SSLIssuer string `json:"sslIssuer"`
|
||||||
|
SSLValidFrom *time.Time `json:"sslValidFrom"`
|
||||||
|
SSLValidTo *time.Time `json:"sslValidTo"`
|
||||||
|
SSLSubject string `json:"sslSubject"`
|
||||||
|
SSLFingerprint string `json:"sslFingerprint"`
|
||||||
|
SSLKeySize int `json:"sslKeySize"`
|
||||||
|
SSLSigAlgo string `json:"sslSigAlgo"`
|
||||||
|
SSLAltNames []string `json:"sslAltNames"`
|
||||||
|
|
||||||
|
// Host geolocation (ip-api.com; empty when unreachable/private)
|
||||||
|
HostCountry string `json:"hostCountry"`
|
||||||
|
HostCountryCode string `json:"hostCountryCode"`
|
||||||
|
HostRegion string `json:"hostRegion"`
|
||||||
|
HostCity string `json:"hostCity"`
|
||||||
|
HostISP string `json:"hostIsp"`
|
||||||
|
HostOrg string `json:"hostOrg"`
|
||||||
|
HostAS string `json:"hostAs"`
|
||||||
|
HostLat float64 `json:"hostLat"`
|
||||||
|
HostLon float64 `json:"hostLon"`
|
||||||
|
|
||||||
|
// Provider detection
|
||||||
|
DNSProvider string `json:"dnsProvider"`
|
||||||
|
EmailProvider string `json:"emailProvider"`
|
||||||
|
HostingProvider string `json:"hostingProvider"`
|
||||||
|
CAProvider string `json:"caProvider"`
|
||||||
|
|
||||||
|
Headers map[string]string `json:"headers"`
|
||||||
|
FaviconURL string `json:"faviconUrl"`
|
||||||
|
Error string `json:"error"` // non-fatal partial failure note
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean normalises user input to a bare registrable hostname.
|
||||||
|
func Clean(name string) string {
|
||||||
|
name = strings.TrimSpace(strings.ToLower(name))
|
||||||
|
name = regexp.MustCompile(`^https?://`).ReplaceAllString(name, "")
|
||||||
|
name = regexp.MustCompile(`^www\.`).ReplaceAllString(name, "")
|
||||||
|
if i := strings.IndexAny(name, "/?#"); i >= 0 {
|
||||||
|
name = name[:i]
|
||||||
|
}
|
||||||
|
if i := strings.Index(name, ":"); i >= 0 {
|
||||||
|
name = name[:i]
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
// Valid reports whether s is a plausible DNS name we can query for.
|
||||||
|
func Valid(s string) bool { return validDomainName(s) }
|
||||||
|
|
||||||
|
// Run gathers everything. Individual probe failures are non-fatal — a domain
|
||||||
|
// with no HTTPS still returns WHOIS+DNS. The returned Error field carries the
|
||||||
|
// first fatal WHOIS failure when even registration data is unavailable.
|
||||||
|
func (l *Lookup) Run(ctx context.Context, rawName string) *Result {
|
||||||
|
name := Clean(rawName)
|
||||||
|
r := &Result{
|
||||||
|
Name: name,
|
||||||
|
TLD: tldOf(name),
|
||||||
|
Statuses: []string{},
|
||||||
|
NameServers: []string{},
|
||||||
|
MXRecords: []string{},
|
||||||
|
TXTRecords: []string{},
|
||||||
|
IPv4: []string{},
|
||||||
|
IPv6: []string{},
|
||||||
|
SSLAltNames: []string{},
|
||||||
|
Headers: map[string]string{},
|
||||||
|
FaviconURL: fmt.Sprintf("https://www.google.com/s2/favicons?domain=%s&sz=128", name),
|
||||||
|
}
|
||||||
|
|
||||||
|
if w, err := l.Whois(ctx, name); err == nil {
|
||||||
|
r.ExpiryDate = w.ExpiryDate
|
||||||
|
r.CreationDate = w.CreationDate
|
||||||
|
r.UpdatedDate = w.UpdatedDate
|
||||||
|
r.RegistrarName = w.RegistrarName
|
||||||
|
r.RegistrarID = w.RegistrarID
|
||||||
|
r.RegistrarURL = w.RegistrarURL
|
||||||
|
r.RegistryDomainID = w.RegistryDomainID
|
||||||
|
r.DNSSEC = w.DNSSEC
|
||||||
|
r.Statuses = w.Statuses
|
||||||
|
r.PrivacyEnabled = w.PrivacyEnabled
|
||||||
|
r.TransferLock = w.TransferLock
|
||||||
|
r.RegistrantName = w.RegistrantName
|
||||||
|
r.RegistrantOrg = w.RegistrantOrg
|
||||||
|
r.RegistrantCountry = w.RegistrantCountry
|
||||||
|
r.AbuseEmail = w.AbuseEmail
|
||||||
|
} else {
|
||||||
|
r.Error = "whois: " + err.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
l.dns(ctx, name, r)
|
||||||
|
l.cert(ctx, name, r)
|
||||||
|
if len(r.IPv4) > 0 {
|
||||||
|
l.host(r.IPv4[0], r)
|
||||||
|
}
|
||||||
|
l.headers(ctx, name, r)
|
||||||
|
|
||||||
|
r.DNSProvider = detectDNSProvider(r.NameServers)
|
||||||
|
r.EmailProvider = detectEmailProvider(r.MXRecords)
|
||||||
|
r.HostingProvider = detectHostingProvider(r.Headers)
|
||||||
|
r.CAProvider = detectCA(r.SSLIssuer)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- DNS --------------------------------------------------------------------
|
||||||
|
|
||||||
|
func (l *Lookup) dns(ctx context.Context, name string, r *Result) {
|
||||||
|
res := net.DefaultResolver
|
||||||
|
if ns, err := res.LookupNS(ctx, name); err == nil {
|
||||||
|
for _, n := range ns {
|
||||||
|
r.NameServers = append(r.NameServers, strings.TrimSuffix(strings.ToLower(n.Host), "."))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if mx, err := res.LookupMX(ctx, name); err == nil {
|
||||||
|
for _, m := range mx {
|
||||||
|
r.MXRecords = append(r.MXRecords, fmt.Sprintf("%s (pri %d)", strings.TrimSuffix(m.Host, "."), m.Pref))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if txt, err := res.LookupTXT(ctx, name); err == nil {
|
||||||
|
r.TXTRecords = txt
|
||||||
|
}
|
||||||
|
if c, err := res.LookupCNAME(ctx, name); err == nil && c != "" && strings.TrimSuffix(c, ".") != name {
|
||||||
|
r.CNAME = strings.TrimSuffix(c, ".")
|
||||||
|
}
|
||||||
|
if ips, err := res.LookupIP(ctx, "ip4", name); err == nil {
|
||||||
|
for _, ip := range ips {
|
||||||
|
r.IPv4 = append(r.IPv4, ip.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ips, err := res.LookupIP(ctx, "ip6", name); err == nil {
|
||||||
|
for _, ip := range ips {
|
||||||
|
r.IPv6 = append(r.IPv6, ip.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- TLS leaf certificate ----------------------------------------------------
|
||||||
|
|
||||||
|
func (l *Lookup) cert(ctx context.Context, name string, r *Result) {
|
||||||
|
d := &net.Dialer{Timeout: 5 * time.Second}
|
||||||
|
conn, err := tls.DialWithDialer(d, "tcp", net.JoinHostPort(name, "443"), &tls.Config{
|
||||||
|
ServerName: name,
|
||||||
|
InsecureSkipVerify: true, // inspecting, not authenticating — expiry is data
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
certs := conn.ConnectionState().PeerCertificates
|
||||||
|
if len(certs) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c := certs[0]
|
||||||
|
if len(c.Issuer.Organization) > 0 {
|
||||||
|
r.SSLIssuer = c.Issuer.Organization[0]
|
||||||
|
} else {
|
||||||
|
r.SSLIssuer = c.Issuer.CommonName
|
||||||
|
}
|
||||||
|
r.SSLValidFrom = &c.NotBefore
|
||||||
|
r.SSLValidTo = &c.NotAfter
|
||||||
|
r.SSLSubject = c.Subject.CommonName
|
||||||
|
fp := sha256.Sum256(c.Raw)
|
||||||
|
h := strings.ToUpper(hex.EncodeToString(fp[:]))
|
||||||
|
var parts []string
|
||||||
|
for i := 0; i+2 <= len(h); i += 2 {
|
||||||
|
parts = append(parts, h[i:i+2])
|
||||||
|
}
|
||||||
|
r.SSLFingerprint = strings.Join(parts, ":")
|
||||||
|
r.SSLSigAlgo = c.SignatureAlgorithm.String()
|
||||||
|
switch k := c.PublicKey.(type) {
|
||||||
|
case *rsa.PublicKey:
|
||||||
|
r.SSLKeySize = k.N.BitLen()
|
||||||
|
case *ecdsa.PublicKey:
|
||||||
|
r.SSLKeySize = k.Curve.Params().BitSize
|
||||||
|
}
|
||||||
|
for _, n := range c.DNSNames {
|
||||||
|
r.SSLAltNames = append(r.SSLAltNames, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Host geolocation (ip-api.com, free non-commercial tier) -----------------
|
||||||
|
|
||||||
|
func (l *Lookup) host(ip string, r *Result) {
|
||||||
|
req, err := http.NewRequest(http.MethodGet,
|
||||||
|
fmt.Sprintf("http://ip-api.com/json/%s?fields=status,country,countryCode,regionName,city,lat,lon,isp,org,as", ip), nil)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp, err := l.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
var g struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Country string `json:"country"`
|
||||||
|
Code string `json:"countryCode"`
|
||||||
|
Region string `json:"regionName"`
|
||||||
|
City string `json:"city"`
|
||||||
|
Lat float64 `json:"lat"`
|
||||||
|
Lon float64 `json:"lon"`
|
||||||
|
ISP string `json:"isp"`
|
||||||
|
Org string `json:"org"`
|
||||||
|
AS string `json:"as"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(io.LimitReader(resp.Body, 64<<10)).Decode(&g); err != nil || g.Status != "success" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.HostCountry, r.HostCountryCode = g.Country, g.Code
|
||||||
|
r.HostRegion, r.HostCity = g.Region, g.City
|
||||||
|
r.HostISP, r.HostOrg, r.HostAS = g.ISP, g.Org, g.AS
|
||||||
|
r.HostLat, r.HostLon = g.Lat, g.Lon
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- HTTP headers (feed provider detection) -----------------------------------
|
||||||
|
|
||||||
|
func (l *Lookup) headers(ctx context.Context, name string, r *Result) {
|
||||||
|
for _, scheme := range []string{"https", "http"} {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodHead, scheme+"://"+name, nil)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", "Dash domain lookup")
|
||||||
|
resp, err := l.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for k, v := range resp.Header {
|
||||||
|
if len(v) > 0 {
|
||||||
|
r.Headers[strings.ToLower(k)] = v[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Provider detection (condensed port of beszel's detect package) -----------
|
||||||
|
|
||||||
|
func detectDNSProvider(ns []string) string {
|
||||||
|
table := []struct{ sub, name string }{
|
||||||
|
{"cloudflare", "Cloudflare"}, {"awsdns", "Amazon Route 53"},
|
||||||
|
{"googledomains", "Google Domains"}, {"google.com", "Google Domains"},
|
||||||
|
{"namecheap", "Namecheap"}, {"godaddy", "GoDaddy"}, {"domaincontrol", "GoDaddy"},
|
||||||
|
{"nsone.net", "NS1"}, {"digitalocean", "DigitalOcean"}, {"linode", "Linode"},
|
||||||
|
{"vultr", "Vultr"}, {"he.net", "Hurricane Electric"}, {"dynect", "Dyn (Oracle)"},
|
||||||
|
{"ultradns", "UltraDNS"}, {"dnsimple", "DNSimple"}, {"hover", "Hover"},
|
||||||
|
{"enom", "eNom"}, {"worldnic", "Network Solutions"}, {"zoneedit", "ZoneEdit"},
|
||||||
|
{"easydns", "EasyDNS"}, {"gandi", "Gandi"}, {"ovh", "OVH"},
|
||||||
|
{"hetzner", "Hetzner"}, {"azure-dns", "Microsoft Azure"},
|
||||||
|
{"dns.google", "Google Cloud DNS"}, {"porkbun", "Porkbun"},
|
||||||
|
{"cloudns", "ClouDNS"}, {"desec", "deSEC"}, {"nic.cz", "CZ.NIC"},
|
||||||
|
}
|
||||||
|
for _, n := range ns {
|
||||||
|
n = strings.ToLower(n)
|
||||||
|
for _, t := range table {
|
||||||
|
if strings.Contains(n, t.sub) {
|
||||||
|
return t.name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func detectEmailProvider(mx []string) string {
|
||||||
|
table := []struct{ sub, name string }{
|
||||||
|
{"google.com", "Google Workspace"}, {"aspmx", "Google Workspace"},
|
||||||
|
{"outlook.com", "Microsoft 365"}, {"protection.outlook", "Microsoft 365"},
|
||||||
|
{"mail.protection", "Microsoft 365"}, {"protonmail", "Proton Mail"},
|
||||||
|
{"proton.me", "Proton Mail"}, {"zoho", "Zoho Mail"},
|
||||||
|
{"fastmail", "Fastmail"}, {"mimecast", "Mimecast"},
|
||||||
|
{"proofpoint", "Proofpoint"}, {"barracuda", "Barracuda"},
|
||||||
|
{"mailgun", "Mailgun"}, {"sendgrid", "SendGrid"},
|
||||||
|
{"amazonses", "Amazon SES"}, {"yandex", "Yandex"},
|
||||||
|
{"tuta", "Tuta"}, {"mailbox.org", "mailbox.org"},
|
||||||
|
}
|
||||||
|
for _, m := range mx {
|
||||||
|
m = strings.ToLower(m)
|
||||||
|
for _, t := range table {
|
||||||
|
if strings.Contains(m, t.sub) {
|
||||||
|
return t.name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func detectHostingProvider(h map[string]string) string {
|
||||||
|
server := strings.ToLower(h["server"] + " " + h["x-powered-by"] + " " + h["via"])
|
||||||
|
if _, ok := h["cf-ray"]; ok || strings.Contains(server, "cloudflare") {
|
||||||
|
return "Cloudflare"
|
||||||
|
}
|
||||||
|
if _, ok := h["x-vercel-id"]; ok || strings.Contains(server, "vercel") {
|
||||||
|
return "Vercel"
|
||||||
|
}
|
||||||
|
if _, ok := h["x-nf-request-id"]; ok || strings.Contains(server, "netlify") {
|
||||||
|
return "Netlify"
|
||||||
|
}
|
||||||
|
if _, ok := h["x-amz-cf-id"]; ok || strings.Contains(server, "cloudfront") {
|
||||||
|
return "AWS CloudFront"
|
||||||
|
}
|
||||||
|
if _, ok := h["x-github-request-id"]; ok || strings.Contains(server, "github") {
|
||||||
|
return "GitHub Pages"
|
||||||
|
}
|
||||||
|
if strings.Contains(server, "fly.io") || h["fly-request-id"] != "" {
|
||||||
|
return "Fly.io"
|
||||||
|
}
|
||||||
|
if strings.Contains(server, "render") {
|
||||||
|
return "Render"
|
||||||
|
}
|
||||||
|
table := []struct{ sub, name string }{
|
||||||
|
{"nginx", "nginx"}, {"apache", "Apache"}, {"caddy", "Caddy"},
|
||||||
|
{"litespeed", "LiteSpeed"}, {"openresty", "OpenResty"},
|
||||||
|
{"envoy", "Envoy"}, {"awselb", "AWS ELB"}, {"gws", "Google"},
|
||||||
|
{"kestrel", "Kestrel/.NET"}, {"cowboy", "Cowboy/Erlang"},
|
||||||
|
}
|
||||||
|
for _, t := range table {
|
||||||
|
if strings.Contains(server, t.sub) {
|
||||||
|
return t.name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func detectCA(issuer string) string {
|
||||||
|
i := strings.ToLower(issuer)
|
||||||
|
table := []struct{ sub, name string }{
|
||||||
|
{"let's encrypt", "Let's Encrypt"}, {"letsencrypt", "Let's Encrypt"},
|
||||||
|
{"digicert", "DigiCert"}, {"sectigo", "Sectigo"}, {"comodoca", "Sectigo"},
|
||||||
|
{"globalsign", "GlobalSign"}, {"godaddy", "GoDaddy"}, {"starfield", "GoDaddy"},
|
||||||
|
{"entrust", "Entrust"}, {"google trust", "Google Trust Services"},
|
||||||
|
{"amazon", "Amazon"}, {"zerossl", "ZeroSSL"}, {"buypass", "Buypass"},
|
||||||
|
{"ssl.com", "SSL.com"}, {"actalis", "Actalis"}, {"certum", "Certum"},
|
||||||
|
{"harica", "HARICA"}, {"swisssign", "SwissSign"}, {"cloudflare", "Cloudflare"},
|
||||||
|
{"pki.goog", "Google Trust Services"}, {"microsoft", "Microsoft"},
|
||||||
|
}
|
||||||
|
for _, t := range table {
|
||||||
|
if strings.Contains(i, t.sub) {
|
||||||
|
return t.name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,411 @@
|
|||||||
|
// WHOIS data acquisition. Ported from Beszel's hub/domains/whois — the
|
||||||
|
// portable parts only: RDAP via IANA bootstrap, TCP WHOIS on :43, and the
|
||||||
|
// registry-format parser. Dropped: native `whois` exec (breaks the
|
||||||
|
// single-binary model), EURid/whois.com HTML scraping (fragile), and the
|
||||||
|
// paid WhoisXML API.
|
||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// WhoisData is the registry half of a Lookup result.
|
||||||
|
type WhoisData struct {
|
||||||
|
DomainName string
|
||||||
|
Statuses []string
|
||||||
|
DNSSEC string // "signed" | "unsigned" | ""
|
||||||
|
ExpiryDate *time.Time
|
||||||
|
CreationDate *time.Time
|
||||||
|
UpdatedDate *time.Time
|
||||||
|
RegistrarName string
|
||||||
|
RegistrarID string
|
||||||
|
RegistrarURL string
|
||||||
|
RegistryDomainID string
|
||||||
|
RegistrantName string
|
||||||
|
RegistrantOrg string
|
||||||
|
RegistrantCountry string
|
||||||
|
AbuseEmail string
|
||||||
|
PrivacyEnabled bool
|
||||||
|
TransferLock bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whois looks up registration data: RDAP first (clean JSON), TCP WHOIS as
|
||||||
|
// fallback for TLDs without RDAP service.
|
||||||
|
func (l *Lookup) Whois(ctx context.Context, name string) (*WhoisData, error) {
|
||||||
|
if d, err := l.rdap(ctx, name); err == nil && d.valid() {
|
||||||
|
return d, nil
|
||||||
|
}
|
||||||
|
if d, err := tcpWhois(ctx, name); err == nil && d.valid() {
|
||||||
|
return d, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("no whois data for %s", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *WhoisData) valid() bool {
|
||||||
|
return d != nil && (d.ExpiryDate != nil || d.CreationDate != nil ||
|
||||||
|
(d.RegistrarName != "" && d.RegistrarName != "Unknown") || len(d.Statuses) > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- RDAP ------------------------------------------------------------------
|
||||||
|
|
||||||
|
// rdapBases caches TLD -> RDAP base URL from the IANA bootstrap registry.
|
||||||
|
var rdapBases = struct {
|
||||||
|
sync.Mutex
|
||||||
|
m map[string]string
|
||||||
|
}{m: map[string]string{}}
|
||||||
|
|
||||||
|
func (l *Lookup) rdap(ctx context.Context, name string) (*WhoisData, error) {
|
||||||
|
tld := tldOf(name)
|
||||||
|
if tld == "" {
|
||||||
|
return nil, fmt.Errorf("no tld")
|
||||||
|
}
|
||||||
|
base, err := rdapBase(ctx, tld)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, base+"/domain/"+name, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Accept", "application/rdap+json")
|
||||||
|
resp, err := l.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("rdap status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var r struct {
|
||||||
|
LdhName string `json:"ldhName"`
|
||||||
|
Handle string `json:"handle"`
|
||||||
|
Status []string `json:"status"`
|
||||||
|
Events []struct {
|
||||||
|
Action string `json:"eventAction"`
|
||||||
|
Date string `json:"eventDate"`
|
||||||
|
} `json:"events"`
|
||||||
|
Entities []struct {
|
||||||
|
Roles []string `json:"roles"`
|
||||||
|
PublicIDs []struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Identifier string `json:"identifier"`
|
||||||
|
} `json:"publicIds"`
|
||||||
|
VCard []any `json:"vcardArray"`
|
||||||
|
} `json:"entities"`
|
||||||
|
SecureDNS struct {
|
||||||
|
ZoneSigned bool `json:"zoneSigned"`
|
||||||
|
} `json:"secureDNS"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&r); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
d := &WhoisData{DomainName: r.LdhName, Statuses: r.Status, RegistryDomainID: r.Handle}
|
||||||
|
if r.SecureDNS.ZoneSigned {
|
||||||
|
d.DNSSEC = "signed"
|
||||||
|
}
|
||||||
|
for _, e := range r.Events {
|
||||||
|
t, err := time.Parse(time.RFC3339, e.Date)
|
||||||
|
if err != nil || t.IsZero() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch e.Action {
|
||||||
|
case "registration":
|
||||||
|
d.CreationDate = &t
|
||||||
|
case "expiration":
|
||||||
|
d.ExpiryDate = &t
|
||||||
|
case "last changed":
|
||||||
|
d.UpdatedDate = &t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, ent := range r.Entities {
|
||||||
|
if !hasRole(ent.Roles, "registrar") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// vcardArray: ["vcard", [[name, params, type, value], ...]]
|
||||||
|
if len(ent.VCard) > 1 {
|
||||||
|
if props, ok := ent.VCard[1].([]any); ok {
|
||||||
|
for _, p := range props {
|
||||||
|
if arr, ok := p.([]any); ok && len(arr) >= 4 && arr[0] == "fn" {
|
||||||
|
if s, ok := arr[3].(string); ok {
|
||||||
|
d.RegistrarName = s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, pid := range ent.PublicIDs {
|
||||||
|
if pid.Type == "IANA Registrar ID" {
|
||||||
|
d.RegistrarID = pid.Identifier
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
d.derive()
|
||||||
|
return d, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasRole(roles []string, want string) bool {
|
||||||
|
for _, r := range roles {
|
||||||
|
if r == want {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func rdapBase(ctx context.Context, tld string) (string, error) {
|
||||||
|
rdapBases.Lock()
|
||||||
|
defer rdapBases.Unlock()
|
||||||
|
if u, ok := rdapBases.m[tld]; ok {
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://data.iana.org/rdap/dns.json", nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
resp, err := (&http.Client{Timeout: 10 * time.Second}).Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
var boot struct {
|
||||||
|
Services [][]any `json:"services"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(io.LimitReader(resp.Body, 4<<20)).Decode(&boot); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
for _, svc := range boot.Services {
|
||||||
|
if len(svc) < 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
tlds, _ := svc[0].([]any)
|
||||||
|
urls, _ := svc[1].([]any)
|
||||||
|
if len(urls) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
base, _ := urls[0].(string)
|
||||||
|
for _, t := range tlds {
|
||||||
|
if s, ok := t.(string); ok {
|
||||||
|
rdapBases.m[s] = strings.TrimSuffix(base, "/")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if u, ok := rdapBases.m[tld]; ok {
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("no rdap server for .%s", tld)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- TCP WHOIS (:43) --------------------------------------------------------
|
||||||
|
|
||||||
|
var whoisServers = map[string]string{
|
||||||
|
"com": "whois.verisign-grs.com", "net": "whois.verisign-grs.com",
|
||||||
|
"org": "whois.pir.org", "io": "whois.nic.io", "co": "whois.nic.co",
|
||||||
|
"dev": "whois.nic.google", "app": "whois.nic.google", "xyz": "whois.nic.xyz",
|
||||||
|
"info": "whois.afilias.net", "biz": "whois.biz", "us": "whois.nic.us",
|
||||||
|
"uk": "whois.nic.uk", "de": "whois.denic.de", "fr": "whois.nic.fr",
|
||||||
|
"eu": "whois.eu", "nl": "whois.domain-registry.nl", "ca": "whois.cira.ca",
|
||||||
|
"au": "whois.auda.org.au", "me": "whois.nic.me", "tv": "whois.nic.tv",
|
||||||
|
"cc": "whois.nic.cc", "name": "whois.nic.name", "pro": "whois.nic.pro",
|
||||||
|
"cz": "whois.nic.cz", "sk": "whois.sk-nic.sk", "pl": "whois.dns.pl",
|
||||||
|
"se": "whois.iis.se", "fi": "whois.fi", "no": "whois.norid.no",
|
||||||
|
"ch": "whois.nic.ch", "at": "whois.nic.at", "be": "whois.dns.be",
|
||||||
|
"it": "whois.nic.it", "es": "whois.nic.es", "pt": "whois.dns.pt",
|
||||||
|
"jp": "whois.jprs.jp", "kr": "whois.kr", "in": "whois.registry.in",
|
||||||
|
"br": "whois.registro.br", "za": "whois.registry.net.za",
|
||||||
|
"cloud": "whois.nic.cloud", "online": "whois.nic.online",
|
||||||
|
"site": "whois.nic.site", "store": "whois.nic.store", "tech": "whois.nic.tech",
|
||||||
|
"ai": "whois.nic.ai", "gg": "whois.gg", "sh": "whois.nic.sh",
|
||||||
|
}
|
||||||
|
|
||||||
|
func tcpWhois(ctx context.Context, name string) (*WhoisData, error) {
|
||||||
|
tld := tldOf(name)
|
||||||
|
server, ok := whoisServers[tld]
|
||||||
|
if !ok {
|
||||||
|
server = "whois.iana.org"
|
||||||
|
}
|
||||||
|
d := &net.Dialer{Timeout: 10 * time.Second}
|
||||||
|
conn, err := d.DialContext(ctx, "tcp", net.JoinHostPort(server, "43"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
_ = conn.SetDeadline(time.Now().Add(15 * time.Second))
|
||||||
|
if _, err := conn.Write([]byte(name + "\r\n")); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
raw, err := io.ReadAll(io.LimitReader(conn, 1<<20))
|
||||||
|
if err != nil && len(raw) == 0 {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := parseWhois(string(raw), name)
|
||||||
|
out.derive()
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseWhois flattens "Key: Value" lines and extracts the fields we store.
|
||||||
|
// Registry formats vary wildly; the field-name fallbacks cover the common set.
|
||||||
|
func parseWhois(raw, name string) *WhoisData {
|
||||||
|
d := &WhoisData{DomainName: name}
|
||||||
|
kv := map[string]string{}
|
||||||
|
for _, line := range strings.Split(raw, "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" || strings.HasPrefix(line, "%") || strings.HasPrefix(line, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
idx := strings.Index(line, ":")
|
||||||
|
if idx <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := strings.ToLower(strings.TrimSpace(line[:idx]))
|
||||||
|
key = strings.NewReplacer(" ", "_", "/", "_", "-", "_").Replace(key)
|
||||||
|
val := strings.TrimSpace(line[idx+1:])
|
||||||
|
if val == "" || strings.HasPrefix(strings.ToUpper(val), "REDACTED") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, dup := kv[key]; !dup {
|
||||||
|
kv[key] = val
|
||||||
|
}
|
||||||
|
switch key {
|
||||||
|
case "domain_status", "status":
|
||||||
|
if s := parseStatus(val); len(s) > 0 {
|
||||||
|
d.Statuses = append(d.Statuses, s...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
d.ExpiryDate = parseDate(kv["registry_expiry_date"], kv["registrar_registration_expiration_date"],
|
||||||
|
kv["expiry_date"], kv["expiration_time"], kv["expire"], kv["paid_until"],
|
||||||
|
kv["expire_date"], kv["renewal_date"], kv["valid_until"], kv["expires"])
|
||||||
|
d.CreationDate = parseDate(kv["creation_date"], kv["created_date"], kv["created"],
|
||||||
|
kv["registration_time"], kv["registered_on"], kv["domain_registered"], kv["registered"])
|
||||||
|
d.UpdatedDate = parseDate(kv["updated_date"], kv["last_updated"], kv["last_modified"],
|
||||||
|
kv["modified_date"], kv["modified"], kv["changed"])
|
||||||
|
|
||||||
|
for _, k := range []string{"registrar", "registrar_name", "sponsoring_registrar", "registrar_organization"} {
|
||||||
|
if v := kv[k]; v != "" {
|
||||||
|
d.RegistrarName = v
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if d.RegistrarName == "" {
|
||||||
|
d.RegistrarName = "Unknown"
|
||||||
|
}
|
||||||
|
d.RegistrarID = kv["registrar_iana_id"]
|
||||||
|
d.RegistrarURL = kv["registrar_url"]
|
||||||
|
d.RegistryDomainID = kv["registry_domain_id"]
|
||||||
|
|
||||||
|
d.RegistrantName = firstOf(kv, "registrant_name", "registrant", "holder", "domain_holder")
|
||||||
|
d.RegistrantOrg = firstOf(kv, "registrant_organization", "org", "organization", "holder_org")
|
||||||
|
d.RegistrantCountry = firstOf(kv, "registrant_country", "country", "holder_country")
|
||||||
|
d.AbuseEmail = kv["registrar_abuse_contact_email"]
|
||||||
|
|
||||||
|
if v := strings.ToLower(firstOf(kv, "dnssec", "dnssec_signed", "signed_dnssec")); v == "signed" || v == "yes" || v == "true" || v == "signeddelegation" {
|
||||||
|
d.DNSSEC = "signed"
|
||||||
|
} else if v != "" {
|
||||||
|
d.DNSSEC = "unsigned"
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
// derive fills computed flags after whichever lookup path populated d.
|
||||||
|
func (d *WhoisData) derive() {
|
||||||
|
low := strings.ToLower(d.RegistrantName + " " + d.RegistrantOrg)
|
||||||
|
for _, marker := range []string{"redacted", "privacy", "whoisguard", "not disclosed", "hidden", "data protected", "gdpr"} {
|
||||||
|
if strings.Contains(low, marker) {
|
||||||
|
d.PrivacyEnabled = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, s := range d.Statuses {
|
||||||
|
if sl := strings.ToLower(s); strings.Contains(sl, "transferprohibited") {
|
||||||
|
d.TransferLock = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstOf(kv map[string]string, keys ...string) string {
|
||||||
|
for _, k := range keys {
|
||||||
|
if v := kv[k]; v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var knownStatuses = []string{
|
||||||
|
"clientDeleteProhibited", "clientHold", "clientRenewProhibited",
|
||||||
|
"clientTransferProhibited", "clientUpdateProhibited",
|
||||||
|
"serverDeleteProhibited", "serverHold", "serverRenewProhibited",
|
||||||
|
"serverTransferProhibited", "serverUpdateProhibited",
|
||||||
|
"inactive", "ok", "pendingCreate", "pendingDelete", "pendingRenew",
|
||||||
|
"pendingRestore", "pendingTransfer", "pendingUpdate",
|
||||||
|
"addPeriod", "autoRenewPeriod", "renewPeriod", "transferPeriod",
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseStatus(s string) []string {
|
||||||
|
s = strings.ToLower(s)
|
||||||
|
var out []string
|
||||||
|
for _, k := range knownStatuses {
|
||||||
|
if strings.Contains(s, strings.ToLower(k)) {
|
||||||
|
out = append(out, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
var dateFormats = []string{
|
||||||
|
time.RFC3339, "2006-01-02T15:04:05Z0700", "2006-01-02T15:04:05",
|
||||||
|
"2006-01-02 15:04:05", "2006-01-02 15:04:05.0", "2006-01-02",
|
||||||
|
"01/02/2006", "02/01/2006", "02.01.2006", "02-Jan-2006", "2-Jan-2006",
|
||||||
|
"Jan 2 2006", "January 2 2006", "2 Jan 2006", "Jan 02 2006",
|
||||||
|
"2006-01-02 15:04:05 MST", "Mon, 02 Jan 2006 15:04:05 MST",
|
||||||
|
"20060102", "20060102150405",
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseDate(candidates ...string) *time.Time {
|
||||||
|
for _, s := range candidates {
|
||||||
|
s = strings.TrimSpace(strings.TrimPrefix(strings.TrimPrefix(s, "before "), "after "))
|
||||||
|
if s == "" || strings.Contains(strings.ToUpper(s), "REDACTED") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Trim trailing "(YYYY-MM-DD)" style annotations and zone offsets in parens.
|
||||||
|
if i := strings.Index(s, "("); i > 0 {
|
||||||
|
s = strings.TrimSpace(s[:i])
|
||||||
|
}
|
||||||
|
for _, f := range dateFormats {
|
||||||
|
if t, err := time.Parse(f, s); err == nil && !t.IsZero() {
|
||||||
|
return &t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// tldOf returns the last label; registrable-domain granularity is
|
||||||
|
// unnecessary here since we only feed it to server maps.
|
||||||
|
func tldOf(name string) string {
|
||||||
|
parts := strings.Split(name, ".")
|
||||||
|
if len(parts) < 2 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.ToLower(parts[len(parts)-1])
|
||||||
|
}
|
||||||
|
|
||||||
|
// whoisPattern guards against sending garbage to whois servers.
|
||||||
|
var whoisPattern = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$`)
|
||||||
|
|
||||||
|
func validDomainName(s string) bool {
|
||||||
|
return len(s) <= 253 && whoisPattern.MatchString(s)
|
||||||
|
}
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
// Package monitor holds the uptime-check domain: the Monitor model mirroring
|
||||||
|
// the monitors table, plus the check implementations the scheduler invokes.
|
||||||
|
// No DB access, no HTTP handlers — pure check logic.
|
||||||
|
package monitor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/net/icmp"
|
||||||
|
"golang.org/x/net/ipv4"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Monitor mirrors one row of the monitors table.
|
||||||
|
type Monitor struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Hostname string `json:"hostname"`
|
||||||
|
Port int `json:"port"`
|
||||||
|
Method string `json:"method"`
|
||||||
|
Headers string `json:"headers"` // JSON object
|
||||||
|
Body string `json:"body"`
|
||||||
|
Keyword string `json:"keyword"`
|
||||||
|
KeywordInvert bool `json:"keywordInvert"`
|
||||||
|
JSONQuery string `json:"jsonQuery"`
|
||||||
|
Expected string `json:"expected"`
|
||||||
|
DNSType string `json:"dnsType"`
|
||||||
|
IntervalS int `json:"intervalS"`
|
||||||
|
TimeoutS int `json:"timeoutS"`
|
||||||
|
Retries int `json:"retries"`
|
||||||
|
Active bool `json:"active"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
PushToken string `json:"pushToken,omitempty"`
|
||||||
|
Tags []string `json:"tags"`
|
||||||
|
Notes string `json:"notes"`
|
||||||
|
Position float64 `json:"position"`
|
||||||
|
LastCheck *string `json:"lastCheck"`
|
||||||
|
CreatedAt string `json:"createdAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Result is the outcome of one check.
|
||||||
|
type Result struct {
|
||||||
|
Up bool
|
||||||
|
PingMs int
|
||||||
|
Msg string
|
||||||
|
CertExpiry int // days until TLS cert expiry; -1 when not applicable
|
||||||
|
}
|
||||||
|
|
||||||
|
// Types lists the check kinds the add-monitor dialog offers.
|
||||||
|
func Types() []string {
|
||||||
|
return []string{"http", "tcp", "ping", "dns", "keyword", "json", "push"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check dispatches to the checker for m.Type. Retries happen in the caller.
|
||||||
|
func Check(ctx context.Context, m *Monitor) *Result {
|
||||||
|
timeout := time.Duration(m.TimeoutS) * time.Second
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = 10 * time.Second
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
switch m.Type {
|
||||||
|
case "http", "keyword", "json":
|
||||||
|
return checkHTTP(ctx, m)
|
||||||
|
case "tcp":
|
||||||
|
return checkTCP(ctx, m)
|
||||||
|
case "ping":
|
||||||
|
return checkPing(ctx, m)
|
||||||
|
case "dns":
|
||||||
|
return checkDNS(ctx, m)
|
||||||
|
default:
|
||||||
|
return &Result{Msg: "unknown monitor type: " + m.Type}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func hostOf(m *Monitor) string {
|
||||||
|
if m.Hostname != "" {
|
||||||
|
return m.Hostname
|
||||||
|
}
|
||||||
|
return m.URL
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkHTTP(ctx context.Context, m *Monitor) *Result {
|
||||||
|
method := m.Method
|
||||||
|
if method == "" {
|
||||||
|
method = http.MethodGet
|
||||||
|
}
|
||||||
|
var body io.Reader
|
||||||
|
if m.Body != "" {
|
||||||
|
body = strings.NewReader(m.Body)
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, method, m.URL, body)
|
||||||
|
if err != nil {
|
||||||
|
return &Result{Msg: "bad url: " + m.URL}
|
||||||
|
}
|
||||||
|
var hdrs map[string]string
|
||||||
|
if json.Unmarshal([]byte(m.Headers), &hdrs) == nil {
|
||||||
|
for k, v := range hdrs {
|
||||||
|
req.Header.Set(k, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if m.Body != "" && req.Header.Get("Content-Type") == "" {
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
ping := int(time.Since(start).Milliseconds())
|
||||||
|
if err != nil {
|
||||||
|
return &Result{Msg: err.Error()}
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
res := &Result{PingMs: ping}
|
||||||
|
if resp.TLS != nil && len(resp.TLS.PeerCertificates) > 0 {
|
||||||
|
res.CertExpiry = int(time.Until(resp.TLS.PeerCertificates[0].NotAfter).Hours() / 24)
|
||||||
|
} else {
|
||||||
|
res.CertExpiry = -1
|
||||||
|
}
|
||||||
|
|
||||||
|
switch m.Type {
|
||||||
|
case "keyword", "json":
|
||||||
|
raw, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||||
|
if err != nil {
|
||||||
|
res.Msg = "read body: " + err.Error()
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
if m.Type == "keyword" {
|
||||||
|
found := strings.Contains(string(raw), m.Keyword)
|
||||||
|
if m.KeywordInvert {
|
||||||
|
found = !found
|
||||||
|
}
|
||||||
|
res.Up = found
|
||||||
|
res.Msg = fmt.Sprintf("keyword %q %s", m.Keyword, map[bool]string{true: "found", false: "absent"}[found])
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
return checkJSONBody(res, raw, m)
|
||||||
|
default:
|
||||||
|
res.Up = resp.StatusCode < 400
|
||||||
|
res.Msg = fmt.Sprintf("HTTP %d", resp.StatusCode)
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkJSONBody walks a dot path ("a.b.0.c") into decoded JSON and compares
|
||||||
|
// the leaf to m.Expected.
|
||||||
|
func checkJSONBody(res *Result, raw []byte, m *Monitor) *Result {
|
||||||
|
var v any
|
||||||
|
if err := json.Unmarshal(raw, &v); err != nil {
|
||||||
|
res.Msg = "invalid JSON: " + err.Error()
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
for _, key := range strings.Split(m.JSONQuery, ".") {
|
||||||
|
switch n := v.(type) {
|
||||||
|
case map[string]any:
|
||||||
|
v = n[key]
|
||||||
|
case []any:
|
||||||
|
i, err := strconv.Atoi(key)
|
||||||
|
if err != nil || i < 0 || i >= len(n) {
|
||||||
|
res.Msg = "json path missing: " + m.JSONQuery
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
v = n[i]
|
||||||
|
default:
|
||||||
|
res.Msg = "json path missing: " + m.JSONQuery
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
}
|
||||||
|
got := fmt.Sprint(v)
|
||||||
|
res.Up = got == m.Expected
|
||||||
|
res.Msg = fmt.Sprintf("%s = %q (want %q)", m.JSONQuery, got, m.Expected)
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkTCP(ctx context.Context, m *Monitor) *Result {
|
||||||
|
host := hostOf(m)
|
||||||
|
if host == "" {
|
||||||
|
return &Result{Msg: "hostname or url required"}
|
||||||
|
}
|
||||||
|
addr := net.JoinHostPort(host, strconv.Itoa(m.Port))
|
||||||
|
start := time.Now()
|
||||||
|
conn, err := (&net.Dialer{}).DialContext(ctx, "tcp", addr)
|
||||||
|
if err != nil {
|
||||||
|
return &Result{Msg: err.Error()}
|
||||||
|
}
|
||||||
|
_ = conn.Close()
|
||||||
|
return &Result{Up: true, PingMs: int(time.Since(start).Milliseconds()), Msg: "port open"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkPing sends one ICMP echo. Raw sockets need CAP_NET_RAW; the udp4
|
||||||
|
// datagram path works unprivileged on Linux via ping_group_range.
|
||||||
|
func checkPing(ctx context.Context, m *Monitor) *Result {
|
||||||
|
host := hostOf(m)
|
||||||
|
if host == "" {
|
||||||
|
return &Result{Msg: "hostname required"}
|
||||||
|
}
|
||||||
|
dst, err := net.ResolveIPAddr("ip4", host)
|
||||||
|
if err != nil {
|
||||||
|
if dst, err = net.ResolveIPAddr("ip6", host); err != nil {
|
||||||
|
return &Result{Msg: "resolve: " + err.Error()}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, err := icmp.ListenPacket("ip4:icmp", "0.0.0.0")
|
||||||
|
if err != nil {
|
||||||
|
conn, err = icmp.ListenPacket("udp4", "0.0.0.0")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return &Result{Msg: "icmp unavailable (needs cap_net_raw or ping_group_range): " + err.Error()}
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
_ = conn.SetDeadline(time.Now().Add(4 * time.Second))
|
||||||
|
|
||||||
|
wm := icmp.Message{Type: ipv4.ICMPTypeEcho, Code: 0, Body: &icmp.Echo{
|
||||||
|
ID: 1,
|
||||||
|
Seq: 1,
|
||||||
|
Data: []byte("dash"),
|
||||||
|
}}
|
||||||
|
wb, err := wm.Marshal(nil)
|
||||||
|
if err != nil {
|
||||||
|
return &Result{Msg: err.Error()}
|
||||||
|
}
|
||||||
|
start := time.Now()
|
||||||
|
var dstAddr net.Addr = dst
|
||||||
|
if _, err := conn.WriteTo(wb, dstAddr); err != nil {
|
||||||
|
return &Result{Msg: err.Error()}
|
||||||
|
}
|
||||||
|
rb := make([]byte, 1500)
|
||||||
|
for {
|
||||||
|
n, _, err := conn.ReadFrom(rb)
|
||||||
|
if err != nil {
|
||||||
|
return &Result{Msg: "no reply: " + err.Error()}
|
||||||
|
}
|
||||||
|
rm, err := icmp.ParseMessage(ipv4.ICMPTypeEchoReply.Protocol(), rb[:n])
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if rm.Type == ipv4.ICMPTypeEchoReply {
|
||||||
|
return &Result{Up: true, PingMs: int(time.Since(start).Milliseconds()), Msg: "echo reply"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkDNS(ctx context.Context, m *Monitor) *Result {
|
||||||
|
host := hostOf(m)
|
||||||
|
if host == "" {
|
||||||
|
return &Result{Msg: "hostname required"}
|
||||||
|
}
|
||||||
|
r := net.DefaultResolver
|
||||||
|
start := time.Now()
|
||||||
|
var answers []string
|
||||||
|
var err error
|
||||||
|
switch strings.ToUpper(m.DNSType) {
|
||||||
|
case "AAAA":
|
||||||
|
var ips []net.IP
|
||||||
|
ips, err = r.LookupIP(ctx, "ip6", host)
|
||||||
|
for _, ip := range ips {
|
||||||
|
answers = append(answers, ip.String())
|
||||||
|
}
|
||||||
|
case "MX":
|
||||||
|
var mxs []*net.MX
|
||||||
|
mxs, err = r.LookupMX(ctx, host)
|
||||||
|
for _, mx := range mxs {
|
||||||
|
answers = append(answers, mx.Host)
|
||||||
|
}
|
||||||
|
case "NS":
|
||||||
|
var nss []*net.NS
|
||||||
|
nss, err = r.LookupNS(ctx, host)
|
||||||
|
for _, ns := range nss {
|
||||||
|
answers = append(answers, ns.Host)
|
||||||
|
}
|
||||||
|
case "TXT":
|
||||||
|
answers, err = r.LookupTXT(ctx, host)
|
||||||
|
case "CNAME":
|
||||||
|
var c string
|
||||||
|
c, err = r.LookupCNAME(ctx, host)
|
||||||
|
if c != "" {
|
||||||
|
answers = []string{c}
|
||||||
|
}
|
||||||
|
default: // A
|
||||||
|
var ips []net.IP
|
||||||
|
ips, err = r.LookupIP(ctx, "ip4", host)
|
||||||
|
for _, ip := range ips {
|
||||||
|
answers = append(answers, ip.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ping := int(time.Since(start).Milliseconds())
|
||||||
|
if err != nil {
|
||||||
|
return &Result{Msg: err.Error()}
|
||||||
|
}
|
||||||
|
if len(answers) == 0 {
|
||||||
|
return &Result{Msg: "no records"}
|
||||||
|
}
|
||||||
|
res := &Result{Up: true, PingMs: ping, Msg: strings.Join(answers, ", ")}
|
||||||
|
if m.Expected != "" {
|
||||||
|
for _, a := range answers {
|
||||||
|
if strings.Contains(a, m.Expected) {
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
}
|
||||||
|
res.Up = false
|
||||||
|
res.Msg = fmt.Sprintf("no record contains %q (got %s)", m.Expected, res.Msg)
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
+2
-2
@@ -12,8 +12,8 @@
|
|||||||
document.documentElement.dataset.theme = t === "light" ? "light" : "dark";
|
document.documentElement.dataset.theme = t === "light" ? "light" : "dark";
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
</script>
|
</script>
|
||||||
<script type="module" crossorigin src="/assets/index-LtBmkZex.js"></script>
|
<script type="module" crossorigin src="/assets/index-WK6y87Wv.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-CoYED22n.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-CHTjdWqd.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
// AdGuard Home REST API: GET /control/stats with HTTP basic auth.
|
// AdGuard Home REST API: GET /control/stats with HTTP basic auth.
|
||||||
type adguard struct{}
|
type adguard struct{}
|
||||||
|
|
||||||
func init() { register(adguard{}) }
|
func init() { Register(adguard{}) }
|
||||||
|
|
||||||
func (adguard) Meta() Type {
|
func (adguard) Meta() Type {
|
||||||
return Type{
|
return Type{
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
// it and the add-widget dialog can offer it like any other type.
|
// it and the add-widget dialog can offer it like any other type.
|
||||||
type clock struct{}
|
type clock struct{}
|
||||||
|
|
||||||
func init() { register(clock{}) }
|
func init() { Register(clock{}) }
|
||||||
|
|
||||||
func (clock) Meta() Type {
|
func (clock) Meta() Type {
|
||||||
return Type{
|
return Type{
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
// Immich REST API: GET /api/statistics with an x-api-key header.
|
// Immich REST API: GET /api/statistics with an x-api-key header.
|
||||||
type immich struct{}
|
type immich struct{}
|
||||||
|
|
||||||
func init() { register(immich{}) }
|
func init() { Register(immich{}) }
|
||||||
|
|
||||||
func (immich) Meta() Type {
|
func (immich) Meta() Type {
|
||||||
return Type{
|
return Type{
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
// jarvis: v5 only for now — v6 (/api/...) lands when someone runs it.
|
// jarvis: v5 only for now — v6 (/api/...) lands when someone runs it.
|
||||||
type pihole struct{}
|
type pihole struct{}
|
||||||
|
|
||||||
func init() { register(pihole{}) }
|
func init() { Register(pihole{}) }
|
||||||
|
|
||||||
func (pihole) Meta() Type {
|
func (pihole) Meta() Type {
|
||||||
return Type{
|
return Type{
|
||||||
|
|||||||
@@ -40,7 +40,9 @@ type Fetcher interface {
|
|||||||
|
|
||||||
var registry = map[string]Fetcher{}
|
var registry = map[string]Fetcher{}
|
||||||
|
|
||||||
func register(f Fetcher) { registry[f.Meta().Type] = f }
|
// Register adds a fetcher — used by the api package for DB-backed widgets
|
||||||
|
// (monitor/domain/system) that can't live in this leaf package.
|
||||||
|
func Register(f Fetcher) { registry[f.Meta().Type] = f }
|
||||||
|
|
||||||
// Get resolves a config.type to its fetcher.
|
// Get resolves a config.type to its fetcher.
|
||||||
func Get(t string) (Fetcher, bool) {
|
func Get(t string) (Fetcher, bool) {
|
||||||
|
|||||||
@@ -28,8 +28,10 @@
|
|||||||
"lucide-react": "^0.545.0",
|
"lucide-react": "^0.545.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
|
"recharts": "^3.10.1",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"tw-animate-css": "^1.4.0"
|
"tw-animate-css": "^1.4.0",
|
||||||
|
"wouter": "^3.11.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4.1.13",
|
"@tailwindcss/vite": "^4.1.13",
|
||||||
|
|||||||
@@ -1,12 +1,24 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { LayoutGrid, Moon, Plus, Search, Settings, Sun } from "lucide-react";
|
import { Link, Route, useLocation } from "wouter";
|
||||||
|
import { Activity, AlertTriangle, Globe, LayoutGrid, Megaphone, Moon, Plus, Search, Server, Settings, Sun } from "lucide-react";
|
||||||
import { BoardProvider, useBoard } from "@/board/store";
|
import { BoardProvider, useBoard } from "@/board/store";
|
||||||
import { Board } from "@/board/Board";
|
import { Board } from "@/board/Board";
|
||||||
import { ServiceDialog } from "@/components/ServiceDialog";
|
import { ServiceDialog } from "@/components/ServiceDialog";
|
||||||
import { WidgetDialog } from "@/components/WidgetDialog";
|
import { WidgetDialog } from "@/components/WidgetDialog";
|
||||||
import { SettingsDialog } from "@/components/SettingsDialog";
|
import { SettingsDialog } from "@/components/SettingsDialog";
|
||||||
import { CommandPalette } from "@/components/CommandPalette";
|
import { CommandPalette } from "@/components/CommandPalette";
|
||||||
|
import { MonitorsPage } from "@/pages/MonitorsPage";
|
||||||
|
import { MonitorDetailPage } from "@/pages/MonitorDetailPage";
|
||||||
|
import { DomainsPage } from "@/pages/DomainsPage";
|
||||||
|
import { DomainDetailPage } from "@/pages/DomainDetailPage";
|
||||||
|
import { SystemsPage } from "@/pages/SystemsPage";
|
||||||
|
import { SystemDetailPage } from "@/pages/SystemDetailPage";
|
||||||
|
import { IncidentsPage } from "@/pages/IncidentsPage";
|
||||||
|
import { StatusPagesPage } from "@/pages/StatusPagesPage";
|
||||||
|
import { StatusPublicPage } from "@/pages/StatusPublicPage";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { t, type Key } from "@/i18n";
|
||||||
import type { Item } from "@/types";
|
import type { Item } from "@/types";
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
@@ -17,8 +29,18 @@ export default function App() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const NAV: { href: string; labelKey: Key; icon: typeof LayoutGrid }[] = [
|
||||||
|
{ href: "/", labelKey: "nav.board", icon: LayoutGrid },
|
||||||
|
{ href: "/monitors", labelKey: "nav.monitors", icon: Activity },
|
||||||
|
{ href: "/domains", labelKey: "nav.domains", icon: Globe },
|
||||||
|
{ href: "/systems", labelKey: "nav.systems", icon: Server },
|
||||||
|
{ href: "/incidents", labelKey: "nav.incidents", icon: AlertTriangle },
|
||||||
|
{ href: "/status", labelKey: "nav.status", icon: Megaphone },
|
||||||
|
];
|
||||||
|
|
||||||
function Shell() {
|
function Shell() {
|
||||||
const board = useBoard();
|
const board = useBoard();
|
||||||
|
const [loc] = useLocation();
|
||||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
const [paletteOpen, setPaletteOpen] = useState(false);
|
const [paletteOpen, setPaletteOpen] = useState(false);
|
||||||
const [svcOpen, setSvcOpen] = useState(false);
|
const [svcOpen, setSvcOpen] = useState(false);
|
||||||
@@ -60,9 +82,27 @@ function Shell() {
|
|||||||
</svg>
|
</svg>
|
||||||
Dash
|
Dash
|
||||||
</div>
|
</div>
|
||||||
|
<nav className="ml-6 flex items-center gap-1" aria-label="Primary">
|
||||||
|
{NAV.map((n) => {
|
||||||
|
const active = n.href === "/" ? loc === "/" : loc.startsWith(n.href);
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={n.href}
|
||||||
|
href={n.href}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1.5 rounded-[7px] px-2.5 py-1.5 text-[12.5px] transition-colors",
|
||||||
|
active ? "bg-surface-hover text-text" : "text-text-faint hover:text-text",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<n.icon size={13} strokeWidth={1.8} />
|
||||||
|
{t(n.labelKey)}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
<div className="ml-auto flex items-center gap-2">
|
<div className="ml-auto flex items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="outline" aria-label="Search (⌘K)"
|
variant="outline" aria-label={t("header.search")}
|
||||||
onClick={() => setPaletteOpen(true)}
|
onClick={() => setPaletteOpen(true)}
|
||||||
className="gap-1.5 text-text-faint"
|
className="gap-1.5 text-text-faint"
|
||||||
>
|
>
|
||||||
@@ -70,32 +110,61 @@ function Shell() {
|
|||||||
<kbd className="hidden font-mono text-[10px] min-[620px]:inline">⌘K</kbd>
|
<kbd className="hidden font-mono text-[10px] min-[620px]:inline">⌘K</kbd>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline" size="icon" aria-label="Settings"
|
variant="outline" size="icon" aria-label={t("header.settings")}
|
||||||
onClick={() => setSettingsOpen(true)}
|
onClick={() => setSettingsOpen(true)}
|
||||||
>
|
>
|
||||||
<Settings size={15} strokeWidth={1.8} />
|
<Settings size={15} strokeWidth={1.8} />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline" size="icon" aria-label="Toggle theme"
|
variant="outline" size="icon" aria-label={t("header.theme")}
|
||||||
onClick={() => void board.setSetting("theme", board.theme === "dark" ? "light" : "dark")}
|
onClick={() => void board.setSetting("theme", board.theme === "dark" ? "light" : "dark")}
|
||||||
>
|
>
|
||||||
{board.theme === "dark" ? <Moon size={15} strokeWidth={1.8} /> : <Sun size={15} strokeWidth={1.8} />}
|
{board.theme === "dark" ? <Moon size={15} strokeWidth={1.8} /> : <Sun size={15} strokeWidth={1.8} />}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline" aria-label="Add widget"
|
variant="outline" aria-label={t("header.addWidget")}
|
||||||
onClick={() => { setEditing(undefined); setWdgOpen(true); }}
|
onClick={() => { setEditing(undefined); setWdgOpen(true); }}
|
||||||
>
|
>
|
||||||
<LayoutGrid size={13} strokeWidth={2} />
|
<LayoutGrid size={13} strokeWidth={2} />
|
||||||
Widget
|
{t("header.widget")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={() => { setEditing(undefined); setSvcOpen(true); }}>
|
<Button onClick={() => { setEditing(undefined); setSvcOpen(true); }}>
|
||||||
<Plus size={13} strokeWidth={2.2} />
|
<Plus size={13} strokeWidth={2.2} />
|
||||||
Add service
|
{t("header.addService")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<Board onEditItem={openEditor} />
|
<Route path="/">
|
||||||
|
<Board onEditItem={openEditor} />
|
||||||
|
</Route>
|
||||||
|
<Route path="/monitors">
|
||||||
|
<MonitorsPage />
|
||||||
|
</Route>
|
||||||
|
<Route path="/monitors/:id">
|
||||||
|
{(p) => <MonitorDetailPage id={p.id} />}
|
||||||
|
</Route>
|
||||||
|
<Route path="/domains">
|
||||||
|
<DomainsPage />
|
||||||
|
</Route>
|
||||||
|
<Route path="/domains/:id">
|
||||||
|
{(p) => <DomainDetailPage id={p.id} />}
|
||||||
|
</Route>
|
||||||
|
<Route path="/systems">
|
||||||
|
<SystemsPage />
|
||||||
|
</Route>
|
||||||
|
<Route path="/systems/:id">
|
||||||
|
{(p) => <SystemDetailPage id={p.id} />}
|
||||||
|
</Route>
|
||||||
|
<Route path="/incidents">
|
||||||
|
<IncidentsPage />
|
||||||
|
</Route>
|
||||||
|
<Route path="/status">
|
||||||
|
<StatusPagesPage />
|
||||||
|
</Route>
|
||||||
|
<Route path="/status/:slug">
|
||||||
|
{(p) => <StatusPublicPage slug={p.slug} />}
|
||||||
|
</Route>
|
||||||
|
|
||||||
<ServiceDialog open={svcOpen} onOpenChange={setSvcOpen} item={editing?.kind === "service" ? editing : undefined} />
|
<ServiceDialog open={svcOpen} onOpenChange={setSvcOpen} item={editing?.kind === "service" ? editing : undefined} />
|
||||||
<WidgetDialog open={wdgOpen} onOpenChange={setWdgOpen} item={editing?.kind === "widget" ? editing : undefined} />
|
<WidgetDialog open={wdgOpen} onOpenChange={setWdgOpen} item={editing?.kind === "widget" ? editing : undefined} />
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { ArrowRight, Download, LayoutGrid, Moon, Plus, Settings, Sun } from "lucide-react";
|
import { useLocation } from "wouter";
|
||||||
|
import { Activity, AlertTriangle, ArrowRight, Download, Globe, LayoutGrid, Megaphone, Moon, Plus, Server, Settings, Sun } from "lucide-react";
|
||||||
import { useBoard } from "@/board/store";
|
import { useBoard } from "@/board/store";
|
||||||
import { hostOf, IconImg } from "@/board/primitives";
|
import { hostOf, IconImg } from "@/board/primitives";
|
||||||
import { rendererLabels, rendererList } from "@/renderers";
|
import { rendererLabels, rendererList } from "@/renderers";
|
||||||
@@ -34,12 +35,19 @@ export function CommandPalette({
|
|||||||
onSettings(): void;
|
onSettings(): void;
|
||||||
}) {
|
}) {
|
||||||
const board = useBoard();
|
const board = useBoard();
|
||||||
|
const [, navigate] = useLocation();
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [active, setActive] = useState(0);
|
const [active, setActive] = useState(0);
|
||||||
const listRef = useRef<HTMLDivElement>(null);
|
const listRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const commands = useMemo<Command[]>(() => {
|
const commands = useMemo<Command[]>(() => {
|
||||||
const cmds: Command[] = [
|
const cmds: Command[] = [
|
||||||
|
{ id: "nav-board", group: "Actions", glyph: LayoutGrid, label: "Go to Board", run: () => navigate("/") },
|
||||||
|
{ id: "nav-monitors", group: "Actions", glyph: Activity, label: "Go to Monitors", run: () => navigate("/monitors") },
|
||||||
|
{ id: "nav-domains", group: "Actions", glyph: Globe, label: "Go to Domains", run: () => navigate("/domains") },
|
||||||
|
{ id: "nav-systems", group: "Actions", glyph: Server, label: "Go to Systems", run: () => navigate("/systems") },
|
||||||
|
{ id: "nav-incidents", group: "Actions", glyph: AlertTriangle, label: "Go to Incidents", run: () => navigate("/incidents") },
|
||||||
|
{ id: "nav-status", group: "Actions", glyph: Megaphone, label: "Go to Status pages", run: () => navigate("/status") },
|
||||||
{ id: "add-service", group: "Actions", glyph: Plus, label: "Add service", hint: "new", run: onAddService },
|
{ id: "add-service", group: "Actions", glyph: Plus, label: "Add service", hint: "new", run: onAddService },
|
||||||
{ id: "add-widget", group: "Actions", glyph: Plus, label: "Add widget", hint: "new", run: onAddWidget },
|
{ id: "add-widget", group: "Actions", glyph: Plus, label: "Add widget", hint: "new", run: onAddWidget },
|
||||||
{ id: "settings", group: "Actions", glyph: Settings, label: "Open settings", run: onSettings },
|
{ id: "settings", group: "Actions", glyph: Settings, label: "Open settings", run: onSettings },
|
||||||
@@ -73,7 +81,7 @@ export function CommandPalette({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return cmds;
|
return cmds;
|
||||||
}, [board.sections, board.theme, board.renderer, board.setSetting, onAddService, onAddWidget, onSettings]);
|
}, [board.sections, board.theme, board.renderer, board.setSetting, onAddService, onAddWidget, onSettings, navigate]);
|
||||||
|
|
||||||
const q = query.trim().toLowerCase();
|
const q = query.trim().toLowerCase();
|
||||||
const filtered = q
|
const filtered = q
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { api } from "@/api";
|
||||||
|
import type { Domain, DomainInput } from "@/types";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
||||||
|
// Add/edit a tracked domain. The first WHOIS/DNS/TLS lookup runs on create —
|
||||||
|
// expect a few seconds of spinner.
|
||||||
|
export function DomainDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
domain,
|
||||||
|
onSaved,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (o: boolean) => void;
|
||||||
|
domain?: Domain | undefined;
|
||||||
|
onSaved: () => void;
|
||||||
|
}) {
|
||||||
|
const editing = Boolean(domain);
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [intervalH, setIntervalH] = useState("24");
|
||||||
|
const [alertDays, setAlertDays] = useState("30");
|
||||||
|
const [autoRenew, setAutoRenew] = useState(false);
|
||||||
|
const [notes, setNotes] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [err, setErr] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
setErr("");
|
||||||
|
setName(domain?.name ?? "");
|
||||||
|
setIntervalH(String(domain?.intervalH ?? 24));
|
||||||
|
setAlertDays(String(domain?.alertDaysBefore ?? 30));
|
||||||
|
setAutoRenew(domain?.autoRenew ?? false);
|
||||||
|
setNotes(domain?.notes ?? "");
|
||||||
|
}, [open, domain]);
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (!name.trim()) return setErr("Domain is required"), undefined;
|
||||||
|
setBusy(true);
|
||||||
|
const body: DomainInput = {
|
||||||
|
name: name.trim(),
|
||||||
|
intervalH: Math.max(1, parseInt(intervalH) || 24),
|
||||||
|
alertDaysBefore: Math.max(0, parseInt(alertDays) || 30),
|
||||||
|
autoRenew,
|
||||||
|
notes,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
if (editing && domain) {
|
||||||
|
await api.PATCH("/api/domains/{id}", { params: { path: { id: domain.id! } }, body });
|
||||||
|
} else {
|
||||||
|
const { error } = await api.POST("/api/domains", { body });
|
||||||
|
if (error) throw new Error("create failed");
|
||||||
|
}
|
||||||
|
onSaved();
|
||||||
|
onOpenChange(false);
|
||||||
|
} catch (e) {
|
||||||
|
setErr(e instanceof Error ? e.message : "save failed");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{editing ? "Edit domain" : "Track a domain"}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
WHOIS, DNS, TLS certificate, and host info — refreshed on your interval.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="dom-name">Domain</Label>
|
||||||
|
<Input id="dom-name" value={name} onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="example.com" className="font-mono text-[12px]" autoFocus />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="dom-int">Refresh every (hours)</Label>
|
||||||
|
<Input id="dom-int" value={intervalH} onChange={(e) => setIntervalH(e.target.value)} className="font-mono text-[12px]" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="dom-alert">Alert days before expiry</Label>
|
||||||
|
<Input id="dom-alert" value={alertDays} onChange={(e) => setAlertDays(e.target.value)} className="font-mono text-[12px]" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2 text-[12.5px] text-text-dim">
|
||||||
|
<input type="checkbox" checked={autoRenew} onChange={(e) => setAutoRenew(e.target.checked)} />
|
||||||
|
Auto-renew enabled at the registrar
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="dom-notes">Notes (optional)</Label>
|
||||||
|
<Input id="dom-notes" value={notes} onChange={(e) => setNotes(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{err ? <p className="text-[12px] text-destructive">{err}</p> : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="items-center">
|
||||||
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={busy}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="button" onClick={() => void submit()} disabled={busy}>
|
||||||
|
{busy ? "Looking up…" : editing ? "Save" : "Track domain"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { api } from "@/api";
|
||||||
|
import type { Monitor, MonitorInput } from "@/types";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
|
||||||
|
const TYPES = ["http", "tcp", "ping", "dns", "keyword", "json", "push"] as const;
|
||||||
|
type MonType = (typeof TYPES)[number];
|
||||||
|
|
||||||
|
// One dialog for add + edit. The type picker drives which fields matter —
|
||||||
|
// same pattern as ServiceDialog.
|
||||||
|
export function MonitorDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
monitor,
|
||||||
|
onSaved,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (o: boolean) => void;
|
||||||
|
monitor?: Monitor | undefined;
|
||||||
|
onSaved: () => void;
|
||||||
|
}) {
|
||||||
|
const editing = Boolean(monitor);
|
||||||
|
const [f, setF] = useState<Record<string, string>>({});
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [err, setErr] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
setErr("");
|
||||||
|
setF({
|
||||||
|
name: monitor?.name ?? "",
|
||||||
|
type: monitor?.type ?? "http",
|
||||||
|
url: monitor?.url ?? "",
|
||||||
|
hostname: monitor?.hostname ?? "",
|
||||||
|
port: monitor?.port ? String(monitor.port) : "",
|
||||||
|
method: monitor?.method ?? "GET",
|
||||||
|
keyword: monitor?.keyword ?? "",
|
||||||
|
keywordInvert: monitor?.keywordInvert ? "1" : "",
|
||||||
|
jsonQuery: monitor?.jsonQuery ?? "",
|
||||||
|
expected: monitor?.expected ?? "",
|
||||||
|
dnsType: monitor?.dnsType ?? "A",
|
||||||
|
intervalS: String(monitor?.intervalS ?? 60),
|
||||||
|
timeoutS: String(monitor?.timeoutS ?? 10),
|
||||||
|
retries: String(monitor?.retries ?? 0),
|
||||||
|
notes: monitor?.notes ?? "",
|
||||||
|
});
|
||||||
|
}, [open, monitor]);
|
||||||
|
|
||||||
|
const set = (k: string) => (e: React.ChangeEvent<HTMLInputElement>) =>
|
||||||
|
setF((s) => ({ ...s, [k]: e.target.value }));
|
||||||
|
// SAFETY: f.type only ever receives values from the TYPES select or a
|
||||||
|
// persisted monitor row, both constrained to MonType.
|
||||||
|
const type = (f.type || "http") as MonType;
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (!f.name?.trim()) return setErr("Name is required"), undefined;
|
||||||
|
if (type !== "push" && type !== "tcp" && type !== "ping" && type !== "dns" && !f.url?.trim())
|
||||||
|
return setErr("URL is required"), undefined;
|
||||||
|
if ((type === "tcp" || type === "ping" || type === "dns") && !f.hostname?.trim() && !f.url?.trim())
|
||||||
|
return setErr("Hostname is required"), undefined;
|
||||||
|
setBusy(true);
|
||||||
|
const body: MonitorInput = {
|
||||||
|
name: f.name.trim(),
|
||||||
|
type,
|
||||||
|
url: f.url?.trim() ?? "",
|
||||||
|
hostname: f.hostname?.trim() ?? "",
|
||||||
|
port: parseInt(f.port ?? "0") || 0,
|
||||||
|
method: f.method || "GET",
|
||||||
|
keyword: f.keyword ?? "",
|
||||||
|
keywordInvert: f.keywordInvert === "1",
|
||||||
|
jsonQuery: f.jsonQuery ?? "",
|
||||||
|
expected: f.expected ?? "",
|
||||||
|
dnsType: f.dnsType || "A",
|
||||||
|
intervalS: Math.max(10, parseInt(f.intervalS ?? "60") || 60),
|
||||||
|
timeoutS: Math.max(1, parseInt(f.timeoutS ?? "10") || 10),
|
||||||
|
retries: Math.max(0, parseInt(f.retries ?? "0") || 0),
|
||||||
|
notes: f.notes ?? "",
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
if (editing && monitor) {
|
||||||
|
await api.PATCH("/api/monitors/{id}", { params: { path: { id: monitor.id! } }, body });
|
||||||
|
} else {
|
||||||
|
await api.POST("/api/monitors", { body });
|
||||||
|
}
|
||||||
|
onSaved();
|
||||||
|
onOpenChange(false);
|
||||||
|
} catch {
|
||||||
|
setErr("save failed");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{editing ? "Edit monitor" : "New monitor"}</DialogTitle>
|
||||||
|
<DialogDescription>Uptime check on a schedule.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex max-h-[60vh] flex-col gap-4 overflow-y-auto pr-1">
|
||||||
|
<div className="grid grid-cols-[1fr_130px] gap-3">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="mon-name">Name</Label>
|
||||||
|
<Input id="mon-name" value={f.name ?? ""} onChange={set("name")} placeholder="Plex" autoFocus />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>Type</Label>
|
||||||
|
<Select value={type} onValueChange={(v) => setF((s) => ({ ...s, type: v }))}>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{TYPES.map((t) => <SelectItem key={t} value={t}>{t}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(type === "http" || type === "keyword" || type === "json") && (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-[1fr_110px] gap-3">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="mon-url">URL</Label>
|
||||||
|
<Input id="mon-url" value={f.url ?? ""} onChange={set("url")} placeholder="https://plex.local" className="font-mono text-[12px]" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>Method</Label>
|
||||||
|
<Select value={f.method || "GET"} onValueChange={(v) => setF((s) => ({ ...s, method: v }))}>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{["GET", "HEAD", "POST"].map((m) => <SelectItem key={m} value={m}>{m}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{type === "keyword" && (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="mon-kw">Keyword</Label>
|
||||||
|
<Input id="mon-kw" value={f.keyword ?? ""} onChange={set("keyword")} placeholder="text that must appear" className="font-mono text-[12px]" />
|
||||||
|
<label className="flex items-center gap-2 text-[12px] text-text-dim">
|
||||||
|
<input type="checkbox" checked={f.keywordInvert === "1"}
|
||||||
|
onChange={(e) => setF((s) => ({ ...s, keywordInvert: e.target.checked ? "1" : "" }))} />
|
||||||
|
Invert (down when found)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{type === "json" && (
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="mon-jq">JSON path</Label>
|
||||||
|
<Input id="mon-jq" value={f.jsonQuery ?? ""} onChange={set("jsonQuery")} placeholder="data.status" className="font-mono text-[12px]" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="mon-exp">Expected</Label>
|
||||||
|
<Input id="mon-exp" value={f.expected ?? ""} onChange={set("expected")} placeholder="ok" className="font-mono text-[12px]" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(type === "tcp" || type === "ping" || type === "dns") && (
|
||||||
|
<div className="grid grid-cols-[1fr_110px] gap-3">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="mon-host">Hostname</Label>
|
||||||
|
<Input id="mon-host" value={f.hostname ?? ""} onChange={set("hostname")} placeholder="nas.local" className="font-mono text-[12px]" />
|
||||||
|
</div>
|
||||||
|
{type === "tcp" ? (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="mon-port">Port</Label>
|
||||||
|
<Input id="mon-port" value={f.port ?? ""} onChange={set("port")} placeholder="443" className="font-mono text-[12px]" />
|
||||||
|
</div>
|
||||||
|
) : type === "dns" ? (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>Record</Label>
|
||||||
|
<Select value={f.dnsType || "A"} onValueChange={(v) => setF((s) => ({ ...s, dnsType: v }))}>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{["A", "AAAA", "CNAME", "MX", "NS", "TXT"].map((t) => <SelectItem key={t} value={t}>{t}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
) : <div />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{type === "dns" && (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="mon-dnsexp">Expected record contains (optional)</Label>
|
||||||
|
<Input id="mon-dnsexp" value={f.expected ?? ""} onChange={set("expected")} className="font-mono text-[12px]" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{type === "push" && (
|
||||||
|
<p className="rounded-[8px] border border-border bg-surface px-3 py-2 text-[12px] text-text-dim">
|
||||||
|
After saving, your jobs call the shown push URL on a schedule.
|
||||||
|
If it goes quiet for 2× the interval, the monitor flips down.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="mon-int">Interval (s)</Label>
|
||||||
|
<Input id="mon-int" value={f.intervalS ?? "60"} onChange={set("intervalS")} className="font-mono text-[12px]" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="mon-to">Timeout (s)</Label>
|
||||||
|
<Input id="mon-to" value={f.timeoutS ?? "10"} onChange={set("timeoutS")} className="font-mono text-[12px]" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="mon-re">Retries</Label>
|
||||||
|
<Input id="mon-re" value={f.retries ?? "0"} onChange={set("retries")} className="font-mono text-[12px]" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{monitor?.pushToken ? (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>Push URL</Label>
|
||||||
|
<code className="rounded-[8px] border border-border bg-surface px-3 py-2 font-mono text-[11px] text-text-dim">
|
||||||
|
{location.origin}/api/push/{monitor.pushToken}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{err ? <p className="text-[12px] text-destructive">{err}</p> : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="items-center">
|
||||||
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={busy}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="button" onClick={() => void submit()} disabled={busy}>
|
||||||
|
{busy ? "Saving…" : editing ? "Save" : "Add monitor"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { Plus, Trash2, Upload, X } from "lucide-react";
|
import { Plus, Trash2, Upload, X } from "lucide-react";
|
||||||
import { useBoard } from "@/board/store";
|
import { useBoard } from "@/board/store";
|
||||||
|
import { api } from "@/api";
|
||||||
import type { Item, UrlInput } from "@/types";
|
import type { Item, UrlInput } from "@/types";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -33,6 +34,7 @@ export function ServiceDialog({
|
|||||||
const [icon, setIcon] = useState("");
|
const [icon, setIcon] = useState("");
|
||||||
const [urls, setUrls] = useState<UrlInput[]>([{ url: "", label: "local" }]);
|
const [urls, setUrls] = useState<UrlInput[]>([{ url: "", label: "local" }]);
|
||||||
const [sectionId, setSectionId] = useState("");
|
const [sectionId, setSectionId] = useState("");
|
||||||
|
const [alsoMonitor, setAlsoMonitor] = useState(false);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [err, setErr] = useState("");
|
const [err, setErr] = useState("");
|
||||||
const fileRef = useRef<HTMLInputElement>(null);
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -48,6 +50,7 @@ export function ServiceDialog({
|
|||||||
: [{ url: "", label: "local" }],
|
: [{ url: "", label: "local" }],
|
||||||
);
|
);
|
||||||
setSectionId(item?.sectionId ?? board.sections[0]?.id ?? "");
|
setSectionId(item?.sectionId ?? board.sections[0]?.id ?? "");
|
||||||
|
setAlsoMonitor(false);
|
||||||
}, [open, item, board.sections]);
|
}, [open, item, board.sections]);
|
||||||
|
|
||||||
const setUrl = (i: number, patch: Partial<UrlInput>) =>
|
const setUrl = (i: number, patch: Partial<UrlInput>) =>
|
||||||
@@ -81,6 +84,12 @@ export function ServiceDialog({
|
|||||||
urls: cleanUrls,
|
urls: cleanUrls,
|
||||||
});
|
});
|
||||||
if (created && file) await board.uploadIcon(created.id, file);
|
if (created && file) await board.uploadIcon(created.id, file);
|
||||||
|
if (alsoMonitor && cleanUrls[0]) {
|
||||||
|
// Uptime check on the primary URL — appears under /monitors.
|
||||||
|
await api.POST("/api/monitors", {
|
||||||
|
body: { name: name.trim(), type: "http", url: cleanUrls[0].url },
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -193,6 +202,17 @@ export function ServiceDialog({
|
|||||||
<p className="text-[11px] text-text-faint">URL or uploaded file; blank shows a letter tile.</p>
|
<p className="text-[11px] text-text-faint">URL or uploaded file; blank shows a letter tile.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{!editing && (
|
||||||
|
<label className="flex items-center gap-2 text-[12.5px] text-text-dim">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={alsoMonitor}
|
||||||
|
onChange={(e) => setAlsoMonitor(e.target.checked)}
|
||||||
|
/>
|
||||||
|
Also create an uptime monitor for the first URL
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
{err ? <p className="text-[12px] text-destructive">{err}</p> : null}
|
{err ? <p className="text-[12px] text-destructive">{err}</p> : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { Download, Upload } from "lucide-react";
|
import { Bell, Download, Upload } from "lucide-react";
|
||||||
import { useBoard } from "@/board/store";
|
import { useBoard } from "@/board/store";
|
||||||
|
import { api } from "@/api";
|
||||||
import { rendererDescriptions, rendererLabels, rendererList } from "@/renderers";
|
import { rendererDescriptions, rendererLabels, rendererList } from "@/renderers";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle,
|
Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
|
||||||
@@ -14,6 +16,23 @@ export function SettingsDialog({ open, onOpenChange }: { open: boolean; onOpenCh
|
|||||||
const board = useBoard();
|
const board = useBoard();
|
||||||
const fileRef = useRef<HTMLInputElement>(null);
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
const [msg, setMsg] = useState("");
|
const [msg, setMsg] = useState("");
|
||||||
|
const [webhook, setWebhook] = useState("");
|
||||||
|
const [webhookMsg, setWebhookMsg] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
void api.GET("/api/settings").then(({ data }) => {
|
||||||
|
const v = data?.notify_webhook;
|
||||||
|
setWebhook(typeof v === "string" ? v : "");
|
||||||
|
setWebhookMsg("");
|
||||||
|
});
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
async function saveWebhook() {
|
||||||
|
setWebhookMsg("");
|
||||||
|
await api.PUT("/api/settings", { body: { notify_webhook: webhook.trim() } });
|
||||||
|
setWebhookMsg("saved");
|
||||||
|
}
|
||||||
|
|
||||||
async function exportJson() {
|
async function exportJson() {
|
||||||
const res = await fetch("/api/export");
|
const res = await fetch("/api/export");
|
||||||
@@ -28,11 +47,22 @@ export function SettingsDialog({ open, onOpenChange }: { open: boolean; onOpenCh
|
|||||||
|
|
||||||
async function importJson(file: File) {
|
async function importJson(file: File) {
|
||||||
setMsg("");
|
setMsg("");
|
||||||
const res = await fetch("/api/import", { method: "POST", body: file });
|
// Dash exports are JSON with {version, sections}; anything else
|
||||||
|
// (Homepage/Homarr/Dashy) goes to the external importer for sniffing.
|
||||||
|
let target = "/api/import/external";
|
||||||
|
try {
|
||||||
|
const j: unknown = JSON.parse(await file.text());
|
||||||
|
if (j !== null && typeof j === "object" && "version" in j && "sections" in j) {
|
||||||
|
target = "/api/import";
|
||||||
|
}
|
||||||
|
} catch { /* not JSON — external importer handles YAML */ }
|
||||||
|
const res = await fetch(target, { method: "POST", body: file });
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
location.reload(); // import replaces state; a reload re-syncs everything
|
location.reload(); // import replaces state; a reload re-syncs everything
|
||||||
} else {
|
} else {
|
||||||
setMsg("Import failed — not a Dash export?");
|
// SAFETY: error responses always carry {error: string} per openapi.yaml Error schema.
|
||||||
|
const body = (await res.json().catch(() => null)) as { error?: string } | null;
|
||||||
|
setMsg(body?.error ?? "Import failed");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,6 +112,38 @@ export function SettingsDialog({ open, onOpenChange }: { open: boolean; onOpenCh
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="notify-webhook">Notification webhook</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
id="notify-webhook"
|
||||||
|
value={webhook}
|
||||||
|
onChange={(e) => { setWebhook(e.target.value); setWebhookMsg(""); }}
|
||||||
|
placeholder="https://ntfy.sh/dash or Slack/Discord hook"
|
||||||
|
className="font-mono text-[12px]"
|
||||||
|
/>
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={() => void saveWebhook()}>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button" variant="outline" size="icon" aria-label="Send test notification"
|
||||||
|
disabled={!webhook.trim()}
|
||||||
|
onClick={() => {
|
||||||
|
setWebhookMsg("");
|
||||||
|
void api.POST("/api/notify/test").then(({ error }) =>
|
||||||
|
setWebhookMsg(error ? "failed" : "sent"));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Bell size={12} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-text-faint">
|
||||||
|
Monitor up/down flips and domain/certificate expiry post JSON here.
|
||||||
|
Works with Slack, Discord, ntfy, or any JSON receiver.
|
||||||
|
</p>
|
||||||
|
{webhookMsg ? <p className="text-[11px] text-text-faint">{webhookMsg}</p> : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2 pt-1">
|
<div className="flex gap-2 pt-1">
|
||||||
<Button type="button" variant="outline" size="sm" onClick={() => void exportJson()}>
|
<Button type="button" variant="outline" size="sm" onClick={() => void exportJson()}>
|
||||||
<Download size={12} /> Export JSON
|
<Download size={12} /> Export JSON
|
||||||
@@ -90,7 +152,7 @@ export function SettingsDialog({ open, onOpenChange }: { open: boolean; onOpenCh
|
|||||||
<Upload size={12} /> Import
|
<Upload size={12} /> Import
|
||||||
</Button>
|
</Button>
|
||||||
<input
|
<input
|
||||||
ref={fileRef} type="file" accept="application/json" className="hidden"
|
ref={fileRef} type="file" accept=".json,.yml,.yaml" className="hidden"
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const f = e.target.files?.[0];
|
const f = e.target.files?.[0];
|
||||||
if (f) void importJson(f);
|
if (f) void importJson(f);
|
||||||
@@ -98,6 +160,7 @@ export function SettingsDialog({ open, onOpenChange }: { open: boolean; onOpenCh
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-[11px] text-text-faint">Import accepts Dash exports plus Homepage, Homarr, and Dashy configs.</p>
|
||||||
{msg ? <p className="text-[12px] text-destructive">{msg}</p> : null}
|
{msg ? <p className="text-[12px] text-destructive">{msg}</p> : null}
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useBoard } from "@/board/store";
|
import { useBoard } from "@/board/store";
|
||||||
|
import { api } from "@/api";
|
||||||
import type { Item } from "@/types";
|
import type { Item } from "@/types";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -31,6 +32,9 @@ export function WidgetDialog({
|
|||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [sectionId, setSectionId] = useState("");
|
const [sectionId, setSectionId] = useState("");
|
||||||
const [values, setValues] = useState<Record<string, string>>({});
|
const [values, setValues] = useState<Record<string, string>>({});
|
||||||
|
const [monitors, setMonitors] = useState<{ id?: string; name?: string }[]>([]);
|
||||||
|
const [domains, setDomains] = useState<{ id?: string; name?: string }[]>([]);
|
||||||
|
const [systems, setSystems] = useState<{ id?: string; name?: string }[]>([]);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [err, setErr] = useState("");
|
const [err, setErr] = useState("");
|
||||||
|
|
||||||
@@ -42,6 +46,9 @@ export function WidgetDialog({
|
|||||||
.then((r) => r.json() as Promise<WidgetType[]>)
|
.then((r) => r.json() as Promise<WidgetType[]>)
|
||||||
.then(setTypes)
|
.then(setTypes)
|
||||||
.catch(() => setTypes([]));
|
.catch(() => setTypes([]));
|
||||||
|
void api.GET("/api/monitors").then(({ data }) => setMonitors(data ?? []));
|
||||||
|
void api.GET("/api/domains").then(({ data }) => setDomains(data ?? []));
|
||||||
|
void api.GET("/api/systems").then(({ data }) => setSystems(data ?? []));
|
||||||
|
|
||||||
const cfg = item?.config ?? null;
|
const cfg = item?.config ?? null;
|
||||||
setType(cfg && typeof cfg.type === "string" ? cfg.type : "");
|
setType(cfg && typeof cfg.type === "string" ? cfg.type : "");
|
||||||
@@ -141,14 +148,28 @@ export function WidgetDialog({
|
|||||||
<Label htmlFor={`wdg-${f.key}`}>
|
<Label htmlFor={`wdg-${f.key}`}>
|
||||||
{f.label}{f.required ? "" : " (optional)"}
|
{f.label}{f.required ? "" : " (optional)"}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
{f.key === "monitorId" || f.key === "domainId" || f.key === "systemId" ? (
|
||||||
id={`wdg-${f.key}`}
|
<Select
|
||||||
type={f.secret ? "password" : "text"}
|
value={values[f.key] ?? ""}
|
||||||
value={values[f.key] ?? ""}
|
onValueChange={(v) => setValues((s) => ({ ...s, [f.key]: v }))}
|
||||||
placeholder={f.placeholder}
|
>
|
||||||
onChange={(e) => setValues((v) => ({ ...v, [f.key]: e.target.value }))}
|
<SelectTrigger><SelectValue placeholder="Pick one…" /></SelectTrigger>
|
||||||
className="font-mono text-[12px]"
|
<SelectContent>
|
||||||
/>
|
{(f.key === "monitorId" ? monitors : f.key === "domainId" ? domains : systems).map((m) => (
|
||||||
|
<SelectItem key={m.id} value={m.id ?? ""}>{m.name}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
id={`wdg-${f.key}`}
|
||||||
|
type={f.secret ? "password" : "text"}
|
||||||
|
value={values[f.key] ?? ""}
|
||||||
|
placeholder={f.placeholder}
|
||||||
|
onChange={(e) => setValues((v) => ({ ...v, [f.key]: e.target.value }))}
|
||||||
|
className="font-mono text-[12px]"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
// English source strings. Flat keys, dot-separated namespaces.
|
||||||
|
// Add a locale by copying this file — keys must match exactly.
|
||||||
|
export const en = {
|
||||||
|
"nav.board": "Board",
|
||||||
|
"nav.monitors": "Monitors",
|
||||||
|
"nav.domains": "Domains",
|
||||||
|
"nav.systems": "Systems",
|
||||||
|
"nav.incidents": "Incidents",
|
||||||
|
"nav.status": "Status",
|
||||||
|
|
||||||
|
"header.search": "Search (⌘K)",
|
||||||
|
"header.settings": "Settings",
|
||||||
|
"header.theme": "Toggle theme",
|
||||||
|
"header.addWidget": "Add widget",
|
||||||
|
"header.addService": "Add service",
|
||||||
|
"header.widget": "Widget",
|
||||||
|
|
||||||
|
"common.add": "Add",
|
||||||
|
"common.edit": "Edit",
|
||||||
|
"common.delete": "Delete",
|
||||||
|
"common.cancel": "Cancel",
|
||||||
|
"common.save": "Save",
|
||||||
|
"common.test": "Test",
|
||||||
|
"common.loading": "Loading…",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type Key = keyof typeof en;
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { en, type Key } from "@/i18n/en";
|
||||||
|
|
||||||
|
export type { Key } from "@/i18n/en";
|
||||||
|
|
||||||
|
// i18n seam: dictionaries are plain objects keyed by dotted names. `t` is a
|
||||||
|
// typed lookup that falls back to the key itself, so a missing translation
|
||||||
|
// renders the key — loud in dev, never a crash. Locales beyond `en` plug in
|
||||||
|
// here later (settings key + lazy import); nothing else changes.
|
||||||
|
const dict: Record<Key, string> = en;
|
||||||
|
|
||||||
|
export function t(key: Key): string {
|
||||||
|
return dict[key] ?? key;
|
||||||
|
}
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { Link } from "wouter";
|
||||||
|
import { ArrowLeft, Pause, Play, RefreshCw, Shield, Trash2 } from "lucide-react";
|
||||||
|
import { api } from "@/api";
|
||||||
|
import type { DomainCheck, DomainView } from "@/types";
|
||||||
|
import { DomainDialog } from "@/components/DomainDialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function Row({ k, v, mono }: { k: string; v: React.ReactNode; mono?: boolean }) {
|
||||||
|
if (v === undefined || v === null || v === "") return null;
|
||||||
|
return (
|
||||||
|
<div className="flex items-baseline justify-between gap-4 border-b border-border/50 px-4 py-2 last:border-0">
|
||||||
|
<span className="shrink-0 font-mono text-[10.5px] uppercase tracking-[0.08em] text-text-faint">{k}</span>
|
||||||
|
<span className={cn("truncate text-right text-[12px] text-text", mono && "font-mono text-[11px]")}>{v}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="overflow-hidden rounded-[10px] border border-border bg-surface">
|
||||||
|
<h2 className="border-b border-border px-4 py-2.5 font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">{title}</h2>
|
||||||
|
<div>{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDate(iso: string | null | undefined) {
|
||||||
|
if (!iso) return "—";
|
||||||
|
return new Date(iso).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DomainDetailPage({ id }: { id: string }) {
|
||||||
|
const [d, setD] = useState<DomainView | null>(null);
|
||||||
|
const [checks, setChecks] = useState<DomainCheck[]>([]);
|
||||||
|
const [dlgOpen, setDlgOpen] = useState(false);
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
const [{ data: dv }, { data: ck }] = await Promise.all([
|
||||||
|
api.GET("/api/domains/{id}", { params: { path: { id } } }),
|
||||||
|
api.GET("/api/domains/{id}/checks", { params: { path: { id } } }),
|
||||||
|
]);
|
||||||
|
if (dv) setD(dv);
|
||||||
|
if (ck) setChecks(ck);
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
useEffect(() => { void load(); }, [load]);
|
||||||
|
|
||||||
|
if (!d) return <main className="mx-auto max-w-5xl px-7 py-8 text-[13px] text-text-faint">Loading…</main>;
|
||||||
|
|
||||||
|
const expiring = d.expiring || (d.daysUntilExpiry ?? Infinity) < 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="mx-auto w-full max-w-5xl px-7 py-8">
|
||||||
|
<Link href="/domains" className="mb-4 inline-flex items-center gap-1.5 text-[12px] text-text-faint hover:text-text">
|
||||||
|
<ArrowLeft size={12} /> Domains
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="mb-6 flex items-start justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{d.faviconUrl ? <img src={d.faviconUrl} alt="" className="size-8 rounded-[7px]" /> : null}
|
||||||
|
<div>
|
||||||
|
<h1 className="text-[17px] font-semibold tracking-tight">{d.name}</h1>
|
||||||
|
<p className="mt-0.5 flex items-center gap-2 font-mono text-[11px] text-text-faint">
|
||||||
|
{d.tld ? `.${d.tld}` : ""}
|
||||||
|
{d.dnssec === "signed" && <span className="flex items-center gap-1 text-up"><Shield size={10} /> dnssec</span>}
|
||||||
|
{d.transferLock ? <span>transfer-locked</span> : null}
|
||||||
|
{d.privacyEnabled ? <span>whois privacy</span> : null}
|
||||||
|
{d.autoRenew ? <span>auto-renew</span> : null}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" size="sm" disabled={refreshing}
|
||||||
|
onClick={() => {
|
||||||
|
setRefreshing(true);
|
||||||
|
void api.POST("/api/domains/{id}/refresh", { params: { path: { id } } })
|
||||||
|
.then(load).finally(() => setRefreshing(false));
|
||||||
|
}}>
|
||||||
|
<RefreshCw size={12} className={cn(refreshing && "animate-spin")} /> Refresh
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm"
|
||||||
|
onClick={() => void api.PATCH("/api/domains/{id}", { params: { path: { id } }, body: { active: !d.active } }).then(load)}>
|
||||||
|
{d.active ? <Pause size={12} /> : <Play size={12} />} {d.active ? "Pause" : "Resume"}
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setDlgOpen(true)}>Edit</Button>
|
||||||
|
<Button variant="outline" size="sm" aria-label="Delete"
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm(`Stop tracking "${d.name}"?`)) {
|
||||||
|
void api.DELETE("/api/domains/{id}", { params: { path: { id } } }).then(() => { location.href = "/domains"; });
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
<Trash2 size={12} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{d.lookupError ? (
|
||||||
|
<p className="mb-4 rounded-[8px] border border-down/40 bg-down/10 px-3 py-2 font-mono text-[11px] text-down">
|
||||||
|
lookup: {d.lookupError}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="mb-6 grid grid-cols-4 gap-3 max-[720px]:grid-cols-2">
|
||||||
|
<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">Domain expires</div>
|
||||||
|
<div className={cn("mt-1 font-mono text-[17px] font-medium", expiring && "text-down")}>
|
||||||
|
{d.daysUntilExpiry !== null && d.daysUntilExpiry !== undefined
|
||||||
|
? (d.daysUntilExpiry < 0 ? "expired" : `${d.daysUntilExpiry}d`)
|
||||||
|
: "—"}
|
||||||
|
</div>
|
||||||
|
<div className="font-mono text-[10px] text-text-faint">{fmtDate(d.expiryDate)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-[10px] border border-border bg-surface px-4 py-3">
|
||||||
|
<div className="font-mono text-[10px] uppercase tracking-[0.1em] text-text-faint">SSL expires</div>
|
||||||
|
<div className={cn("mt-1 font-mono text-[17px] font-medium", d.sslExpiring && "text-down")}>
|
||||||
|
{d.sslDaysUntilExpiry !== null && d.sslDaysUntilExpiry !== undefined
|
||||||
|
? (d.sslDaysUntilExpiry < 0 ? "expired" : `${d.sslDaysUntilExpiry}d`)
|
||||||
|
: "—"}
|
||||||
|
</div>
|
||||||
|
<div className="font-mono text-[10px] text-text-faint">{fmtDate(d.sslValidTo)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-[10px] border border-border bg-surface px-4 py-3">
|
||||||
|
<div className="font-mono text-[10px] uppercase tracking-[0.1em] text-text-faint">Registrar</div>
|
||||||
|
<div className="mt-1 truncate text-[13px] font-medium">{d.registrarName || "—"}</div>
|
||||||
|
<div className="font-mono text-[10px] text-text-faint">{d.caProvider || "—"}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-[10px] border border-border bg-surface px-4 py-3">
|
||||||
|
<div className="font-mono text-[10px] uppercase tracking-[0.1em] text-text-faint">Providers</div>
|
||||||
|
<div className="mt-1 truncate text-[13px] font-medium">{d.dnsProvider || "—"}</div>
|
||||||
|
<div className="font-mono text-[10px] text-text-faint">{d.hostingProvider || d.emailProvider || "—"}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3 max-[820px]:grid-cols-1">
|
||||||
|
<Section title="Registration">
|
||||||
|
<Row k="Created" v={fmtDate(d.creationDate)} />
|
||||||
|
<Row k="Updated" v={fmtDate(d.updatedDate)} />
|
||||||
|
<Row k="Registrar" v={d.registrarName} />
|
||||||
|
<Row k="IANA ID" v={d.registrarId} mono />
|
||||||
|
<Row k="Registry ID" v={d.registryDomainId} mono />
|
||||||
|
<Row k="Registrant" v={[d.registrantName, d.registrantOrg].filter(Boolean).join(" / ")} />
|
||||||
|
<Row k="Country" v={d.registrantCountry} />
|
||||||
|
<Row k="Abuse" v={d.abuseEmail} mono />
|
||||||
|
{d.statuses && d.statuses.length > 0 ? <Row k="Status" v={d.statuses.join(", ")} mono /> : null}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="DNS">
|
||||||
|
{d.nameServers?.map((n) => <Row key={n} k="NS" v={n} mono />)}
|
||||||
|
{d.mxRecords?.map((m) => <Row key={m} k="MX" v={m} mono />)}
|
||||||
|
{d.ipv4?.map((a) => <Row key={a} k="A" v={a} mono />)}
|
||||||
|
{d.ipv6?.map((a) => <Row key={a} k="AAAA" v={a} mono />)}
|
||||||
|
{d.cname ? <Row k="CNAME" v={d.cname} mono /> : null}
|
||||||
|
{d.txtRecords?.slice(0, 4).map((t, i) => <Row key={i} k="TXT" v={t.length > 60 ? t.slice(0, 60) + "…" : t} mono />)}
|
||||||
|
{d.dnsProvider ? <Row k="DNS provider" v={d.dnsProvider} /> : null}
|
||||||
|
{d.emailProvider ? <Row k="Email" v={d.emailProvider} /> : null}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="TLS certificate">
|
||||||
|
<Row k="Issuer" v={d.sslIssuer} />
|
||||||
|
<Row k="CA" v={d.caProvider} />
|
||||||
|
<Row k="Valid" v={`${fmtDate(d.sslValidFrom)} → ${fmtDate(d.sslValidTo)}`} mono />
|
||||||
|
<Row k="Subject" v={d.sslSubject} mono />
|
||||||
|
<Row k="Key" v={d.sslKeySize ? `${d.sslKeySize} bit` : ""} mono />
|
||||||
|
<Row k="Algorithm" v={d.sslSigAlgo} mono />
|
||||||
|
{d.sslAltNames && d.sslAltNames.length > 0 ? (
|
||||||
|
<Row k="SANs" v={d.sslAltNames.slice(0, 5).join(", ") + (d.sslAltNames.length > 5 ? ` +${d.sslAltNames.length - 5}` : "")} mono />
|
||||||
|
) : null}
|
||||||
|
<Row k="SHA-256" v={d.sslFingerprint ? d.sslFingerprint.slice(0, 29) + "…" : ""} mono />
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Host">
|
||||||
|
<Row k="Location" v={[d.hostCity, d.hostRegion, d.hostCountry].filter(Boolean).join(", ")} />
|
||||||
|
<Row k="ISP" v={d.hostIsp} />
|
||||||
|
<Row k="Org" v={d.hostOrg} />
|
||||||
|
<Row k="AS" v={d.hostAs} mono />
|
||||||
|
<Row k="Hosting" v={d.hostingProvider} />
|
||||||
|
<Row k="Checked" v={d.lastChecked ? new Date(d.lastChecked).toLocaleString() : "—"} mono />
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{Object.keys(d.headers ?? {}).length > 0 ? (
|
||||||
|
<details className="mt-3 overflow-hidden rounded-[10px] border border-border bg-surface">
|
||||||
|
<summary className="cursor-pointer px-4 py-2.5 font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">
|
||||||
|
Response headers ({Object.keys(d.headers ?? {}).length})
|
||||||
|
</summary>
|
||||||
|
<div className="border-t border-border px-4 py-2">
|
||||||
|
{Object.entries(d.headers ?? {}).map(([k, v]) => (
|
||||||
|
<div key={k} className="flex gap-3 py-0.5 font-mono text-[11px]">
|
||||||
|
<span className="w-44 shrink-0 text-text-faint">{k}</span>
|
||||||
|
<span className="truncate text-text-dim">{v}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<h2 className="mb-2 mt-6 font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">Check history</h2>
|
||||||
|
<div className="overflow-hidden rounded-[10px] border border-border">
|
||||||
|
<table className="w-full text-[12px]">
|
||||||
|
<tbody>
|
||||||
|
{checks.map((c) => (
|
||||||
|
<tr key={c.id} className="border-b border-border/60 last:border-0">
|
||||||
|
<td className="px-4 py-2 font-mono text-[10.5px] text-text-faint">{new Date(c.checkedAt ?? "").toLocaleString()}</td>
|
||||||
|
<td className="px-4 py-2 font-mono text-[11px] text-text-dim">exp {fmtDate(c.expiryDate)}</td>
|
||||||
|
<td className="px-4 py-2 font-mono text-[11px] text-text-dim">ssl {fmtDate(c.sslValidTo)}</td>
|
||||||
|
<td className="px-4 py-2 font-mono text-[11px] text-text-faint">{(c.ipv4 ?? []).join(", ")}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{checks.length === 0 && (
|
||||||
|
<tr><td className="px-4 py-6 text-center text-text-faint">No snapshots yet.</td></tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DomainDialog open={dlgOpen} onOpenChange={setDlgOpen} domain={d} onSaved={load} />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { Link } from "wouter";
|
||||||
|
import { Pause, Play, Plus, RefreshCw, Trash2 } from "lucide-react";
|
||||||
|
import { api } from "@/api";
|
||||||
|
import type { DomainView } from "@/types";
|
||||||
|
import { DomainDialog } from "@/components/DomainDialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function daysLabel(days: number | null | undefined, warn?: boolean) {
|
||||||
|
if (days === null || days === undefined) return "—";
|
||||||
|
const cls = warn || days < 0 ? "text-down" : days <= 30 ? "text-amber-500" : "text-text-dim";
|
||||||
|
return <span className={cn("font-mono text-[11px]", cls)}>{days < 0 ? "expired" : `${days}d`}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DomainsPage() {
|
||||||
|
const [domains, setDomains] = useState<DomainView[]>([]);
|
||||||
|
const [loaded, setLoaded] = useState(false);
|
||||||
|
const [dlgOpen, setDlgOpen] = useState(false);
|
||||||
|
const [editing, setEditing] = useState<DomainView | undefined>();
|
||||||
|
const [refreshing, setRefreshing] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
const { data } = await api.GET("/api/domains");
|
||||||
|
if (data) setDomains(data);
|
||||||
|
setLoaded(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { void load(); }, [load]);
|
||||||
|
|
||||||
|
async function act(id: string | undefined, fn: () => Promise<unknown>) {
|
||||||
|
if (!id) return;
|
||||||
|
await fn();
|
||||||
|
await load();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="mx-auto w-full max-w-5xl px-7 py-8">
|
||||||
|
<div className="mb-5 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-[15px] font-semibold tracking-tight">Domains</h1>
|
||||||
|
<p className="text-[12px] text-text-faint">WHOIS, DNS, and certificate watch.</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => { setEditing(undefined); setDlgOpen(true); }}>
|
||||||
|
<Plus size={13} strokeWidth={2.2} /> Track domain
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loaded && domains.length === 0 ? (
|
||||||
|
<div className="rounded-[10px] border border-dashed border-border px-6 py-14 text-center">
|
||||||
|
<p className="text-[13px] text-text-dim">No domains tracked yet.</p>
|
||||||
|
<p className="mt-1 text-[11.5px] text-text-faint">
|
||||||
|
Expiry, registrar, DNS records, SSL certificates, and provider detection.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-hidden rounded-[10px] border border-border">
|
||||||
|
<table className="w-full text-[13px]">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border bg-surface text-left font-mono text-[10px] uppercase tracking-[0.1em] text-text-faint">
|
||||||
|
<th className="px-4 py-2.5 font-medium">Domain</th>
|
||||||
|
<th className="px-4 py-2.5 font-medium">Registrar</th>
|
||||||
|
<th className="px-4 py-2.5 font-medium">Expires</th>
|
||||||
|
<th className="px-4 py-2.5 font-medium">SSL</th>
|
||||||
|
<th className="px-4 py-2.5 font-medium">Providers</th>
|
||||||
|
<th className="px-4 py-2.5" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{domains.map((d) => (
|
||||||
|
<tr key={d.id} className={cn("border-b border-border/60 last:border-0 hover:bg-surface-hover/60", !d.active && "opacity-50")}>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className="flex items-center gap-2.5 font-medium">
|
||||||
|
{d.faviconUrl ? (
|
||||||
|
<img src={d.faviconUrl} alt="" className="size-4 rounded-sm" loading="lazy" />
|
||||||
|
) : null}
|
||||||
|
<Link href={`/domains/${d.id}`} className="hover:underline">{d.name}</Link>
|
||||||
|
{d.lookupError ? (
|
||||||
|
<span className="font-mono text-[10px] text-down" title={d.lookupError}>lookup error</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-[11.5px] text-text-dim">{d.registrarName || "—"}</td>
|
||||||
|
<td className="px-4 py-3">{daysLabel(d.daysUntilExpiry, d.expiring)}</td>
|
||||||
|
<td className="px-4 py-3">{daysLabel(d.sslDaysUntilExpiry, d.sslExpiring)}</td>
|
||||||
|
<td className="px-4 py-3 font-mono text-[10.5px] text-text-faint">
|
||||||
|
{[d.dnsProvider, d.hostingProvider].filter(Boolean).join(" · ") || "—"}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex items-center justify-end gap-1">
|
||||||
|
<Button variant="outline" size="icon" aria-label="Refresh now" disabled={refreshing === d.id}
|
||||||
|
onClick={() => {
|
||||||
|
if (!d.id) return;
|
||||||
|
setRefreshing(d.id);
|
||||||
|
void act(d.id, () => api.POST("/api/domains/{id}/refresh", { params: { path: { id: d.id } } }))
|
||||||
|
.finally(() => setRefreshing(null));
|
||||||
|
}}>
|
||||||
|
<RefreshCw size={12} className={cn(refreshing === d.id && "animate-spin")} />
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="icon" aria-label={d.active ? "Pause" : "Resume"}
|
||||||
|
onClick={() => void act(d.id, () => api.PATCH("/api/domains/{id}", { params: { path: { id: d.id! } }, body: { active: !d.active } }))}>
|
||||||
|
{d.active ? <Pause size={12} /> : <Play size={12} />}
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="icon" aria-label="Delete"
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm(`Stop tracking "${d.name}"?`)) {
|
||||||
|
void act(d.id, () => api.DELETE("/api/domains/{id}", { params: { path: { id: d.id! } } }));
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
<Trash2 size={12} />
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => { setEditing(d); setDlgOpen(true); }}>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DomainDialog open={dlgOpen} onOpenChange={setDlgOpen} domain={editing} onSaved={load} />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { Plus, Trash2 } from "lucide-react";
|
||||||
|
import { api } from "@/api";
|
||||||
|
import type { Incident } from "@/types";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const SEV_COLOR = {
|
||||||
|
critical: "text-down",
|
||||||
|
major: "text-down",
|
||||||
|
minor: "text-text-faint",
|
||||||
|
} satisfies Record<NonNullable<Incident["severity"]>, string>;
|
||||||
|
|
||||||
|
type Status = NonNullable<Incident["status"]>;
|
||||||
|
const NEXT: Partial<Record<Status, { label: string; to: Status }[]>> = {
|
||||||
|
open: [{ label: "Acknowledge", to: "ack" }, { label: "Resolve", to: "resolved" }],
|
||||||
|
ack: [{ label: "Resolve", to: "resolved" }],
|
||||||
|
resolved: [{ label: "Close", to: "closed" }],
|
||||||
|
};
|
||||||
|
|
||||||
|
export function IncidentsPage() {
|
||||||
|
const [incidents, setIncidents] = useState<Incident[]>([]);
|
||||||
|
const [loaded, setLoaded] = useState(false);
|
||||||
|
const [showAll, setShowAll] = useState(false);
|
||||||
|
const [dlgOpen, setDlgOpen] = useState(false);
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
|
const [sev, setSev] = useState("major");
|
||||||
|
const [msg, setMsg] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [expanded, setExpanded] = useState<string | null>(null);
|
||||||
|
const [note, setNote] = useState("");
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
const { data } = await api.GET("/api/incidents", showAll ? {} : { params: { query: { status: "open" } } });
|
||||||
|
if (data) setIncidents(data);
|
||||||
|
setLoaded(true);
|
||||||
|
}, [showAll]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
const t = setInterval(() => void load(), 30_000);
|
||||||
|
return () => clearInterval(t);
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
async function create() {
|
||||||
|
if (!title.trim()) return;
|
||||||
|
setBusy(true);
|
||||||
|
// The select only offers the three severities, but its value is a
|
||||||
|
// plain string — narrow at the boundary instead of asserting.
|
||||||
|
const severity = sev === "critical" || sev === "minor" ? sev : "major";
|
||||||
|
await api.POST("/api/incidents", {
|
||||||
|
body: { title: title.trim(), severity, ...(msg.trim() ? { message: msg.trim() } : {}) },
|
||||||
|
});
|
||||||
|
setBusy(false);
|
||||||
|
setDlgOpen(false);
|
||||||
|
setTitle(""); setMsg("");
|
||||||
|
void load();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function transition(id: string | undefined, to: Status, message?: string) {
|
||||||
|
if (!id) return;
|
||||||
|
await api.PATCH("/api/incidents/{id}", {
|
||||||
|
params: { path: { id } },
|
||||||
|
body: { status: to, ...(message?.trim() ? { message } : {}) },
|
||||||
|
});
|
||||||
|
await load();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addNote(id: string | undefined) {
|
||||||
|
if (!id || !note.trim()) return;
|
||||||
|
await api.POST("/api/incidents/{id}/updates", { params: { path: { id } }, body: { message: note.trim() } });
|
||||||
|
setNote("");
|
||||||
|
await load();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="mx-auto w-full max-w-5xl px-7 py-8">
|
||||||
|
<div className="mb-5 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-[15px] font-semibold tracking-tight">Incidents</h1>
|
||||||
|
<p className="text-[12px] text-text-faint">Auto-opened on monitor outages, or tracked manually.</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setShowAll((v) => !v)}>
|
||||||
|
{showAll ? "Open only" : "Show all"}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => setDlgOpen(true)}>
|
||||||
|
<Plus size={13} strokeWidth={2.2} /> New incident
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loaded && incidents.length === 0 ? (
|
||||||
|
<div className="rounded-[10px] border border-dashed border-border px-6 py-14 text-center">
|
||||||
|
<p className="text-[13px] text-text-dim">No open incidents.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-2.5">
|
||||||
|
{incidents.map((inc) => (
|
||||||
|
<div key={inc.id} className="rounded-[10px] border border-border bg-surface px-4 py-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className={cn("font-mono text-[10px] uppercase tracking-[0.08em]",
|
||||||
|
inc.severity ? SEV_COLOR[inc.severity] : "text-text-faint")}>
|
||||||
|
{inc.severity}
|
||||||
|
</span>
|
||||||
|
<button type="button" className="min-w-0 flex-1 truncate text-left text-[13px] font-medium hover:underline"
|
||||||
|
onClick={() => setExpanded(expanded === inc.id ? null : (inc.id ?? null))}>
|
||||||
|
{inc.title}
|
||||||
|
</button>
|
||||||
|
<span className="font-mono text-[10.5px] uppercase text-text-faint">{inc.status}</span>
|
||||||
|
<span className="font-mono text-[10.5px] text-text-faint">
|
||||||
|
{inc.createdAt ? new Date(inc.createdAt).toLocaleString() : ""}
|
||||||
|
</span>
|
||||||
|
{(inc.status ? (NEXT[inc.status] ?? []) : []).map((n) => (
|
||||||
|
<Button key={n.to} variant="outline" size="sm" onClick={() => void transition(inc.id, n.to)}>
|
||||||
|
{n.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
<Button variant="outline" size="icon" aria-label="Delete"
|
||||||
|
onClick={() => {
|
||||||
|
if (inc.id && confirm(`Delete incident "${inc.title}"?`)) {
|
||||||
|
void api.DELETE("/api/incidents/{id}", { params: { path: { id: inc.id } } }).then(load);
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
<Trash2 size={12} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{expanded === inc.id && (
|
||||||
|
<div className="mt-3 border-t border-border/60 pt-3">
|
||||||
|
<div className="mb-2.5 flex flex-col gap-1.5">
|
||||||
|
{(inc.updates ?? []).map((u) => (
|
||||||
|
<div key={u.id} className="flex items-baseline gap-2.5 text-[12px]">
|
||||||
|
<span className="shrink-0 font-mono text-[10px] uppercase tracking-wide text-text-faint">{u.status}</span>
|
||||||
|
<span className="flex-1 text-text-dim">{u.message}</span>
|
||||||
|
<span className="shrink-0 font-mono text-[10px] text-text-faint">
|
||||||
|
{u.at ? new Date(u.at).toLocaleString() : ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{(inc.updates ?? []).length === 0 && (
|
||||||
|
<p className="text-[11.5px] text-text-faint">No updates yet.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input value={note} onChange={(e) => setNote(e.target.value)}
|
||||||
|
placeholder="Add a note…" className="h-8 text-[12px]"
|
||||||
|
onKeyDown={(e) => { if (e.key === "Enter") void addNote(inc.id); }} />
|
||||||
|
<Button variant="outline" size="sm" onClick={() => void addNote(inc.id)}>Post</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Dialog open={dlgOpen} onOpenChange={setDlgOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>New incident</DialogTitle>
|
||||||
|
<DialogDescription>Track an outage or degradation manually.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="inc-title">Title</Label>
|
||||||
|
<Input id="inc-title" value={title} onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder="NAS offline" autoFocus />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>Severity</Label>
|
||||||
|
<Select value={sev} onValueChange={setSev}>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="minor">minor</SelectItem>
|
||||||
|
<SelectItem value="major">major</SelectItem>
|
||||||
|
<SelectItem value="critical">critical</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="inc-msg">Note (optional)</Label>
|
||||||
|
<Input id="inc-msg" value={msg} onChange={(e) => setMsg(e.target.value)} placeholder="First observed…" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setDlgOpen(false)} disabled={busy}>Cancel</Button>
|
||||||
|
<Button onClick={() => void create()} disabled={busy || !title.trim()}>
|
||||||
|
{busy ? "Creating…" : "Create"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { Link } from "wouter";
|
||||||
|
import { ArrowLeft, Pause, Play, RefreshCw, Trash2 } from "lucide-react";
|
||||||
|
import { Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
||||||
|
import { api } from "@/api";
|
||||||
|
import type { Heartbeat, MonitorView } from "@/types";
|
||||||
|
import { MonitorDialog } from "@/components/MonitorDialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export function MonitorDetailPage({ id }: { id: string }) {
|
||||||
|
const [m, setM] = useState<MonitorView | null>(null);
|
||||||
|
const [hbs, setHbs] = useState<Heartbeat[]>([]);
|
||||||
|
const [hours, setHours] = useState(24);
|
||||||
|
const [dlgOpen, setDlgOpen] = useState(false);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
const [{ data: mv }, { data: hb }] = await Promise.all([
|
||||||
|
api.GET("/api/monitors/{id}", { params: { path: { id } } }),
|
||||||
|
api.GET("/api/monitors/{id}/heartbeats", { params: { path: { id }, query: { hours } } }),
|
||||||
|
]);
|
||||||
|
if (mv) setM(mv);
|
||||||
|
if (hb) setHbs(hb);
|
||||||
|
}, [id, hours]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
const t = setInterval(() => void load(), 30_000);
|
||||||
|
return () => clearInterval(t);
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
if (!m) return <main className="mx-auto max-w-5xl px-7 py-8 text-[13px] text-text-faint">Loading…</main>;
|
||||||
|
|
||||||
|
const chart = hbs.map((h) => ({
|
||||||
|
t: new Date(h.checkedAt ?? "").toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }),
|
||||||
|
ping: h.pingMs,
|
||||||
|
up: h.status === "up",
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="mx-auto w-full max-w-5xl px-7 py-8">
|
||||||
|
<Link href="/monitors" className="mb-4 inline-flex items-center gap-1.5 text-[12px] text-text-faint hover:text-text">
|
||||||
|
<ArrowLeft size={12} /> Monitors
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="mb-6 flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<span className={cn("size-2 rounded-full", m.status === "up" ? "bg-up" : m.status === "down" ? "bg-down" : "bg-text-faint/40")} />
|
||||||
|
<h1 className="text-[17px] font-semibold tracking-tight">{m.name}</h1>
|
||||||
|
<span className="font-mono text-[10.5px] uppercase tracking-[0.08em] text-text-faint">{m.status}</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 font-mono text-[11.5px] text-text-faint">
|
||||||
|
{m.type} · {m.url || m.hostname}{m.port ? `:${m.port}` : ""} · every {m.intervalS}s
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={() => void api.POST("/api/monitors/{id}/check", { params: { path: { id } } }).then(load)}>
|
||||||
|
<RefreshCw size={12} /> Check now
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm"
|
||||||
|
onClick={() => void api.PATCH("/api/monitors/{id}", { params: { path: { id } }, body: { active: !m.active } }).then(load)}>
|
||||||
|
{m.active ? <Pause size={12} /> : <Play size={12} />} {m.active ? "Pause" : "Resume"}
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setDlgOpen(true)}>Edit</Button>
|
||||||
|
<Button variant="outline" size="sm" aria-label="Delete"
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm(`Delete monitor "${m.name}"?`)) {
|
||||||
|
void api.DELETE("/api/monitors/{id}", { params: { path: { id } } }).then(() => { location.href = "/monitors"; });
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
<Trash2 size={12} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-6 grid grid-cols-4 gap-3 max-[720px]:grid-cols-2">
|
||||||
|
{[
|
||||||
|
["Uptime 24h", `${m.stats?.uptime24h?.toFixed(2)}%`],
|
||||||
|
["Uptime 30d", `${m.stats?.uptime30d?.toFixed(2)}%`],
|
||||||
|
["Avg ping 24h", m.stats?.avgPing24h ? `${m.stats.avgPing24h} ms` : "—"],
|
||||||
|
["Checks", String((m.stats?.up ?? 0) + (m.stats?.down ?? 0))],
|
||||||
|
].map(([k, v]) => (
|
||||||
|
<div key={k} 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">{k}</div>
|
||||||
|
<div className="mt-1 font-mono text-[17px] font-medium">{v}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-2 flex items-center justify-between">
|
||||||
|
<h2 className="font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">Response time</h2>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{[24, 168, 720].map((h) => (
|
||||||
|
<button key={h} type="button" onClick={() => setHours(h)}
|
||||||
|
className={cn("rounded-[6px] px-2 py-1 font-mono text-[10.5px]",
|
||||||
|
hours === h ? "bg-surface-hover text-text" : "text-text-faint hover:text-text")}>
|
||||||
|
{h === 24 ? "24h" : h === 168 ? "7d" : "30d"}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mb-6 h-56 rounded-[10px] border border-border bg-surface p-3">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<AreaChart data={chart} margin={{ top: 4, right: 4, bottom: 0, left: -18 }}>
|
||||||
|
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" vertical={false} />
|
||||||
|
<XAxis dataKey="t" tick={{ fontSize: 10, fill: "var(--text-faint)" }} tickLine={false} axisLine={false} minTickGap={40} />
|
||||||
|
<YAxis tick={{ fontSize: 10, fill: "var(--text-faint)" }} tickLine={false} axisLine={false} unit=" ms" width={62} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{ background: "var(--surface)", border: "1px solid var(--border)", borderRadius: 8, fontSize: 11 }}
|
||||||
|
labelStyle={{ color: "var(--text-faint)" }}
|
||||||
|
/>
|
||||||
|
<Area type="monotone" dataKey="ping" stroke="var(--up)" fill="var(--up)" fillOpacity={0.12} strokeWidth={1.5} dot={false} isAnimationActive={false} />
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 className="mb-2 font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">Recent checks</h2>
|
||||||
|
<div className="overflow-hidden rounded-[10px] border border-border">
|
||||||
|
<table className="w-full text-[12px]">
|
||||||
|
<tbody>
|
||||||
|
{[...hbs].reverse().slice(0, 40).map((h) => (
|
||||||
|
<tr key={h.id} className="border-b border-border/60 last:border-0">
|
||||||
|
<td className="px-4 py-2 font-mono text-[10.5px] text-text-faint">
|
||||||
|
{new Date(h.checkedAt ?? "").toLocaleString()}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
<span className={cn("font-mono text-[10.5px] uppercase", h.status === "up" ? "text-up" : "text-down")}>
|
||||||
|
{h.status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 font-mono text-[11px] text-text-dim">{h.pingMs ? `${h.pingMs}ms` : "—"}</td>
|
||||||
|
<td className="px-4 py-2 font-mono text-[11px] text-text-faint">{h.msg}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{hbs.length === 0 && (
|
||||||
|
<tr><td className="px-4 py-6 text-center text-text-faint">No checks in range.</td></tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<MonitorDialog open={dlgOpen} onOpenChange={setDlgOpen} monitor={m} onSaved={load} />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { Link } from "wouter";
|
||||||
|
import { Pause, Play, Plus, RefreshCw, Trash2 } from "lucide-react";
|
||||||
|
import { api } from "@/api";
|
||||||
|
import type { MonitorView } from "@/types";
|
||||||
|
import { MonitorDialog } from "@/components/MonitorDialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function StatusDot({ status }: { status?: string }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"size-1.5 shrink-0 rounded-full",
|
||||||
|
status === "up" ? "bg-up" : status === "down" ? "bg-down" : "bg-text-faint/40",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MonitorsPage() {
|
||||||
|
const [monitors, setMonitors] = useState<MonitorView[]>([]);
|
||||||
|
const [loaded, setLoaded] = useState(false);
|
||||||
|
const [dlgOpen, setDlgOpen] = useState(false);
|
||||||
|
const [editing, setEditing] = useState<MonitorView | undefined>();
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
const { data } = await api.GET("/api/monitors");
|
||||||
|
if (data) setMonitors(data);
|
||||||
|
setLoaded(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
const t = setInterval(() => void load(), 30_000);
|
||||||
|
return () => clearInterval(t);
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
async function act(id: string | undefined, fn: () => Promise<unknown>) {
|
||||||
|
if (!id) return;
|
||||||
|
await fn();
|
||||||
|
await load();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="mx-auto w-full max-w-5xl px-7 py-8">
|
||||||
|
<div className="mb-5 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-[15px] font-semibold tracking-tight">Monitors</h1>
|
||||||
|
<p className="text-[12px] text-text-faint">Uptime checks on a schedule.</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => { setEditing(undefined); setDlgOpen(true); }}>
|
||||||
|
<Plus size={13} strokeWidth={2.2} /> New monitor
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loaded && monitors.length === 0 ? (
|
||||||
|
<div className="rounded-[10px] border border-dashed border-border px-6 py-14 text-center">
|
||||||
|
<p className="text-[13px] text-text-dim">No monitors yet.</p>
|
||||||
|
<p className="mt-1 text-[11.5px] text-text-faint">
|
||||||
|
HTTP, TCP, ping, DNS, keyword, JSON path, or push — checks run server-side on your interval.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-hidden rounded-[10px] border border-border">
|
||||||
|
<table className="w-full text-[13px]">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border bg-surface text-left font-mono text-[10px] uppercase tracking-[0.1em] text-text-faint">
|
||||||
|
<th className="px-4 py-2.5 font-medium">Status</th>
|
||||||
|
<th className="px-4 py-2.5 font-medium">Name</th>
|
||||||
|
<th className="px-4 py-2.5 font-medium">Type</th>
|
||||||
|
<th className="px-4 py-2.5 font-medium">Uptime 24h</th>
|
||||||
|
<th className="px-4 py-2.5 font-medium">Uptime 30d</th>
|
||||||
|
<th className="px-4 py-2.5 font-medium">Ping</th>
|
||||||
|
<th className="px-4 py-2.5 font-medium">Every</th>
|
||||||
|
<th className="px-4 py-2.5" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{monitors.map((m) => (
|
||||||
|
<tr key={m.id} className="border-b border-border/60 last:border-0 hover:bg-surface-hover/60">
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className="flex items-center gap-2 font-mono text-[10.5px] uppercase tracking-[0.06em] text-text-faint">
|
||||||
|
<StatusDot status={m.status} />
|
||||||
|
{m.status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 font-medium">
|
||||||
|
<Link href={`/monitors/${m.id}`} className="hover:underline">{m.name}</Link>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 font-mono text-[11px] text-text-dim">{m.type}</td>
|
||||||
|
<td className="px-4 py-3 font-mono text-[11px] text-text-dim">
|
||||||
|
{m.stats?.uptime24h?.toFixed(1)}%
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 font-mono text-[11px] text-text-dim">
|
||||||
|
{m.stats?.uptime30d?.toFixed(1)}%
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 font-mono text-[11px] text-text-dim">
|
||||||
|
{m.stats?.avgPing24h ? `${m.stats.avgPing24h}ms` : "—"}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 font-mono text-[11px] text-text-dim">{m.intervalS}s</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex items-center justify-end gap-1">
|
||||||
|
<Button variant="outline" size="icon" aria-label="Check now"
|
||||||
|
onClick={() => void act(m.id, () => api.POST("/api/monitors/{id}/check", { params: { path: { id: m.id! } } }))}>
|
||||||
|
<RefreshCw size={12} />
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="icon" aria-label={m.active ? "Pause" : "Resume"}
|
||||||
|
onClick={() => void act(m.id, () => api.PATCH("/api/monitors/{id}", { params: { path: { id: m.id! } }, body: { active: !m.active } }))}>
|
||||||
|
{m.active ? <Pause size={12} /> : <Play size={12} />}
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="icon" aria-label="Delete"
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm(`Delete monitor "${m.name}"?`)) {
|
||||||
|
void act(m.id, () => api.DELETE("/api/monitors/{id}", { params: { path: { id: m.id! } } }));
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
<Trash2 size={12} />
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => { setEditing(m); setDlgOpen(true); }}>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<MonitorDialog open={dlgOpen} onOpenChange={setDlgOpen} monitor={editing} onSaved={load} />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { Link } from "wouter";
|
||||||
|
import { ExternalLink, Plus, Trash2 } from "lucide-react";
|
||||||
|
import { api } from "@/api";
|
||||||
|
import type { MaintenanceWindow, MonitorView, StatusPage } from "@/types";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
// Admin for public status pages + maintenance windows. The public view
|
||||||
|
// itself lives at /status/:slug.
|
||||||
|
|
||||||
|
export function StatusPagesPage() {
|
||||||
|
const [pages, setPages] = useState<StatusPage[]>([]);
|
||||||
|
const [windows, setWindows] = useState<MaintenanceWindow[]>([]);
|
||||||
|
const [monitors, setMonitors] = useState<MonitorView[]>([]);
|
||||||
|
const [loaded, setLoaded] = useState(false);
|
||||||
|
|
||||||
|
const [pageOpen, setPageOpen] = useState(false);
|
||||||
|
const [pTitle, setPTitle] = useState("");
|
||||||
|
const [pSlug, setPSlug] = useState("");
|
||||||
|
const [pDesc, setPDesc] = useState("");
|
||||||
|
const [pMonitors, setPMonitors] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const [winOpen, setWinOpen] = useState(false);
|
||||||
|
const [wTitle, setWTitle] = useState("");
|
||||||
|
const [wStart, setWStart] = useState("");
|
||||||
|
const [wEnd, setWEnd] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [err, setErr] = useState("");
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
const [{ data: p }, { data: w }, { data: m }] = await Promise.all([
|
||||||
|
api.GET("/api/status-pages"),
|
||||||
|
api.GET("/api/maintenance"),
|
||||||
|
api.GET("/api/monitors"),
|
||||||
|
]);
|
||||||
|
if (p) setPages(p);
|
||||||
|
if (w) setWindows(w);
|
||||||
|
if (m) setMonitors(m);
|
||||||
|
setLoaded(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
const t = setInterval(() => void load(), 30_000);
|
||||||
|
return () => clearInterval(t);
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
async function createPage() {
|
||||||
|
if (!pTitle.trim()) return;
|
||||||
|
setBusy(true); setErr("");
|
||||||
|
const { error } = await api.POST("/api/status-pages", {
|
||||||
|
body: {
|
||||||
|
title: pTitle.trim(),
|
||||||
|
...(pSlug.trim() ? { slug: pSlug.trim() } : {}),
|
||||||
|
description: pDesc.trim(),
|
||||||
|
monitorIds: pMonitors,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
setBusy(false);
|
||||||
|
if (error) { setErr("Could not create — slug may be taken."); return; }
|
||||||
|
setPageOpen(false); setPTitle(""); setPSlug(""); setPDesc(""); setPMonitors([]);
|
||||||
|
void load();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createWindow() {
|
||||||
|
if (!wTitle.trim() || !wStart || !wEnd) return;
|
||||||
|
setBusy(true); setErr("");
|
||||||
|
const { error } = await api.POST("/api/maintenance", {
|
||||||
|
body: {
|
||||||
|
title: wTitle.trim(),
|
||||||
|
startsAt: new Date(wStart).toISOString(),
|
||||||
|
endsAt: new Date(wEnd).toISOString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
setBusy(false);
|
||||||
|
if (error) { setErr("Invalid window — check times."); return; }
|
||||||
|
setWinOpen(false); setWTitle(""); setWStart(""); setWEnd("");
|
||||||
|
void load();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="mx-auto w-full max-w-5xl px-7 py-8">
|
||||||
|
<div className="mb-5 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-[15px] font-semibold tracking-tight">Status pages</h1>
|
||||||
|
<p className="text-[12px] text-text-faint">Public live views at <code className="font-mono">/status/<slug></code>.</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => setPageOpen(true)}>
|
||||||
|
<Plus size={13} strokeWidth={2.2} /> New page
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loaded && pages.length === 0 ? (
|
||||||
|
<div className="mb-8 rounded-[10px] border border-dashed border-border px-6 py-10 text-center">
|
||||||
|
<p className="text-[13px] text-text-dim">No status pages yet.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mb-8 flex flex-col gap-2">
|
||||||
|
{pages.map((p) => (
|
||||||
|
<div key={p.id} className="flex items-center gap-3 rounded-[10px] border border-border bg-surface px-4 py-3">
|
||||||
|
<span className="min-w-0 flex-1 truncate text-[13px] font-medium">{p.title}</span>
|
||||||
|
<code className="font-mono text-[11px] text-text-faint">/status/{p.slug}</code>
|
||||||
|
<Link href={`/status/${p.slug}`} className="shrink-0">
|
||||||
|
<Button variant="outline" size="icon" aria-label="Open public page"><ExternalLink size={12} /></Button>
|
||||||
|
</Link>
|
||||||
|
<Button variant="outline" size="icon" aria-label="Delete"
|
||||||
|
onClick={() => {
|
||||||
|
if (p.id && confirm(`Delete status page "${p.title}"?`)) {
|
||||||
|
void api.DELETE("/api/status-pages/{id}", { params: { path: { id: p.id } } }).then(load);
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
<Trash2 size={12} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mb-5 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-[15px] font-semibold tracking-tight">Maintenance windows</h2>
|
||||||
|
<p className="text-[12px] text-text-faint">Active windows mute alerts + auto-incidents and mark covered monitors.</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" onClick={() => setWinOpen(true)}>
|
||||||
|
<Plus size={13} strokeWidth={2.2} /> Schedule
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loaded && windows.length === 0 ? (
|
||||||
|
<div className="rounded-[10px] border border-dashed border-border px-6 py-10 text-center">
|
||||||
|
<p className="text-[13px] text-text-dim">No maintenance scheduled.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{windows.map((w) => (
|
||||||
|
<div key={w.id} className="flex items-center gap-3 rounded-[10px] border border-border bg-surface px-4 py-3">
|
||||||
|
<span className={cn("size-1.5 shrink-0 rounded-full", w.active ? "bg-up" : "bg-text-faint/40")} />
|
||||||
|
<span className="min-w-0 flex-1 truncate text-[13px] font-medium">{w.title}</span>
|
||||||
|
<span className="font-mono text-[10.5px] text-text-faint">
|
||||||
|
{new Date(w.startsAt ?? "").toLocaleString()} → {new Date(w.endsAt ?? "").toLocaleString()}
|
||||||
|
</span>
|
||||||
|
<span className={cn("font-mono text-[10px] uppercase", w.active ? "text-up" : "text-text-faint")}>
|
||||||
|
{w.active ? "active" : "scheduled"}
|
||||||
|
</span>
|
||||||
|
<Button variant="outline" size="icon" aria-label="Delete"
|
||||||
|
onClick={() => {
|
||||||
|
if (w.id && confirm(`Delete window "${w.title}"?`)) {
|
||||||
|
void api.DELETE("/api/maintenance/{id}", { params: { path: { id: w.id } } }).then(load);
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
<Trash2 size={12} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Dialog open={pageOpen} onOpenChange={setPageOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>New status page</DialogTitle>
|
||||||
|
<DialogDescription>Public live view over selected monitors.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="sp-title">Title</Label>
|
||||||
|
<Input id="sp-title" value={pTitle} onChange={(e) => setPTitle(e.target.value)} placeholder="Homelab status" autoFocus />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="sp-slug">Slug</Label>
|
||||||
|
<Input id="sp-slug" value={pSlug} onChange={(e) => setPSlug(e.target.value)} placeholder="auto" className="font-mono text-[12px]" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="sp-desc">Description</Label>
|
||||||
|
<Input id="sp-desc" value={pDesc} onChange={(e) => setPDesc(e.target.value)} placeholder="optional" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>Monitors</Label>
|
||||||
|
<div className="max-h-40 overflow-y-auto rounded-[7px] border border-border p-2">
|
||||||
|
{monitors.map((m) => (
|
||||||
|
<label key={m.id} className="flex items-center gap-2 px-1 py-1 text-[12px]">
|
||||||
|
<input type="checkbox" className="accent-current"
|
||||||
|
checked={pMonitors.includes(m.id ?? "")}
|
||||||
|
onChange={(e) => setPMonitors((v) =>
|
||||||
|
e.target.checked ? [...v, m.id ?? ""] : v.filter((x) => x !== m.id))} />
|
||||||
|
{m.name}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
{monitors.length === 0 && <p className="px-1 py-1 text-[11.5px] text-text-faint">No monitors yet — empty selection shows all.</p>}
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-text-faint">Leave all unchecked to include every active monitor.</p>
|
||||||
|
</div>
|
||||||
|
{err ? <p className="text-[12px] text-destructive">{err}</p> : null}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setPageOpen(false)} disabled={busy}>Cancel</Button>
|
||||||
|
<Button onClick={() => void createPage()} disabled={busy || !pTitle.trim()}>
|
||||||
|
{busy ? "Creating…" : "Create"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog open={winOpen} onOpenChange={setWinOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Schedule maintenance</DialogTitle>
|
||||||
|
<DialogDescription>Suppresses alerts and auto-incidents for the window.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="mw-title">Title</Label>
|
||||||
|
<Input id="mw-title" value={wTitle} onChange={(e) => setWTitle(e.target.value)} placeholder="Network upgrade" autoFocus />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="mw-start">Starts</Label>
|
||||||
|
<Input id="mw-start" type="datetime-local" value={wStart} onChange={(e) => setWStart(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="mw-end">Ends</Label>
|
||||||
|
<Input id="mw-end" type="datetime-local" value={wEnd} onChange={(e) => setWEnd(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{err ? <p className="text-[12px] text-destructive">{err}</p> : null}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setWinOpen(false)} disabled={busy}>Cancel</Button>
|
||||||
|
<Button onClick={() => void createWindow()} disabled={busy || !wTitle.trim() || !wStart || !wEnd}>
|
||||||
|
{busy ? "Saving…" : "Schedule"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { api } from "@/api";
|
||||||
|
import type { PublicStatus } from "@/types";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
// Public status page — clean read-only view; refreshes every 30s.
|
||||||
|
// Route: /status/:slug -> GET /api/status-pages/:slug/public.
|
||||||
|
|
||||||
|
// status strings arrive as plain `string` — map with functions, not
|
||||||
|
// open dictionaries, so the lookup type stays honest.
|
||||||
|
function overallBanner(overall?: string) {
|
||||||
|
if (overall === "down") return { label: "Major outage", cls: "bg-down" };
|
||||||
|
if (overall === "maintenance") return { label: "Under maintenance", cls: "bg-text-faint/60" };
|
||||||
|
if (overall === "up") return { label: "All systems operational", cls: "bg-up" };
|
||||||
|
return { label: "Checking…", cls: "bg-text-faint/40" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function dotCls(status?: string) {
|
||||||
|
if (status === "up") return "bg-up";
|
||||||
|
if (status === "down") return "bg-down";
|
||||||
|
if (status === "maintenance") return "bg-text-faint/60";
|
||||||
|
return "bg-text-faint/40";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatusPublicPage({ slug }: { slug: string }) {
|
||||||
|
const [st, setSt] = useState<PublicStatus | null>(null);
|
||||||
|
const [missing, setMissing] = useState(false);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
const { data, response } = await api.GET("/api/status-pages/{slug}/public", { params: { path: { slug } } });
|
||||||
|
if (response.status === 404) setMissing(true);
|
||||||
|
if (data) setSt(data);
|
||||||
|
}, [slug]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
const t = setInterval(() => void load(), 30_000);
|
||||||
|
return () => clearInterval(t);
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
if (missing) return <main className="mx-auto max-w-2xl px-7 py-16 text-center text-[13px] text-text-faint">Status page not found.</main>;
|
||||||
|
if (!st) return <main className="mx-auto max-w-2xl px-7 py-16 text-center text-[13px] text-text-faint">Loading…</main>;
|
||||||
|
|
||||||
|
const banner = overallBanner(st.overall);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="mx-auto w-full max-w-2xl px-7 py-10">
|
||||||
|
<div className="mb-6 text-center">
|
||||||
|
<h1 className="text-[17px] font-semibold tracking-tight">{st.title}</h1>
|
||||||
|
{st.description ? <p className="mt-1 text-[12px] text-text-faint">{st.description}</p> : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={cn("mb-8 rounded-[10px] px-5 py-3.5 text-center text-[13px] font-medium text-white", banner.cls)}>
|
||||||
|
{banner.label}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(st.monitors ?? []).length > 0 && (
|
||||||
|
<section className="mb-6">
|
||||||
|
<h2 className="mb-2 font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">Services</h2>
|
||||||
|
<div className="overflow-hidden rounded-[10px] border border-border">
|
||||||
|
{(st.monitors ?? []).map((m) => (
|
||||||
|
<div key={m.id} className="flex items-center gap-3 border-b border-border/60 px-4 py-2.5 last:border-0">
|
||||||
|
<span className={cn("size-1.5 shrink-0 rounded-full", dotCls(m.status))} />
|
||||||
|
<span className="min-w-0 flex-1 truncate text-[13px]">{m.name}</span>
|
||||||
|
<span className="font-mono text-[10.5px] text-text-faint">{m.uptime24h?.toFixed(1)}%</span>
|
||||||
|
<span className="w-20 text-right font-mono text-[10.5px] uppercase text-text-faint">{m.status}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(st.systems ?? []).length > 0 && (
|
||||||
|
<section className="mb-6">
|
||||||
|
<h2 className="mb-2 font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">Systems</h2>
|
||||||
|
<div className="overflow-hidden rounded-[10px] border border-border">
|
||||||
|
{(st.systems ?? []).map((s) => (
|
||||||
|
<div key={s.id} className="flex items-center gap-3 border-b border-border/60 px-4 py-2.5 last:border-0">
|
||||||
|
<span className={cn("size-1.5 shrink-0 rounded-full", dotCls(s.status))} />
|
||||||
|
<span className="min-w-0 flex-1 truncate text-[13px]">{s.name}</span>
|
||||||
|
<span className="font-mono text-[10.5px] uppercase text-text-faint">{s.status}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(st.maintenance ?? []).length > 0 && (
|
||||||
|
<section className="mb-6">
|
||||||
|
<h2 className="mb-2 font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">Maintenance</h2>
|
||||||
|
<div className="overflow-hidden rounded-[10px] border border-border">
|
||||||
|
{(st.maintenance ?? []).map((w) => (
|
||||||
|
<div key={w.id} className="flex items-center gap-3 border-b border-border/60 px-4 py-2.5 last:border-0">
|
||||||
|
<span className="min-w-0 flex-1 truncate text-[13px]">{w.title}</span>
|
||||||
|
<span className="font-mono text-[10.5px] text-text-faint">
|
||||||
|
{new Date(w.startsAt ?? "").toLocaleString()} → {new Date(w.endsAt ?? "").toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(st.incidents ?? []).length > 0 && (
|
||||||
|
<section className="mb-6">
|
||||||
|
<h2 className="mb-2 font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">Incidents</h2>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{(st.incidents ?? []).map((inc) => (
|
||||||
|
<div key={inc.id} className="rounded-[10px] border border-border px-4 py-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="min-w-0 flex-1 truncate text-[13px] font-medium">{inc.title}</span>
|
||||||
|
<span className="font-mono text-[10.5px] uppercase text-text-faint">{inc.status}</span>
|
||||||
|
</div>
|
||||||
|
{(inc.updates ?? []).slice(-3).map((u) => (
|
||||||
|
<p key={u.id} className="mt-1.5 text-[11.5px] text-text-faint">
|
||||||
|
<span className="font-mono text-[10px] uppercase">{u.status}</span> — {u.message}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="mt-10 text-center font-mono text-[10px] text-text-faint">powered by dash</p>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { Link } from "wouter";
|
||||||
|
import { ArrowLeft, Copy, Trash2 } from "lucide-react";
|
||||||
|
import { Area, AreaChart, CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
||||||
|
import { api } from "@/api";
|
||||||
|
import type { System, SystemStat } from "@/types";
|
||||||
|
import { agentCmd, fmtBytes, fmtUptime } from "@/pages/SystemsPage";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export function SystemDetailPage({ id }: { id: string }) {
|
||||||
|
const [sys, setSys] = useState<System | null>(null);
|
||||||
|
const [stats, setStats] = useState<SystemStat[]>([]);
|
||||||
|
const [hours, setHours] = useState(24);
|
||||||
|
const [notFound, setNotFound] = useState(false);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
const [{ data: s, response }, { data: st }] = await Promise.all([
|
||||||
|
api.GET("/api/systems/{id}", { params: { path: { id } } }),
|
||||||
|
api.GET("/api/systems/{id}/stats", { params: { path: { id }, query: { hours } } }),
|
||||||
|
]);
|
||||||
|
if (response.status === 404) setNotFound(true);
|
||||||
|
if (s) setSys(s);
|
||||||
|
if (st) setStats(st);
|
||||||
|
}, [id, hours]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
const t = setInterval(() => void load(), 15_000);
|
||||||
|
return () => clearInterval(t);
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
if (notFound) return (
|
||||||
|
<main className="mx-auto max-w-5xl px-7 py-8 text-[13px] text-text-faint">
|
||||||
|
<Link href="/systems" className="inline-flex items-center gap-1.5 hover:text-text"><ArrowLeft size={12} /> Systems</Link>
|
||||||
|
<p className="mt-4">System not found.</p>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
if (!sys) return <main className="mx-auto max-w-5xl px-7 py-8 text-[13px] text-text-faint">Loading…</main>;
|
||||||
|
|
||||||
|
const l = sys.latest;
|
||||||
|
const chart = stats.map((s) => ({
|
||||||
|
t: new Date(s.ts ?? "").toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }),
|
||||||
|
cpu: s.cpu,
|
||||||
|
memPct: s.memTotal ? ((s.memUsed ?? 0) / s.memTotal) * 100 : undefined,
|
||||||
|
rx: (s.netRx ?? 0) / 1024,
|
||||||
|
tx: (s.netTx ?? 0) / 1024,
|
||||||
|
load: s.load1,
|
||||||
|
}));
|
||||||
|
const axisTick = { fontSize: 10, fill: "var(--text-faint)" };
|
||||||
|
const tipStyle = { background: "var(--surface)", border: "1px solid var(--border)", borderRadius: 8, fontSize: 11 };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="mx-auto w-full max-w-5xl px-7 py-8">
|
||||||
|
<Link href="/systems" className="mb-4 inline-flex items-center gap-1.5 text-[12px] text-text-faint hover:text-text">
|
||||||
|
<ArrowLeft size={12} /> Systems
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="mb-6 flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<span className={cn("size-2 rounded-full",
|
||||||
|
sys.status === "up" ? "bg-up" : sys.status === "down" ? "bg-down" : "bg-text-faint/40")} />
|
||||||
|
<h1 className="text-[17px] font-semibold tracking-tight">{sys.name}</h1>
|
||||||
|
<span className="font-mono text-[10.5px] uppercase tracking-[0.08em] text-text-faint">{sys.status}</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 font-mono text-[11.5px] text-text-faint">
|
||||||
|
{sys.host ? `${sys.host} · ` : ""}{sys.os}/{sys.arch}
|
||||||
|
{sys.cpuModel ? ` · ${sys.cpuModel}` : ""}{sys.cores ? ` · ${sys.cores} cores` : ""}
|
||||||
|
{sys.lastSeenAt ? ` · seen ${new Date(sys.lastSeenAt).toLocaleTimeString()}` : " · never seen"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" size="sm" aria-label="Delete"
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm(`Delete system "${sys.name}"?`)) {
|
||||||
|
void api.DELETE("/api/systems/{id}", { params: { path: { id } } }).then(() => { location.href = "/systems"; });
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
<Trash2 size={12} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-6 grid grid-cols-4 gap-3 max-[720px]:grid-cols-2">
|
||||||
|
{[
|
||||||
|
["CPU", l?.cpu !== undefined ? `${l.cpu.toFixed(1)}%` : "—"],
|
||||||
|
["Memory", l?.memTotal ? `${fmtBytes(l.memUsed ?? 0)} / ${fmtBytes(l.memTotal)}` : "—"],
|
||||||
|
["Disk /", l?.diskTotal ? `${fmtBytes(l.diskUsed ?? 0)} / ${fmtBytes(l.diskTotal)}` : "—"],
|
||||||
|
["Uptime", l?.uptimeS ? fmtUptime(l.uptimeS) : "—"],
|
||||||
|
].map(([k, v]) => (
|
||||||
|
<div key={k} 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">{k}</div>
|
||||||
|
<div className="mt-1 font-mono text-[15px] font-medium">{v}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-2 flex items-center justify-between">
|
||||||
|
<h2 className="font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">Usage</h2>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{[1, 6, 24, 168].map((h) => (
|
||||||
|
<button key={h} type="button" onClick={() => setHours(h)}
|
||||||
|
className={cn("rounded-[6px] px-2 py-1 font-mono text-[10.5px]",
|
||||||
|
hours === h ? "bg-surface-hover text-text" : "text-text-faint hover:text-text")}>
|
||||||
|
{h === 1 ? "1h" : h === 6 ? "6h" : h === 24 ? "24h" : "7d"}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-6 grid grid-cols-2 gap-3 max-[720px]:grid-cols-1">
|
||||||
|
<div className="h-44 rounded-[10px] border border-border bg-surface p-3">
|
||||||
|
<div className="mb-1 font-mono text-[9.5px] uppercase tracking-[0.1em] text-text-faint">CPU %</div>
|
||||||
|
<ResponsiveContainer width="100%" height="85%">
|
||||||
|
<AreaChart data={chart} margin={{ top: 4, right: 4, bottom: 0, left: -22 }}>
|
||||||
|
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" vertical={false} />
|
||||||
|
<XAxis dataKey="t" tick={axisTick} tickLine={false} axisLine={false} minTickGap={40} />
|
||||||
|
<YAxis tick={axisTick} tickLine={false} axisLine={false} domain={[0, 100]} unit="%" width={48} />
|
||||||
|
<Tooltip contentStyle={tipStyle} labelStyle={{ color: "var(--text-faint)" }} />
|
||||||
|
<Area type="monotone" dataKey="cpu" stroke="var(--up)" fill="var(--up)" fillOpacity={0.12} strokeWidth={1.5} dot={false} isAnimationActive={false} />
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
<div className="h-44 rounded-[10px] border border-border bg-surface p-3">
|
||||||
|
<div className="mb-1 font-mono text-[9.5px] uppercase tracking-[0.1em] text-text-faint">Memory %</div>
|
||||||
|
<ResponsiveContainer width="100%" height="85%">
|
||||||
|
<AreaChart data={chart} margin={{ top: 4, right: 4, bottom: 0, left: -22 }}>
|
||||||
|
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" vertical={false} />
|
||||||
|
<XAxis dataKey="t" tick={axisTick} tickLine={false} axisLine={false} minTickGap={40} />
|
||||||
|
<YAxis tick={axisTick} tickLine={false} axisLine={false} domain={[0, 100]} unit="%" width={48} />
|
||||||
|
<Tooltip contentStyle={tipStyle} labelStyle={{ color: "var(--text-faint)" }} />
|
||||||
|
<Area type="monotone" dataKey="memPct" stroke="var(--up)" fill="var(--up)" fillOpacity={0.12} strokeWidth={1.5} dot={false} isAnimationActive={false} />
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
<div className="h-44 rounded-[10px] border border-border bg-surface p-3">
|
||||||
|
<div className="mb-1 font-mono text-[9.5px] uppercase tracking-[0.1em] text-text-faint">Network KB/s</div>
|
||||||
|
<ResponsiveContainer width="100%" height="85%">
|
||||||
|
<LineChart data={chart} margin={{ top: 4, right: 4, bottom: 0, left: -18 }}>
|
||||||
|
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" vertical={false} />
|
||||||
|
<XAxis dataKey="t" tick={axisTick} tickLine={false} axisLine={false} minTickGap={40} />
|
||||||
|
<YAxis tick={axisTick} tickLine={false} axisLine={false} width={58} />
|
||||||
|
<Tooltip contentStyle={tipStyle} labelStyle={{ color: "var(--text-faint)" }} />
|
||||||
|
<Line type="monotone" dataKey="rx" name="rx" stroke="var(--up)" strokeWidth={1.5} dot={false} isAnimationActive={false} />
|
||||||
|
<Line type="monotone" dataKey="tx" name="tx" stroke="var(--text-faint)" strokeWidth={1.5} dot={false} isAnimationActive={false} />
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
<div className="h-44 rounded-[10px] border border-border bg-surface p-3">
|
||||||
|
<div className="mb-1 font-mono text-[9.5px] uppercase tracking-[0.1em] text-text-faint">Load 1m</div>
|
||||||
|
<ResponsiveContainer width="100%" height="85%">
|
||||||
|
<AreaChart data={chart} margin={{ top: 4, right: 4, bottom: 0, left: -22 }}>
|
||||||
|
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" vertical={false} />
|
||||||
|
<XAxis dataKey="t" tick={axisTick} tickLine={false} axisLine={false} minTickGap={40} />
|
||||||
|
<YAxis tick={axisTick} tickLine={false} axisLine={false} width={48} />
|
||||||
|
<Tooltip contentStyle={tipStyle} labelStyle={{ color: "var(--text-faint)" }} />
|
||||||
|
<Area type="monotone" dataKey="load" stroke="var(--up)" fill="var(--up)" fillOpacity={0.12} strokeWidth={1.5} dot={false} isAnimationActive={false} />
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3 max-[720px]:grid-cols-1">
|
||||||
|
{l?.temps && Object.keys(l.temps).length > 0 && (
|
||||||
|
<div className="rounded-[10px] border border-border">
|
||||||
|
<h2 className="border-b border-border bg-surface px-4 py-2.5 font-mono text-[10px] uppercase tracking-[0.1em] text-text-faint">
|
||||||
|
Temperatures
|
||||||
|
</h2>
|
||||||
|
<table className="w-full text-[12px]">
|
||||||
|
<tbody>
|
||||||
|
{Object.entries(l.temps).map(([k, v]) => (
|
||||||
|
<tr key={k} className="border-b border-border/60 last:border-0">
|
||||||
|
<td className="px-4 py-2 text-text-dim">{k}</td>
|
||||||
|
<td className="px-4 py-2 text-right font-mono text-[11px]">{v.toFixed(1)}°C</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{l?.containers && l.containers.length > 0 && (
|
||||||
|
<div className="rounded-[10px] border border-border">
|
||||||
|
<h2 className="border-b border-border bg-surface px-4 py-2.5 font-mono text-[10px] uppercase tracking-[0.1em] text-text-faint">
|
||||||
|
Containers
|
||||||
|
</h2>
|
||||||
|
<table className="w-full text-[12px]">
|
||||||
|
<tbody>
|
||||||
|
{l.containers.map((ctr) => (
|
||||||
|
<tr key={ctr.name} className="border-b border-border/60 last:border-0">
|
||||||
|
<td className="px-4 py-2 font-mono text-[11px] text-text-dim">{ctr.name}</td>
|
||||||
|
<td className="px-4 py-2 text-right">
|
||||||
|
<span className={cn("font-mono text-[10.5px] uppercase",
|
||||||
|
ctr.state === "running" ? "text-up" : "text-text-faint")}>
|
||||||
|
{ctr.state}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 rounded-[10px] border border-border bg-surface px-4 py-3">
|
||||||
|
<div className="mb-1.5 font-mono text-[10px] uppercase tracking-[0.1em] text-text-faint">Agent command</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="flex-1 overflow-x-auto whitespace-nowrap font-mono text-[11px] text-text-dim">
|
||||||
|
{agentCmd(sys)}
|
||||||
|
</code>
|
||||||
|
<Button variant="outline" size="icon" aria-label="Copy"
|
||||||
|
onClick={() => void navigator.clipboard.writeText(agentCmd(sys))}>
|
||||||
|
<Copy size={12} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { Link } from "wouter";
|
||||||
|
import { Copy, Plus, Trash2 } from "lucide-react";
|
||||||
|
import { api } from "@/api";
|
||||||
|
import type { System } from "@/types";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const TB = 2 ** 40;
|
||||||
|
const GB = 2 ** 30;
|
||||||
|
const MB = 2 ** 20;
|
||||||
|
|
||||||
|
export function fmtBytes(b: number) {
|
||||||
|
if (b >= TB) return `${(b / TB).toFixed(1)} TB`;
|
||||||
|
if (b >= GB) return `${(b / GB).toFixed(1)} GB`;
|
||||||
|
return `${(b / MB).toFixed(0)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fmtUptime(s: number) {
|
||||||
|
const d = Math.floor(s / 86400);
|
||||||
|
if (d > 0) return `${d}d ${Math.floor((s % 86400) / 3600)}h`;
|
||||||
|
const h = Math.floor(s / 3600);
|
||||||
|
if (h > 0) return `${h}h ${Math.floor((s % 3600) / 60)}m`;
|
||||||
|
return `${Math.floor(s / 60)}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Bar({ pct, warnAt = 90 }: { pct: number | undefined; warnAt?: number }) {
|
||||||
|
return (
|
||||||
|
<span className="h-1.5 w-full overflow-hidden rounded-full bg-surface-hover">
|
||||||
|
<span
|
||||||
|
className={cn("block h-full rounded-full", pct !== undefined && pct > warnAt ? "bg-down" : "bg-up")}
|
||||||
|
style={{ width: `${Math.min(pct ?? 0, 100)}%` }}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The one-liner agents run; shown on create and on the detail page. */
|
||||||
|
export function agentCmd(sys: System) {
|
||||||
|
const base = location.origin;
|
||||||
|
return `DASH_URL=${base} DASH_TOKEN=${sys.token} dash-agent`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SystemsPage() {
|
||||||
|
const [systems, setSystems] = useState<System[]>([]);
|
||||||
|
const [loaded, setLoaded] = useState(false);
|
||||||
|
const [dlgOpen, setDlgOpen] = useState(false);
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [created, setCreated] = useState<System | null>(null);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
const { data } = await api.GET("/api/systems");
|
||||||
|
if (data) setSystems(data);
|
||||||
|
setLoaded(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
const t = setInterval(() => void load(), 15_000);
|
||||||
|
return () => clearInterval(t);
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
async function create() {
|
||||||
|
if (!name.trim()) return;
|
||||||
|
setBusy(true);
|
||||||
|
const { data } = await api.POST("/api/systems", { body: { name: name.trim() } });
|
||||||
|
setBusy(false);
|
||||||
|
if (data) {
|
||||||
|
setCreated(data);
|
||||||
|
setName("");
|
||||||
|
void load();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(id: string | undefined, n: string | undefined) {
|
||||||
|
if (!id || !confirm(`Delete system "${n}"?`)) return;
|
||||||
|
await api.DELETE("/api/systems/{id}", { params: { path: { id } } });
|
||||||
|
await load();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="mx-auto w-full max-w-5xl px-7 py-8">
|
||||||
|
<div className="mb-5 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-[15px] font-semibold tracking-tight">Systems</h1>
|
||||||
|
<p className="text-[12px] text-text-faint">Servers and PCs reporting via dash-agent.</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => { setCreated(null); setDlgOpen(true); }}>
|
||||||
|
<Plus size={13} strokeWidth={2.2} /> New system
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loaded && systems.length === 0 ? (
|
||||||
|
<div className="rounded-[10px] border border-dashed border-border px-6 py-14 text-center">
|
||||||
|
<p className="text-[13px] text-text-dim">No systems yet.</p>
|
||||||
|
<p className="mt-1 text-[11.5px] text-text-faint">
|
||||||
|
Add one, then run <code className="font-mono">dash-agent</code> on the host with the issued token.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-3 gap-3 max-[900px]:grid-cols-2 max-[560px]:grid-cols-1">
|
||||||
|
{systems.map((sys) => {
|
||||||
|
const l = sys.latest;
|
||||||
|
const memPct = l?.memTotal ? ((l.memUsed ?? 0) / l.memTotal) * 100 : undefined;
|
||||||
|
const diskPct = l?.diskTotal ? ((l.diskUsed ?? 0) / l.diskTotal) * 100 : undefined;
|
||||||
|
const rows: [string, number | undefined, string][] = [
|
||||||
|
["cpu", l?.cpu, l?.cpu !== undefined ? `${l.cpu.toFixed(0)}%` : "—"],
|
||||||
|
["mem", memPct, l?.memTotal ? `${fmtBytes(l.memUsed ?? 0)} / ${fmtBytes(l.memTotal)}` : "—"],
|
||||||
|
["disk", diskPct, l?.diskTotal ? `${fmtBytes(l.diskUsed ?? 0)} / ${fmtBytes(l.diskTotal)}` : "—"],
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<div key={sys.id} className="rounded-[10px] border border-border bg-surface px-4 py-3.5">
|
||||||
|
<div className="mb-2.5 flex items-center gap-2">
|
||||||
|
<span className={cn("size-1.5 shrink-0 rounded-full",
|
||||||
|
sys.status === "up" ? "bg-up" : sys.status === "down" ? "bg-down" : "bg-text-faint/40")} />
|
||||||
|
<Link href={`/systems/${sys.id}`} className="min-w-0 flex-1 truncate text-[13px] font-medium hover:underline">
|
||||||
|
{sys.name}
|
||||||
|
</Link>
|
||||||
|
<Button variant="outline" size="icon" aria-label="Delete"
|
||||||
|
onClick={() => void remove(sys.id, sys.name)}>
|
||||||
|
<Trash2 size={11} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{sys.host ? (
|
||||||
|
<p className="mb-2.5 truncate font-mono text-[10.5px] text-text-faint">
|
||||||
|
{sys.host} · {sys.cpuModel || `${sys.cores} cores`}
|
||||||
|
{l?.uptimeS ? ` · up ${fmtUptime(l.uptimeS)}` : ""}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="mb-2.5 font-mono text-[10.5px] text-text-faint">waiting for agent…</p>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
{rows.map(([label, pct, txt]) => (
|
||||||
|
<div key={label} className="flex items-center gap-2">
|
||||||
|
<span className="w-7 font-mono text-[9.5px] uppercase text-text-faint">{label}</span>
|
||||||
|
<span className="flex-1"><Bar pct={pct} /></span>
|
||||||
|
<span className="w-24 text-right font-mono text-[10px] text-text-dim">{txt}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Dialog open={dlgOpen} onOpenChange={setDlgOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>New system</DialogTitle>
|
||||||
|
<DialogDescription>Name the host, then run dash-agent on it with the issued token.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
{created ? (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<p className="text-[12px] text-text-dim">
|
||||||
|
Agent command for <span className="font-medium text-text">{created.name}</span>:
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="flex-1 overflow-x-auto whitespace-nowrap rounded-[7px] border border-border bg-bg px-3 py-2 font-mono text-[11px]">
|
||||||
|
{agentCmd(created)}
|
||||||
|
</code>
|
||||||
|
<Button variant="outline" size="icon" aria-label="Copy"
|
||||||
|
onClick={() => void navigator.clipboard.writeText(agentCmd(created))}>
|
||||||
|
<Copy size={12} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-text-faint">
|
||||||
|
The token identifies this system — keep it out of public repos.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="sys-name">Name</Label>
|
||||||
|
<Input id="sys-name" value={name} onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="nas" autoFocus
|
||||||
|
onKeyDown={(e) => { if (e.key === "Enter") void create(); }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<DialogFooter>
|
||||||
|
{created ? (
|
||||||
|
<Button onClick={() => setDlgOpen(false)}>Done</Button>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Button variant="outline" onClick={() => setDlgOpen(false)} disabled={busy}>Cancel</Button>
|
||||||
|
<Button onClick={() => void create()} disabled={busy || !name.trim()}>
|
||||||
|
{busy ? "Creating…" : "Create"}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,6 +11,25 @@ export type Status = components["schemas"]["Status"];
|
|||||||
export type StatusMap = Record<string, Status>;
|
export type StatusMap = Record<string, Status>;
|
||||||
export type ExportPayload = components["schemas"]["Export"];
|
export type ExportPayload = components["schemas"]["Export"];
|
||||||
|
|
||||||
|
// Monitoring (merge program) — same generated-schema rule applies.
|
||||||
|
export type Monitor = components["schemas"]["Monitor"];
|
||||||
|
export type MonitorInput = components["schemas"]["MonitorInput"];
|
||||||
|
export type MonitorView = components["schemas"]["MonitorView"];
|
||||||
|
export type Heartbeat = components["schemas"]["Heartbeat"];
|
||||||
|
export type Domain = components["schemas"]["Domain"];
|
||||||
|
export type DomainInput = components["schemas"]["DomainInput"];
|
||||||
|
export type DomainView = components["schemas"]["DomainView"];
|
||||||
|
export type DomainCheck = components["schemas"]["DomainCheck"];
|
||||||
|
export type System = components["schemas"]["System"];
|
||||||
|
export type StatSample = components["schemas"]["StatSample"];
|
||||||
|
/** Stats rows are StatSample plus the server-side timestamp. */
|
||||||
|
export type SystemStat = StatSample & { ts?: string };
|
||||||
|
export type Incident = components["schemas"]["Incident"];
|
||||||
|
export type IncidentInput = components["schemas"]["IncidentInput"];
|
||||||
|
export type MaintenanceWindow = components["schemas"]["MaintenanceWindow"];
|
||||||
|
export type StatusPage = components["schemas"]["StatusPage"];
|
||||||
|
export type PublicStatus = components["schemas"]["PublicStatus"];
|
||||||
|
|
||||||
export type Theme = "dark" | "light";
|
export type Theme = "dark" | "light";
|
||||||
export type RendererName = "bento" | "cards" | "index" | "console";
|
export type RendererName = "bento" | "cards" | "index" | "console";
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import type { WidgetData } from "@/widgets";
|
||||||
|
import type { Item } from "@/types";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
// Board tile for a tracked domain: favicon, name, expiry countdown.
|
||||||
|
export function DomainWidget({ item, data, error }: {
|
||||||
|
item: Item;
|
||||||
|
data: WidgetData | undefined;
|
||||||
|
error: string | undefined;
|
||||||
|
}) {
|
||||||
|
const days = typeof data?.daysUntilExpiry === "number" ? data.daysUntilExpiry : undefined;
|
||||||
|
const sslDays = typeof data?.sslDaysUntilExpiry === "number" ? data.sslDaysUntilExpiry : undefined;
|
||||||
|
const expiring = data?.expiring === true;
|
||||||
|
const domainId = typeof item.config?.domainId === "string" ? item.config.domainId : "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={domainId ? `/domains/${domainId}` : "/domains"}
|
||||||
|
className="flex w-full items-center gap-2.5 text-left"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{typeof data?.faviconUrl === "string" && data.faviconUrl ? (
|
||||||
|
<img src={data.faviconUrl} alt="" className="size-4 shrink-0 rounded-sm" loading="lazy" />
|
||||||
|
) : null}
|
||||||
|
<span className="min-w-0 flex-1 truncate text-[12.5px] font-medium">
|
||||||
|
{typeof data?.name === "string" ? data.name : item.name}
|
||||||
|
</span>
|
||||||
|
<span className={cn(
|
||||||
|
"shrink-0 font-mono text-[10.5px]",
|
||||||
|
error ? "text-down" : expiring || (days !== undefined && days < 0) ? "text-down" : "text-text-faint",
|
||||||
|
)}>
|
||||||
|
{error ? "err" : days === undefined ? "…" : days < 0 ? "expired" : `${days}d`}
|
||||||
|
{sslDays !== undefined && sslDays <= 30 ? ` · ssl ${sslDays}d` : ""}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,9 @@ import type { Item } from "@/types";
|
|||||||
import { ClockWidget } from "@/widgets/clock";
|
import { ClockWidget } from "@/widgets/clock";
|
||||||
import { PiholeWidget, AdguardWidget } from "@/widgets/blocker";
|
import { PiholeWidget, AdguardWidget } from "@/widgets/blocker";
|
||||||
import { ImmichWidget } from "@/widgets/immich";
|
import { ImmichWidget } from "@/widgets/immich";
|
||||||
|
import { MonitorWidget } from "@/widgets/monitor";
|
||||||
|
import { DomainWidget } from "@/widgets/domain";
|
||||||
|
import { SystemWidget } from "@/widgets/system";
|
||||||
|
|
||||||
// WidgetHost seam: renderers own the tile chrome; this maps config.type to
|
// WidgetHost seam: renderers own the tile chrome; this maps config.type to
|
||||||
// the component that fills it. Adding a widget = one fetcher in Go + one
|
// the component that fills it. Adding a widget = one fetcher in Go + one
|
||||||
@@ -28,6 +31,9 @@ const registry: WidgetRegistry = {
|
|||||||
pihole: { view: PiholeWidget },
|
pihole: { view: PiholeWidget },
|
||||||
adguard: { view: AdguardWidget },
|
adguard: { view: AdguardWidget },
|
||||||
immich: { view: ImmichWidget },
|
immich: { view: ImmichWidget },
|
||||||
|
monitor: { view: MonitorWidget },
|
||||||
|
domain: { view: DomainWidget },
|
||||||
|
system: { view: SystemWidget },
|
||||||
};
|
};
|
||||||
|
|
||||||
export function cfgStr(item: Item, key: string): string {
|
export function cfgStr(item: Item, key: string): string {
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import type { WidgetData } from "@/widgets";
|
||||||
|
import type { Item } from "@/types";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
// Board tile for an uptime monitor: status dot, uptime %, avg ping.
|
||||||
|
// config.monitorId binds it; data comes from the monitor fetcher.
|
||||||
|
export function MonitorWidget({ item, data, error }: {
|
||||||
|
item: Item;
|
||||||
|
data: WidgetData | undefined;
|
||||||
|
error: string | undefined;
|
||||||
|
}) {
|
||||||
|
const status = typeof data?.status === "string" ? data.status : undefined;
|
||||||
|
const uptime = typeof data?.uptime24 === "number" ? data.uptime24 : undefined;
|
||||||
|
const ping = typeof data?.ping === "number" ? data.ping : undefined;
|
||||||
|
const monId = typeof item.config?.monitorId === "string" ? item.config.monitorId : "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={monId ? `/monitors/${monId}` : "/monitors"}
|
||||||
|
className="flex w-full items-center gap-2.5 text-left"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"size-1.5 shrink-0 rounded-full",
|
||||||
|
error ? "bg-down" : status === "up" ? "bg-up" : status === "down" ? "bg-down" : "bg-text-faint/40",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span className="min-w-0 flex-1 truncate text-[12.5px] font-medium">
|
||||||
|
{typeof data?.name === "string" ? data.name : item.name}
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 font-mono text-[10.5px] text-text-faint">
|
||||||
|
{error ? "err" : uptime !== undefined ? `${uptime.toFixed(1)}%` : "…"}
|
||||||
|
{ping ? ` · ${Math.round(ping)}ms` : ""}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import type { WidgetData } from "@/widgets";
|
||||||
|
import type { Item } from "@/types";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
// Board tile for a monitored system: status dot + cpu/mem/disk mini bars.
|
||||||
|
// config.systemId binds it; data comes from the system fetcher.
|
||||||
|
export function SystemWidget({ item, data, error }: {
|
||||||
|
item: Item;
|
||||||
|
data: WidgetData | undefined;
|
||||||
|
error: string | undefined;
|
||||||
|
}) {
|
||||||
|
const status = typeof data?.status === "string" ? data.status : undefined;
|
||||||
|
const sysId = typeof item.config?.systemId === "string" ? item.config.systemId : "";
|
||||||
|
const cpu = typeof data?.cpu === "number" ? data.cpu : undefined;
|
||||||
|
const memTotal = typeof data?.memTotal === "number" ? data.memTotal : 0;
|
||||||
|
const memUsed = typeof data?.memUsed === "number" ? data.memUsed : 0;
|
||||||
|
const diskTotal = typeof data?.diskTotal === "number" ? data.diskTotal : 0;
|
||||||
|
const diskUsed = typeof data?.diskUsed === "number" ? data.diskUsed : 0;
|
||||||
|
const memPct = memTotal > 0 ? (memUsed / memTotal) * 100 : undefined;
|
||||||
|
const diskPct = diskTotal > 0 ? (diskUsed / diskTotal) * 100 : undefined;
|
||||||
|
|
||||||
|
const bar = (pct: number | undefined, label: string) => (
|
||||||
|
<span className="flex items-center gap-1" title={`${label}: ${pct !== undefined ? pct.toFixed(0) + "%" : "—"}`}>
|
||||||
|
<span className="font-mono text-[9px] uppercase text-text-faint">{label}</span>
|
||||||
|
<span className="h-1 w-8 overflow-hidden rounded-full bg-surface-hover">
|
||||||
|
<span
|
||||||
|
className={cn("block h-full rounded-full", pct !== undefined && pct > 90 ? "bg-down" : "bg-up")}
|
||||||
|
style={{ width: `${Math.min(pct ?? 0, 100)}%` }}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={sysId ? `/systems/${sysId}` : "/systems"}
|
||||||
|
className="flex w-full items-center gap-2.5 text-left"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"size-1.5 shrink-0 rounded-full",
|
||||||
|
error ? "bg-down" : status === "up" ? "bg-up" : status === "down" ? "bg-down" : "bg-text-faint/40",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span className="min-w-0 flex-1 truncate text-[12.5px] font-medium">
|
||||||
|
{typeof data?.name === "string" ? data.name : item.name}
|
||||||
|
</span>
|
||||||
|
{error ? (
|
||||||
|
<span className="shrink-0 font-mono text-[10.5px] text-text-faint">err</span>
|
||||||
|
) : (
|
||||||
|
<span className="flex shrink-0 items-center gap-2">
|
||||||
|
{bar(cpu, "cpu")}
|
||||||
|
{bar(memPct, "mem")}
|
||||||
|
{bar(diskPct, "dsk")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# v1.0.0 launch post — draft
|
||||||
|
|
||||||
|
For r/selfhosted, HN Show, and similar. Edit freely before posting.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Title:** Dash — a self-hosted homelab dashboard with monitoring built in.
|
||||||
|
Single Go binary, no YAML, no auth.
|
||||||
|
|
||||||
|
**Body:**
|
||||||
|
|
||||||
|
I built Dash because my homelab had outgrown a bookmark page: I wanted service
|
||||||
|
links, uptime checks, domain expiry, server stats, and a status page — without
|
||||||
|
running five separate tools or maintaining a YAML file.
|
||||||
|
|
||||||
|
What it does:
|
||||||
|
|
||||||
|
- **Board** — drag-and-drop service grid, multiple layouts, dark/light, icon
|
||||||
|
upload. Imports your existing Homepage (`services.yml`), Homarr, or Dashy
|
||||||
|
config in one click.
|
||||||
|
- **Monitors** — HTTP/TCP/ping/DNS/keyword/JSON-path checks plus push monitors
|
||||||
|
for cron jobs. Webhook alerts (Slack/Discord/ntfy). Uptime history.
|
||||||
|
- **Domains** — WHOIS/RDAP, DNS, TLS certificate expiry, provider detection.
|
||||||
|
Inspired by Domain Locker, minus the paid APIs.
|
||||||
|
- **Systems** — a ~4 MB static agent pushes CPU/mem/disk/net/temps/Docker
|
||||||
|
container states. Inspired by Beszel, push-JSON instead of SSH.
|
||||||
|
- **Ops** — incidents, maintenance windows, public status pages, SVG badges,
|
||||||
|
Prometheus `/api/metrics`.
|
||||||
|
|
||||||
|
Deployment is one container:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker run -d -v ./data:/data -p 3000:3000 ghcr.io/dvorinka/dash:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
SQLite inside, no database to babysit, no accounts. Deliberately no auth —
|
||||||
|
it expects a trusted LAN or your own proxy in front.
|
||||||
|
|
||||||
|
Stack: Go + Gin + SQLite (single static binary serving an embedded React UI).
|
||||||
|
MIT licensed.
|
||||||
|
|
||||||
|
Repo: https://github.com/Dvorinka/Dash
|
||||||
|
Screenshots are in the README.
|
||||||
|
|
||||||
|
Happy to answer questions — feedback welcome, especially on which monitor
|
||||||
|
types or integrations to add next.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**HN variant (Show HN guidelines — terse):**
|
||||||
|
|
||||||
|
Show HN: Dash – self-hosted homelab dashboard with monitoring, one Go binary
|
||||||
|
|
||||||
|
Dash combines a service board, uptime monitors, domain/SSL watching, system
|
||||||
|
metrics via a tiny push agent, and public status pages. Single container or
|
||||||
|
single binary, SQLite storage, no YAML, no auth (LAN-trust model). MIT.
|
||||||
|
https://github.com/Dvorinka/Dash
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# Configuration
|
||||||
|
|
||||||
|
Dash is configured from the UI. The only environment the server understands:
|
||||||
|
|
||||||
|
| Variable | Default | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `DASH_PORT` | `8080` (`3000` in Docker) | listen port |
|
||||||
|
| `DASH_DATA_DIR` | `./data` (`/data` in Docker) | SQLite + uploaded icons |
|
||||||
|
| `DASH_DEV` | unset | `1` = dev mode: API only, no embedded UI |
|
||||||
|
|
||||||
|
## Data directory
|
||||||
|
|
||||||
|
```
|
||||||
|
data/
|
||||||
|
dash.db # SQLite — boards, monitors, domains, systems, ops
|
||||||
|
icons/ # uploaded service icons
|
||||||
|
```
|
||||||
|
|
||||||
|
Back up the directory to back up everything. `/api/export` produces a JSON
|
||||||
|
snapshot; `/api/import/csv` bulk-loads monitors and domains from CSV.
|
||||||
|
|
||||||
|
## Notifications
|
||||||
|
|
||||||
|
**Settings → Notifications** holds one generic webhook URL
|
||||||
|
(`notify_webhook`). On monitor up/down transitions and domain events, Dash
|
||||||
|
POSTs a JSON payload with `event`, `name`, `text`, `content`, `message`, and
|
||||||
|
`at` fields — shaped so Slack, Discord, and ntfy all render it sensibly.
|
||||||
|
**Test** sends a `test` event. Delivery is async and non-fatal; failures are
|
||||||
|
logged.
|
||||||
|
|
||||||
|
## Monitors
|
||||||
|
|
||||||
|
HTTP(s), TCP, ping (ICMP), DNS, keyword, JSON-path, and push types. Per-monitor
|
||||||
|
interval, timeout, retries, headers/method/body, expected values, tags, and
|
||||||
|
notes. Push monitors expose `POST /api/push/:token` for cron-style jobs — the
|
||||||
|
monitor goes down if no push arrives within the expected window.
|
||||||
|
|
||||||
|
## Domains
|
||||||
|
|
||||||
|
WHOIS (RDAP first, TCP WHOIS fallback), DNS records, TLS certificate chain,
|
||||||
|
and provider detection run daily and on demand. Expiry and certificate
|
||||||
|
deadlines surface in the UI and through notifications.
|
||||||
|
|
||||||
|
## Systems
|
||||||
|
|
||||||
|
Bearer-token push ingest at `POST /api/systems/ingest`. A system is marked
|
||||||
|
offline when no sample arrives within a few intervals. Stats are pruned during
|
||||||
|
the hourly sweep.
|
||||||
|
|
||||||
|
## Operations
|
||||||
|
|
||||||
|
- **Incidents** — open/ack/resolve/close, with per-incident update timeline.
|
||||||
|
A monitor going down opens an incident automatically; recovery resolves it.
|
||||||
|
- **Maintenance windows** — suppress monitor alerts for the window's scope.
|
||||||
|
- **Status pages** — public, unauthenticated views at `/status/:slug` listing
|
||||||
|
selected monitors and systems with live state.
|
||||||
|
- **Badges** — `GET /api/badge/monitor/:id.svg` and
|
||||||
|
`GET /api/badge/domain/:id.svg` for embedding status shields.
|
||||||
|
- **Metrics** — `GET /api/metrics` in Prometheus exposition format.
|
||||||
|
|
||||||
|
## Importers
|
||||||
|
|
||||||
|
Settings → Import accepts:
|
||||||
|
|
||||||
|
- Dash JSON export (full board restore)
|
||||||
|
- Homepage `services.yml`
|
||||||
|
- Homarr board JSON
|
||||||
|
- Dashy `conf.yml`
|
||||||
|
- CSV of monitors/domains (`/api/import/csv`)
|
||||||
|
|
||||||
|
Foreign formats map onto sections, items, and URLs; unknown fields are dropped.
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
# Installation
|
||||||
|
|
||||||
|
Dash ships as a single container image or a single static binary. All state
|
||||||
|
lives in one directory: a SQLite database and uploaded icons.
|
||||||
|
|
||||||
|
## Docker (recommended)
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker run -d --name dash \
|
||||||
|
-v ./data:/data \
|
||||||
|
-p 3000:3000 \
|
||||||
|
--restart unless-stopped \
|
||||||
|
ghcr.io/dvorinka/dash:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
Or compose:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
dash:
|
||||||
|
image: ghcr.io/dvorinka/dash:latest
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
volumes:
|
||||||
|
- ./data:/data
|
||||||
|
restart: unless-stopped
|
||||||
|
```
|
||||||
|
|
||||||
|
Open http://localhost:3000.
|
||||||
|
|
||||||
|
To build the image locally instead of pulling:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker compose up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Binary
|
||||||
|
|
||||||
|
Release archives contain a static `dash` binary (no CGO, no dependencies).
|
||||||
|
|
||||||
|
```sh
|
||||||
|
tar xzf dash_linux_amd64.tar.gz
|
||||||
|
./dash # listens on :8080, stores data in ./data
|
||||||
|
```
|
||||||
|
|
||||||
|
Set `DASH_PORT` and `DASH_DATA_DIR` to change the defaults.
|
||||||
|
|
||||||
|
## dash-agent (system monitoring)
|
||||||
|
|
||||||
|
The agent collects CPU, memory, disk, network, load, temperatures, and Docker
|
||||||
|
container state, then pushes a JSON sample to Dash every few seconds.
|
||||||
|
|
||||||
|
1. In the UI, go to **Systems → New system**, copy the generated token.
|
||||||
|
2. Download the `dash-agent` binary for the host's architecture from the
|
||||||
|
release page, or build it:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd apps/backend && go build -o dash-agent ./cmd/dash-agent
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Run it:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
dash-agent -url http://dash:3000 -token <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Flags / environment
|
||||||
|
|
||||||
|
| Flag | Env | Default | Purpose |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `-url` | `DASH_URL` | — | Dash base URL |
|
||||||
|
| `-token` | `DASH_TOKEN` | — | system token from the UI |
|
||||||
|
| `-interval` | `DASH_INTERVAL` | `10` | push interval, seconds |
|
||||||
|
| `-once` | — | `false` | print one sample and exit (no push) |
|
||||||
|
|
||||||
|
If `/var/run/docker.sock` exists, container states are included automatically.
|
||||||
|
|
||||||
|
### systemd
|
||||||
|
|
||||||
|
`packaging/dash-agent.service` is a ready-made unit:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo install -m755 dash-agent /usr/local/bin/dash-agent
|
||||||
|
sudo install -m644 packaging/dash-agent.service /etc/systemd/system/
|
||||||
|
sudo mkdir -p /etc/dash
|
||||||
|
printf 'DASH_URL=http://dash:3000\nDASH_TOKEN=<token>\n' | sudo tee /etc/dash/agent.env
|
||||||
|
sudo systemctl enable --now dash-agent
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker (limited)
|
||||||
|
|
||||||
|
`infra/Dockerfile.agent` builds a minimal agent image. A containerized agent
|
||||||
|
only sees its own `/proc` — mount the host's procfs and use host PID mode for
|
||||||
|
meaningful numbers:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker run -d --name dash-agent --pid=host \
|
||||||
|
-v /proc:/host/proc:ro \
|
||||||
|
-v /var/run/docker.sock:/var/run/docker.sock:ro \
|
||||||
|
-e DASH_URL=http://dash:3000 -e DASH_TOKEN=<token> \
|
||||||
|
ghcr.io/dvorinka/dash-agent:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
The systemd install above is the recommended path.
|
||||||
|
|
||||||
|
## Reverse proxy
|
||||||
|
|
||||||
|
Dash serves the UI and API on one port — a plain reverse proxy works:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:3000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Upgrading
|
||||||
|
|
||||||
|
Pull the new image and restart, or replace the binary. Goose migrations run
|
||||||
|
automatically on boot; `data/` carries forward.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker pull ghcr.io/dvorinka/dash:latest && docker compose up -d
|
||||||
|
```
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 39 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
@@ -0,0 +1,89 @@
|
|||||||
|
# Widget development
|
||||||
|
|
||||||
|
A widget is two pieces:
|
||||||
|
|
||||||
|
1. a Go **fetcher** in `apps/backend/internal/widget/` that declares config
|
||||||
|
fields and pulls live data, and
|
||||||
|
2. a React **view** in `apps/frontend/src/widgets/` that renders the payload
|
||||||
|
inside a tile.
|
||||||
|
|
||||||
|
Renderers own the tile chrome — widgets never touch layout, drag-drop, or
|
||||||
|
popovers.
|
||||||
|
|
||||||
|
## Backend fetcher
|
||||||
|
|
||||||
|
One file, registered in `init()`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
package widget
|
||||||
|
|
||||||
|
type pihole struct{}
|
||||||
|
|
||||||
|
func init() { Register(pihole{}) }
|
||||||
|
|
||||||
|
func (pihole) Meta() Type {
|
||||||
|
return Type{
|
||||||
|
Type: "pihole",
|
||||||
|
Name: "Pi-hole",
|
||||||
|
Description: "Queries and block rate",
|
||||||
|
Fields: []Field{
|
||||||
|
{Key: "endpoint", Label: "Endpoint", Placeholder: "http://pi.hole", Required: true},
|
||||||
|
{Key: "token", Label: "API token", Secret: true},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pihole) Fetch(ctx context.Context, cfg json.RawMessage) (any, error) {
|
||||||
|
// cfg holds the item's config JSON. Never log it — it may hold keys.
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `Meta()` drives the add-widget dialog: `GET /api/widgets/types` returns
|
||||||
|
every registered type with its fields.
|
||||||
|
- `Fetch` is called by `GET /api/widgets/:id/data`; responses are cached ~30s
|
||||||
|
in-process. Return any JSON-marshalable value — it reaches the frontend as
|
||||||
|
`data`.
|
||||||
|
- Read config with the `cfgStr`/`cfgNum` helpers in the package.
|
||||||
|
- Fetchers needing the database (monitor, domain, system) live in
|
||||||
|
`internal/api` and register via `widget.Register` at router setup — the
|
||||||
|
`widget` package stays a leaf with no DB dependency.
|
||||||
|
|
||||||
|
## Frontend view
|
||||||
|
|
||||||
|
One component in `apps/frontend/src/widgets/`, one line in the registry at
|
||||||
|
`src/widgets/index.tsx`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
export function PiholeWidget({ data, error }: { item: Item; data?: WidgetData; error?: string }) {
|
||||||
|
if (error) return <TileError msg={error} />;
|
||||||
|
if (!data) return <TileLoading />;
|
||||||
|
return <div>{data.blockedPercent}%</div>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const registry: WidgetRegistry = {
|
||||||
|
pihole: { view: PiholeWidget },
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
- `useWidgetData` polls `/api/widgets/:id/data` every 30s. `data` is the
|
||||||
|
fetcher's payload, `error` is a human-readable upstream failure.
|
||||||
|
- Payloads are free-form; read fields defensively (values may be absent on
|
||||||
|
first paint).
|
||||||
|
- `local: true` marks a widget as frontend-only (e.g. clock) — no fetcher, no
|
||||||
|
polling.
|
||||||
|
|
||||||
|
## Config fields
|
||||||
|
|
||||||
|
`Fields` in `Meta()` render inputs in the add-widget dialog. `Secret: true`
|
||||||
|
masks the input. Config is stored in `items.config` JSON, sent back to the
|
||||||
|
fetcher on every poll, and never logged.
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
- [ ] `apps/backend/internal/widget/<name>.go` — fetcher + `init()` + `Meta()`
|
||||||
|
- [ ] `apps/frontend/src/widgets/<name>.tsx` — view component
|
||||||
|
- [ ] registry entry in `src/widgets/index.tsx`
|
||||||
|
- [ ] error and empty-data states render cleanly in the tile
|
||||||
|
- [ ] upstream timeouts bounded (respect `ctx`, keep HTTP client timeouts)
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
|
# Minimal dash-agent image. A containerized agent only sees its own /proc —
|
||||||
|
# run with --pid=host and -v /proc:/host/proc for host metrics, or prefer the
|
||||||
|
# systemd unit in packaging/.
|
||||||
|
FROM golang:1.27-alpine AS build
|
||||||
|
WORKDIR /src
|
||||||
|
COPY apps/backend/go.mod apps/backend/go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY apps/backend ./
|
||||||
|
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/dash-agent ./cmd/dash-agent
|
||||||
|
|
||||||
|
FROM gcr.io/distroless/static-debian12
|
||||||
|
COPY --from=build /out/dash-agent /dash-agent
|
||||||
|
ENTRYPOINT ["/dash-agent"]
|
||||||
+1246
File diff suppressed because it is too large.
Load diff
Generated
+414
-1
@@ -37,8 +37,10 @@
|
|||||||
"lucide-react": "^0.545.0",
|
"lucide-react": "^0.545.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
|
"recharts": "^3.10.1",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"tw-animate-css": "^1.4.0"
|
"tw-animate-css": "^1.4.0",
|
||||||
|
"wouter": "^3.11.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4.1.13",
|
"@tailwindcss/vite": "^4.1.13",
|
||||||
@@ -1986,6 +1988,32 @@
|
|||||||
"npm": ">=9.5.0"
|
"npm": ">=9.5.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@reduxjs/toolkit": {
|
||||||
|
"version": "2.12.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz",
|
||||||
|
"integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@standard-schema/spec": "^1.0.0",
|
||||||
|
"@standard-schema/utils": "^0.3.0",
|
||||||
|
"immer": "^11.0.0",
|
||||||
|
"redux": "^5.0.1",
|
||||||
|
"redux-thunk": "^3.1.0",
|
||||||
|
"reselect": "^5.1.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
|
||||||
|
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react-redux": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@rolldown/pluginutils": {
|
"node_modules/@rolldown/pluginutils": {
|
||||||
"version": "1.0.0-rc.3",
|
"version": "1.0.0-rc.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz",
|
||||||
@@ -2382,6 +2410,18 @@
|
|||||||
"win32"
|
"win32"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"node_modules/@standard-schema/spec": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@standard-schema/utils": {
|
||||||
|
"version": "0.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
||||||
|
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@tailwindcss/node": {
|
"node_modules/@tailwindcss/node": {
|
||||||
"version": "4.3.3",
|
"version": "4.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz",
|
||||||
@@ -2711,6 +2751,69 @@
|
|||||||
"@babel/types": "^7.28.2"
|
"@babel/types": "^7.28.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/d3-array": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-color": {
|
||||||
|
"version": "3.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||||
|
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-ease": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-interpolate": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-color": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-path": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-scale": {
|
||||||
|
"version": "4.0.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
||||||
|
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-time": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-shape": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-path": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-time": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-timer": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/estree": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.9",
|
"version": "1.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
||||||
@@ -2756,6 +2859,12 @@
|
|||||||
"@types/react": "^18.0.0"
|
"@types/react": "^18.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/use-sync-external-store": {
|
||||||
|
"version": "0.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||||
|
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@vitejs/plugin-react": {
|
"node_modules/@vitejs/plugin-react": {
|
||||||
"version": "5.2.0",
|
"version": "5.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz",
|
||||||
@@ -2950,6 +3059,127 @@
|
|||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/d3-array": {
|
||||||
|
"version": "3.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||||
|
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"internmap": "1 - 2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-color": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-ease": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-format": {
|
||||||
|
"version": "3.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
|
||||||
|
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-interpolate": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-color": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-path": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-scale": {
|
||||||
|
"version": "4.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||||
|
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-array": "2.10.0 - 3",
|
||||||
|
"d3-format": "1 - 3",
|
||||||
|
"d3-interpolate": "1.2.0 - 3",
|
||||||
|
"d3-time": "2.1.1 - 3",
|
||||||
|
"d3-time-format": "2 - 4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-shape": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-path": "^3.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-time": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-array": "2 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-time-format": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-time": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-timer": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
@@ -2968,6 +3198,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/decimal.js-light": {
|
||||||
|
"version": "2.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
|
||||||
|
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/detect-libc": {
|
"node_modules/detect-libc": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
@@ -3005,6 +3241,18 @@
|
|||||||
"node": ">=10.13.0"
|
"node": ">=10.13.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/es-toolkit": {
|
||||||
|
"version": "1.52.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.52.0.tgz",
|
||||||
|
"integrity": "sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"workspaces": [
|
||||||
|
"docs",
|
||||||
|
"benchmarks",
|
||||||
|
"tests/types",
|
||||||
|
"tests/browser-compat"
|
||||||
|
]
|
||||||
|
},
|
||||||
"node_modules/esbuild": {
|
"node_modules/esbuild": {
|
||||||
"version": "0.28.2",
|
"version": "0.28.2",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
|
||||||
@@ -3057,6 +3305,12 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/eventemitter3": {
|
||||||
|
"version": "5.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
|
||||||
|
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/fast-deep-equal": {
|
"node_modules/fast-deep-equal": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
@@ -3137,6 +3391,16 @@
|
|||||||
"node": ">= 14"
|
"node": ">= 14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/immer": {
|
||||||
|
"version": "11.1.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.18.tgz",
|
||||||
|
"integrity": "sha512-EQyQtLiYW029lyoczMl/Hh4Xu7cDecSc58JRYpHyL4tIAu3eqd1yJzQX04d2BZHDkzFFvm6qJEJWOtfDSWAXbQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/immer"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/index-to-position": {
|
"node_modules/index-to-position": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz",
|
||||||
@@ -3150,6 +3414,15 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/internmap": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/jiti": {
|
"node_modules/jiti": {
|
||||||
"version": "2.7.0",
|
"version": "2.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
|
||||||
@@ -3782,6 +4055,36 @@
|
|||||||
"react": "^18.3.1"
|
"react": "^18.3.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-is": {
|
||||||
|
"version": "19.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.3.0.tgz",
|
||||||
|
"integrity": "sha512-UpMYezM4v5/18F28aC66AEsjXIgE02kyEMH6yLdgLXu/UTfa1Ntwck/nNLrbqJsEXW7gPb0coNO9FQse9WTovA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
|
},
|
||||||
|
"node_modules/react-redux": {
|
||||||
|
"version": "9.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz",
|
||||||
|
"integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/use-sync-external-store": "^0.0.6",
|
||||||
|
"use-sync-external-store": "^1.4.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "^18.2.25 || ^19",
|
||||||
|
"react": "^18.0 || ^19",
|
||||||
|
"redux": "^5.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"redux": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-refresh": {
|
"node_modules/react-refresh": {
|
||||||
"version": "0.18.0",
|
"version": "0.18.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
|
||||||
@@ -3861,6 +4164,60 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/recharts": {
|
||||||
|
"version": "3.10.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.10.1.tgz",
|
||||||
|
"integrity": "sha512-QXFrvt6IVcw7eeZCoyXTwkIJAX3Dv1nyVhMicXJ47GsGDDpcN8z6o644DibE9XjpBTThtsomLKnTV6lc+cVFUA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"workspaces": [
|
||||||
|
"www"
|
||||||
|
],
|
||||||
|
"dependencies": {
|
||||||
|
"@reduxjs/toolkit": "^1.9.0 || 2.x.x",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"decimal.js-light": "^2.5.1",
|
||||||
|
"es-toolkit": "^1.39.3",
|
||||||
|
"eventemitter3": "^5.0.1",
|
||||||
|
"immer": "^11.1.8",
|
||||||
|
"react-redux": "8.x.x || 9.x.x",
|
||||||
|
"reselect": "5.2.0",
|
||||||
|
"tiny-invariant": "^1.3.3",
|
||||||
|
"use-sync-external-store": "^1.2.2",
|
||||||
|
"victory-vendor": "^37.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/redux": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/redux-thunk": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"redux": "^5.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/regexparam": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/regexparam/-/regexparam-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-RSYAtP31mvYLkAHrOlh25pCNQ5hWnT106VukGaaFfuJrZFkGRX5GhUAdPqpSDXxOhA2c4akmRuplv1mRqnBn6Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/require-from-string": {
|
"node_modules/require-from-string": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||||
@@ -3871,6 +4228,12 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/reselect": {
|
||||||
|
"version": "5.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz",
|
||||||
|
"integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/rollup": {
|
"node_modules/rollup": {
|
||||||
"version": "4.63.3",
|
"version": "4.63.3",
|
||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.3.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.3.tgz",
|
||||||
@@ -3990,6 +4353,12 @@
|
|||||||
"url": "https://opencollective.com/webpack"
|
"url": "https://opencollective.com/webpack"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tiny-invariant": {
|
||||||
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||||
|
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/tinyglobby": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.17",
|
"version": "0.2.17",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||||
@@ -4137,6 +4506,37 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/use-sync-external-store": {
|
||||||
|
"version": "1.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.7.0.tgz",
|
||||||
|
"integrity": "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/victory-vendor": {
|
||||||
|
"version": "37.3.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
|
||||||
|
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
|
||||||
|
"license": "MIT AND ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-array": "^3.0.3",
|
||||||
|
"@types/d3-ease": "^3.0.0",
|
||||||
|
"@types/d3-interpolate": "^3.0.1",
|
||||||
|
"@types/d3-scale": "^4.0.2",
|
||||||
|
"@types/d3-shape": "^3.1.0",
|
||||||
|
"@types/d3-time": "^3.0.0",
|
||||||
|
"@types/d3-timer": "^3.0.0",
|
||||||
|
"d3-array": "^3.1.6",
|
||||||
|
"d3-ease": "^3.0.1",
|
||||||
|
"d3-interpolate": "^3.0.1",
|
||||||
|
"d3-scale": "^4.0.2",
|
||||||
|
"d3-shape": "^3.1.0",
|
||||||
|
"d3-time": "^3.0.0",
|
||||||
|
"d3-timer": "^3.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "7.3.6",
|
"version": "7.3.6",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz",
|
||||||
@@ -4212,6 +4612,19 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/wouter": {
|
||||||
|
"version": "3.11.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/wouter/-/wouter-3.11.0.tgz",
|
||||||
|
"integrity": "sha512-xyMLHhytdGhIjXVRcSjeYbXd7TR1lrxVTPLGBWmmyW52ZuWVQ1jXmsgoJ4G5s4tjloJCq7h4i2eREljWUQ4+/A==",
|
||||||
|
"license": "Unlicense",
|
||||||
|
"dependencies": {
|
||||||
|
"regexparam": "^3.0.0",
|
||||||
|
"use-sync-external-store": "^1.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/yallist": {
|
"node_modules/yallist": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Dash system monitoring agent
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
EnvironmentFile=/etc/dash/agent.env
|
||||||
|
ExecStart=/usr/local/bin/dash-agent
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
# Reads /proc, /sys, and (if present) the Docker socket.
|
||||||
|
ProtectHome=true
|
||||||
|
NoNewPrivileges=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
Reference in new issue
Block a user