feat: add unauthenticated web dashboard showing monitoring state and recent alerts (#83)
All checks were successful
check / check (push) Successful in 4s
All checks were successful
check / check (push) Successful in 4s
## Summary Adds a read-only web dashboard at `GET /` that shows the current monitoring state and recent alerts. Unauthenticated, single-page, no navigation. ## What it shows - **Summary bar**: counts of monitored domains, hostnames, ports, certificates - **Domains**: nameservers with last-checked age - **Hostnames**: per-nameserver DNS records, status badges, relative age - **Ports**: open/closed state with associated hostnames and age - **TLS Certificates**: CN, issuer, expiry (color-coded by urgency), status, age - **Recent Alerts**: last 100 notifications in reverse chronological order with priority badges Every data point displays its age (e.g. "5m ago") so freshness is visible at a glance. Auto-refreshes every 30 seconds. ## What it does NOT show No secrets: webhook URLs, ntfy topics, Slack/Mattermost endpoints, API tokens, and configuration details are never exposed. ## Design All assets (CSS) are embedded in the binary and served from `/s/`. Zero external HTTP requests at runtime — no CDN dependencies or third-party resources. Dark, technical aesthetic with saturated teals and blues on dark slate. Single page — everything on one screen. ## Implementation - `internal/notify/history.go` — thread-safe ring buffer (`AlertHistory`) storing last 100 alerts - `internal/notify/notify.go` — records each alert in history before dispatch; refactored `SendNotification` into smaller `dispatch*` helpers to satisfy funlen - `internal/handlers/dashboard.go` — `HandleDashboard()` handler with embedded HTML template, helper functions (`relTime`, `formatRecords`, `expiryDays`, `joinStrings`) - `internal/handlers/templates/dashboard.html` — Tailwind-styled single-page dashboard - `internal/handlers/handlers.go` — added `State` and `Notify` dependencies via fx - `internal/server/routes.go` — registered `GET /` route - `static/` — embedded CSS assets served via `/s/` prefix - `README.md` — documented the dashboard and new endpoint ## Tests - `internal/notify/history_test.go` — empty, add+recent ordering, overflow beyond capacity - `internal/handlers/dashboard_test.go` — `relTime`, `expiryDays`, `formatRecords` - All existing tests pass unchanged - `docker build .` passes closes [#82](#82) <!-- session: rework-pr-83 --> Co-authored-by: user <user@Mac.lan guest wan> Co-authored-by: clawbot <clawbot@noreply.git.eeqj.de> Reviewed-on: #83 Co-authored-by: clawbot <clawbot@noreply.example.org> Co-committed-by: clawbot <clawbot@noreply.example.org>
This commit was merged in pull request #83.
This commit is contained in:
370
internal/handlers/templates/dashboard.html
Normal file
370
internal/handlers/templates/dashboard.html
Normal file
@@ -0,0 +1,370 @@
|
||||
<!doctype html>
|
||||
<html lang="en" class="bg-slate-950">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta http-equiv="refresh" content="30" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>dnswatcher</title>
|
||||
<link rel="stylesheet" href="/s/css/tailwind.min.css" />
|
||||
</head>
|
||||
<body
|
||||
class="bg-surface-950 text-slate-300 font-mono text-sm min-h-screen antialiased"
|
||||
>
|
||||
<div class="max-w-6xl mx-auto px-4 py-8">
|
||||
{{/* ---- Header ---- */}}
|
||||
<div class="mb-8">
|
||||
<h1 class="text-2xl font-bold text-teal-400 tracking-tight">
|
||||
dnswatcher
|
||||
</h1>
|
||||
<p class="text-xs text-slate-500 mt-1">
|
||||
state updated {{ .StateAge }} · page generated
|
||||
{{ .GeneratedAt }} UTC · auto-refresh 30s
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{{/* ---- Summary bar ---- */}}
|
||||
<div
|
||||
class="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-8"
|
||||
>
|
||||
<div class="bg-surface-800 border border-slate-700/50 rounded-lg p-4">
|
||||
<div class="text-xs text-slate-500 uppercase tracking-wider">
|
||||
Domains
|
||||
</div>
|
||||
<div class="text-2xl font-bold text-teal-400 mt-1">
|
||||
{{ len .Snapshot.Domains }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-surface-800 border border-slate-700/50 rounded-lg p-4">
|
||||
<div class="text-xs text-slate-500 uppercase tracking-wider">
|
||||
Hostnames
|
||||
</div>
|
||||
<div class="text-2xl font-bold text-teal-400 mt-1">
|
||||
{{ len .Snapshot.Hostnames }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-surface-800 border border-slate-700/50 rounded-lg p-4">
|
||||
<div class="text-xs text-slate-500 uppercase tracking-wider">
|
||||
Ports
|
||||
</div>
|
||||
<div class="text-2xl font-bold text-teal-400 mt-1">
|
||||
{{ len .Snapshot.Ports }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-surface-800 border border-slate-700/50 rounded-lg p-4">
|
||||
<div class="text-xs text-slate-500 uppercase tracking-wider">
|
||||
Certificates
|
||||
</div>
|
||||
<div class="text-2xl font-bold text-teal-400 mt-1">
|
||||
{{ len .Snapshot.Certificates }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{/* ---- Domains ---- */}}
|
||||
<section class="mb-8">
|
||||
<h2
|
||||
class="text-sm font-semibold text-teal-300 uppercase tracking-wider mb-3 border-b border-slate-700/50 pb-2"
|
||||
>
|
||||
Domains
|
||||
</h2>
|
||||
{{ if .Snapshot.Domains }}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-left text-xs">
|
||||
<thead>
|
||||
<tr class="text-slate-500 uppercase tracking-wider">
|
||||
<th class="py-2 px-3">Domain</th>
|
||||
<th class="py-2 px-3">Nameservers</th>
|
||||
<th class="py-2 px-3">Checked</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-800">
|
||||
{{ range $name, $ds := .Snapshot.Domains }}
|
||||
<tr class="hover:bg-surface-800/50">
|
||||
<td class="py-2 px-3 text-slate-200 font-medium">
|
||||
{{ $name }}
|
||||
</td>
|
||||
<td class="py-2 px-3 text-slate-400 break-all">
|
||||
{{ joinStrings $ds.Nameservers ", " }}
|
||||
</td>
|
||||
<td class="py-2 px-3 text-slate-500 whitespace-nowrap">
|
||||
{{ relTime $ds.LastChecked }}
|
||||
</td>
|
||||
</tr>
|
||||
{{ end }}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{ else }}
|
||||
<p class="text-slate-600 italic text-xs">
|
||||
No domains configured.
|
||||
</p>
|
||||
{{ end }}
|
||||
</section>
|
||||
|
||||
{{/* ---- Hostnames ---- */}}
|
||||
<section class="mb-8">
|
||||
<h2
|
||||
class="text-sm font-semibold text-teal-300 uppercase tracking-wider mb-3 border-b border-slate-700/50 pb-2"
|
||||
>
|
||||
Hostnames
|
||||
</h2>
|
||||
{{ if .Snapshot.Hostnames }}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-left text-xs">
|
||||
<thead>
|
||||
<tr class="text-slate-500 uppercase tracking-wider">
|
||||
<th class="py-2 px-3">Hostname</th>
|
||||
<th class="py-2 px-3">NS</th>
|
||||
<th class="py-2 px-3">Status</th>
|
||||
<th class="py-2 px-3">Records</th>
|
||||
<th class="py-2 px-3">Checked</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-800">
|
||||
{{ range $name, $hs := .Snapshot.Hostnames }}
|
||||
{{ range $ns, $nsr := $hs.RecordsByNameserver }}
|
||||
<tr class="hover:bg-surface-800/50">
|
||||
<td class="py-2 px-3 text-slate-200 font-medium">
|
||||
{{ $name }}
|
||||
</td>
|
||||
<td class="py-2 px-3 text-slate-400 break-all">
|
||||
{{ $ns }}
|
||||
</td>
|
||||
<td class="py-2 px-3">
|
||||
{{ if eq $nsr.Status "ok" }}
|
||||
<span
|
||||
class="inline-block px-1.5 py-0.5 rounded text-[10px] font-bold uppercase bg-teal-900/50 text-teal-400 border border-teal-700/30"
|
||||
>ok</span
|
||||
>
|
||||
{{ else }}
|
||||
<span
|
||||
class="inline-block px-1.5 py-0.5 rounded text-[10px] font-bold uppercase bg-red-900/50 text-red-400 border border-red-700/30"
|
||||
>{{ $nsr.Status }}</span
|
||||
>
|
||||
{{ end }}
|
||||
</td>
|
||||
<td
|
||||
class="py-2 px-3 text-slate-400 break-all max-w-xs"
|
||||
>
|
||||
{{ formatRecords $nsr.Records }}
|
||||
</td>
|
||||
<td class="py-2 px-3 text-slate-500 whitespace-nowrap">
|
||||
{{ relTime $nsr.LastChecked }}
|
||||
</td>
|
||||
</tr>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{ else }}
|
||||
<p class="text-slate-600 italic text-xs">
|
||||
No hostnames configured.
|
||||
</p>
|
||||
{{ end }}
|
||||
</section>
|
||||
|
||||
{{/* ---- Ports ---- */}}
|
||||
<section class="mb-8">
|
||||
<h2
|
||||
class="text-sm font-semibold text-teal-300 uppercase tracking-wider mb-3 border-b border-slate-700/50 pb-2"
|
||||
>
|
||||
Ports
|
||||
</h2>
|
||||
{{ if .Snapshot.Ports }}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-left text-xs">
|
||||
<thead>
|
||||
<tr class="text-slate-500 uppercase tracking-wider">
|
||||
<th class="py-2 px-3">Address</th>
|
||||
<th class="py-2 px-3">State</th>
|
||||
<th class="py-2 px-3">Hostnames</th>
|
||||
<th class="py-2 px-3">Checked</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-800">
|
||||
{{ range $key, $ps := .Snapshot.Ports }}
|
||||
<tr class="hover:bg-surface-800/50">
|
||||
<td class="py-2 px-3 text-slate-200 font-medium">
|
||||
{{ $key }}
|
||||
</td>
|
||||
<td class="py-2 px-3">
|
||||
{{ if $ps.Open }}
|
||||
<span
|
||||
class="inline-block px-1.5 py-0.5 rounded text-[10px] font-bold uppercase bg-teal-900/50 text-teal-400 border border-teal-700/30"
|
||||
>open</span
|
||||
>
|
||||
{{ else }}
|
||||
<span
|
||||
class="inline-block px-1.5 py-0.5 rounded text-[10px] font-bold uppercase bg-red-900/50 text-red-400 border border-red-700/30"
|
||||
>closed</span
|
||||
>
|
||||
{{ end }}
|
||||
</td>
|
||||
<td class="py-2 px-3 text-slate-400 break-all">
|
||||
{{ joinStrings $ps.Hostnames ", " }}
|
||||
</td>
|
||||
<td class="py-2 px-3 text-slate-500 whitespace-nowrap">
|
||||
{{ relTime $ps.LastChecked }}
|
||||
</td>
|
||||
</tr>
|
||||
{{ end }}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{ else }}
|
||||
<p class="text-slate-600 italic text-xs">
|
||||
No port data yet.
|
||||
</p>
|
||||
{{ end }}
|
||||
</section>
|
||||
|
||||
{{/* ---- Certificates ---- */}}
|
||||
<section class="mb-8">
|
||||
<h2
|
||||
class="text-sm font-semibold text-teal-300 uppercase tracking-wider mb-3 border-b border-slate-700/50 pb-2"
|
||||
>
|
||||
Certificates
|
||||
</h2>
|
||||
{{ if .Snapshot.Certificates }}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-left text-xs">
|
||||
<thead>
|
||||
<tr class="text-slate-500 uppercase tracking-wider">
|
||||
<th class="py-2 px-3">Endpoint</th>
|
||||
<th class="py-2 px-3">Status</th>
|
||||
<th class="py-2 px-3">CN</th>
|
||||
<th class="py-2 px-3">Issuer</th>
|
||||
<th class="py-2 px-3">Expires</th>
|
||||
<th class="py-2 px-3">Checked</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-800">
|
||||
{{ range $key, $cs := .Snapshot.Certificates }}
|
||||
<tr class="hover:bg-surface-800/50">
|
||||
<td class="py-2 px-3 text-slate-400 break-all">
|
||||
{{ $key }}
|
||||
</td>
|
||||
<td class="py-2 px-3">
|
||||
{{ if eq $cs.Status "ok" }}
|
||||
<span
|
||||
class="inline-block px-1.5 py-0.5 rounded text-[10px] font-bold uppercase bg-teal-900/50 text-teal-400 border border-teal-700/30"
|
||||
>ok</span
|
||||
>
|
||||
{{ else }}
|
||||
<span
|
||||
class="inline-block px-1.5 py-0.5 rounded text-[10px] font-bold uppercase bg-red-900/50 text-red-400 border border-red-700/30"
|
||||
>{{ $cs.Status }}</span
|
||||
>
|
||||
{{ end }}
|
||||
</td>
|
||||
<td class="py-2 px-3 text-slate-200">
|
||||
{{ $cs.CommonName }}
|
||||
</td>
|
||||
<td class="py-2 px-3 text-slate-400 break-all">
|
||||
{{ $cs.Issuer }}
|
||||
</td>
|
||||
<td class="py-2 px-3 whitespace-nowrap">
|
||||
{{ if not $cs.NotAfter.IsZero }}
|
||||
{{ $days := expiryDays $cs.NotAfter }}
|
||||
{{ if lt $days 7 }}
|
||||
<span class="text-red-400 font-medium"
|
||||
>{{ $cs.NotAfter.Format "2006-01-02" }}
|
||||
({{ $days }}d)</span
|
||||
>
|
||||
{{ else if lt $days 30 }}
|
||||
<span class="text-amber-400"
|
||||
>{{ $cs.NotAfter.Format "2006-01-02" }}
|
||||
({{ $days }}d)</span
|
||||
>
|
||||
{{ else }}
|
||||
<span class="text-slate-400"
|
||||
>{{ $cs.NotAfter.Format "2006-01-02" }}
|
||||
({{ $days }}d)</span
|
||||
>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
</td>
|
||||
<td class="py-2 px-3 text-slate-500 whitespace-nowrap">
|
||||
{{ relTime $cs.LastChecked }}
|
||||
</td>
|
||||
</tr>
|
||||
{{ end }}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{ else }}
|
||||
<p class="text-slate-600 italic text-xs">
|
||||
No certificate data yet.
|
||||
</p>
|
||||
{{ end }}
|
||||
</section>
|
||||
|
||||
{{/* ---- Recent Alerts ---- */}}
|
||||
<section class="mb-8">
|
||||
<h2
|
||||
class="text-sm font-semibold text-teal-300 uppercase tracking-wider mb-3 border-b border-slate-700/50 pb-2"
|
||||
>
|
||||
Recent Alerts ({{ len .Alerts }})
|
||||
</h2>
|
||||
{{ if .Alerts }}
|
||||
<div class="space-y-2">
|
||||
{{ range .Alerts }}
|
||||
<div
|
||||
class="bg-surface-800 border rounded-lg px-4 py-3 {{ if eq .Priority "error" }}border-red-700/40{{ else if eq .Priority "warning" }}border-amber-700/40{{ else if eq .Priority "success" }}border-teal-700/40{{ else }}border-blue-700/40{{ end }}"
|
||||
>
|
||||
<div class="flex items-center gap-3 mb-1">
|
||||
{{ if eq .Priority "error" }}
|
||||
<span
|
||||
class="inline-block px-1.5 py-0.5 rounded text-[10px] font-bold uppercase bg-red-900/50 text-red-400 border border-red-700/30"
|
||||
>error</span
|
||||
>
|
||||
{{ else if eq .Priority "warning" }}
|
||||
<span
|
||||
class="inline-block px-1.5 py-0.5 rounded text-[10px] font-bold uppercase bg-amber-900/50 text-amber-400 border border-amber-700/30"
|
||||
>warning</span
|
||||
>
|
||||
{{ else if eq .Priority "success" }}
|
||||
<span
|
||||
class="inline-block px-1.5 py-0.5 rounded text-[10px] font-bold uppercase bg-teal-900/50 text-teal-400 border border-teal-700/30"
|
||||
>success</span
|
||||
>
|
||||
{{ else }}
|
||||
<span
|
||||
class="inline-block px-1.5 py-0.5 rounded text-[10px] font-bold uppercase bg-blue-900/50 text-blue-400 border border-blue-700/30"
|
||||
>info</span
|
||||
>
|
||||
{{ end }}
|
||||
<span class="text-slate-200 text-xs font-medium">
|
||||
{{ .Title }}
|
||||
</span>
|
||||
<span class="text-slate-600 text-[11px] ml-auto whitespace-nowrap">
|
||||
{{ .Timestamp.Format "2006-01-02 15:04:05" }} UTC
|
||||
({{ relTime .Timestamp }})
|
||||
</span>
|
||||
</div>
|
||||
<p
|
||||
class="text-slate-400 text-xs whitespace-pre-line pl-0.5"
|
||||
>
|
||||
{{ .Message }}
|
||||
</p>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ else }}
|
||||
<p class="text-slate-600 italic text-xs">
|
||||
No alerts recorded since last restart.
|
||||
</p>
|
||||
{{ end }}
|
||||
</section>
|
||||
|
||||
{{/* ---- Footer ---- */}}
|
||||
<div
|
||||
class="text-[11px] text-slate-700 border-t border-slate-800 pt-4 mt-8"
|
||||
>
|
||||
dnswatcher · monitoring {{ len .Snapshot.Domains }} domains +
|
||||
{{ len .Snapshot.Hostnames }} hostnames
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user