feat: add unauthenticated web dashboard showing monitoring state and recent alerts #83
22
README.md
22
README.md
@@ -124,12 +124,34 @@ includes:
|
|||||||
- State is written atomically (write to temp file, then rename) to prevent
|
- State is written atomically (write to temp file, then rename) to prevent
|
||||||
corruption.
|
corruption.
|
||||||
|
|
||||||
|
### Web Dashboard
|
||||||
|
|
||||||
|
dnswatcher includes an unauthenticated, read-only web dashboard at the
|
||||||
|
root URL (`/`). It displays:
|
||||||
|
|
||||||
|
- **Summary counts** for monitored domains, hostnames, ports, and
|
||||||
|
certificates.
|
||||||
|
- **Domains** with their discovered nameservers.
|
||||||
|
- **Hostnames** with per-nameserver DNS records and status.
|
||||||
|
- **Ports** with open/closed state and associated hostnames.
|
||||||
|
- **TLS certificates** with CN, issuer, expiry, and status.
|
||||||
|
- **Recent alerts** (last 100 notifications sent since the process
|
||||||
|
started), displayed in reverse chronological order.
|
||||||
|
|
||||||
|
Every data point shows its age (e.g. "5m ago") so you can tell at a
|
||||||
|
glance how fresh the information is. The page auto-refreshes every 30
|
||||||
|
seconds.
|
||||||
|
|
||||||
|
The dashboard intentionally does not expose any configuration details
|
||||||
|
such as webhook URLs, notification endpoints, or API tokens.
|
||||||
|
|
||||||
### HTTP API
|
### HTTP API
|
||||||
|
|
||||||
dnswatcher exposes a lightweight HTTP API for operational visibility:
|
dnswatcher exposes a lightweight HTTP API for operational visibility:
|
||||||
|
|
||||||
| Endpoint | Description |
|
| Endpoint | Description |
|
||||||
|---------------------------------------|--------------------------------|
|
|---------------------------------------|--------------------------------|
|
||||||
|
| `GET /` | Web dashboard (HTML) |
|
||||||
| `GET /health` | Health check (JSON) |
|
| `GET /health` | Health check (JSON) |
|
||||||
| `GET /api/v1/status` | Current monitoring state |
|
| `GET /api/v1/status` | Current monitoring state |
|
||||||
| `GET /metrics` | Prometheus metrics (optional) |
|
| `GET /metrics` | Prometheus metrics (optional) |
|
||||||
|
|||||||
151
internal/handlers/dashboard.go
Normal file
151
internal/handlers/dashboard.go
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"math"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/dnswatcher/internal/notify"
|
||||||
|
"sneak.berlin/go/dnswatcher/internal/state"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed templates/dashboard.html
|
||||||
|
var dashboardFS embed.FS
|
||||||
|
|
||||||
|
// Time unit constants for relative time calculations.
|
||||||
|
const (
|
||||||
|
secondsPerMinute = 60
|
||||||
|
minutesPerHour = 60
|
||||||
|
hoursPerDay = 24
|
||||||
|
)
|
||||||
|
|
||||||
|
// newDashboardTemplate parses the embedded dashboard HTML
|
||||||
|
// template with helper functions.
|
||||||
|
func newDashboardTemplate() *template.Template {
|
||||||
|
funcs := template.FuncMap{
|
||||||
|
"relTime": relTime,
|
||||||
|
"joinStrings": joinStrings,
|
||||||
|
"formatRecords": formatRecords,
|
||||||
|
"expiryDays": expiryDays,
|
||||||
|
}
|
||||||
|
|
||||||
|
return template.Must(
|
||||||
|
template.New("dashboard.html").
|
||||||
|
Funcs(funcs).
|
||||||
|
ParseFS(dashboardFS, "templates/dashboard.html"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// dashboardData is the data passed to the dashboard template.
|
||||||
|
type dashboardData struct {
|
||||||
|
Snapshot state.Snapshot
|
||||||
|
Alerts []notify.AlertEntry
|
||||||
|
StateAge string
|
||||||
|
GeneratedAt string
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleDashboard returns the dashboard page handler.
|
||||||
|
func (h *Handlers) HandleDashboard() http.HandlerFunc {
|
||||||
|
tmpl := newDashboardTemplate()
|
||||||
|
|
||||||
|
return func(
|
||||||
|
writer http.ResponseWriter,
|
||||||
|
_ *http.Request,
|
||||||
|
) {
|
||||||
|
snap := h.state.GetSnapshot()
|
||||||
|
alerts := h.notifyHistory.Recent()
|
||||||
|
|
||||||
|
data := dashboardData{
|
||||||
|
Snapshot: snap,
|
||||||
|
Alerts: alerts,
|
||||||
|
StateAge: relTime(snap.LastUpdated),
|
||||||
|
GeneratedAt: time.Now().UTC().Format("2006-01-02 15:04:05"),
|
||||||
|
}
|
||||||
|
|
||||||
|
writer.Header().Set(
|
||||||
|
"Content-Type", "text/html; charset=utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
err := tmpl.Execute(writer, data)
|
||||||
|
if err != nil {
|
||||||
|
h.log.Error(
|
||||||
|
"dashboard template error",
|
||||||
|
"error", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// relTime returns a human-readable relative time string such
|
||||||
|
// as "2 minutes ago" or "never" for zero times.
|
||||||
|
func relTime(t time.Time) string {
|
||||||
|
if t.IsZero() {
|
||||||
|
return "never"
|
||||||
|
}
|
||||||
|
|
||||||
|
d := time.Since(t)
|
||||||
|
if d < 0 {
|
||||||
|
return "just now"
|
||||||
|
}
|
||||||
|
|
||||||
|
seconds := int(math.Round(d.Seconds()))
|
||||||
|
if seconds < secondsPerMinute {
|
||||||
|
return fmt.Sprintf("%ds ago", seconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
minutes := seconds / secondsPerMinute
|
||||||
|
if minutes < minutesPerHour {
|
||||||
|
return fmt.Sprintf("%dm ago", minutes)
|
||||||
|
}
|
||||||
|
|
||||||
|
hours := minutes / minutesPerHour
|
||||||
|
if hours < hoursPerDay {
|
||||||
|
return fmt.Sprintf(
|
||||||
|
"%dh %dm ago", hours, minutes%minutesPerHour,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
days := hours / hoursPerDay
|
||||||
|
|
||||||
|
return fmt.Sprintf(
|
||||||
|
"%dd %dh ago", days, hours%hoursPerDay,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// joinStrings joins a string slice with a separator.
|
||||||
|
func joinStrings(items []string, sep string) string {
|
||||||
|
return strings.Join(items, sep)
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatRecords formats a map of record type → values into a
|
||||||
|
// compact display string.
|
||||||
|
func formatRecords(records map[string][]string) string {
|
||||||
|
if len(records) == 0 {
|
||||||
|
return "-"
|
||||||
|
}
|
||||||
|
|
||||||
|
var parts []string
|
||||||
|
|
||||||
|
for rtype, values := range records {
|
||||||
|
for _, v := range values {
|
||||||
|
parts = append(parts, rtype+": "+v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Join(parts, ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// expiryDays returns the number of days until the given time,
|
||||||
|
// rounded down. Returns 0 if already expired.
|
||||||
|
func expiryDays(t time.Time) int {
|
||||||
|
d := time.Until(t).Hours() / hoursPerDay
|
||||||
|
if d < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return int(d)
|
||||||
|
}
|
||||||
80
internal/handlers/dashboard_test.go
Normal file
80
internal/handlers/dashboard_test.go
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
package handlers_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/dnswatcher/internal/handlers"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRelTime(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
dur time.Duration
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"zero", 0, "never"},
|
||||||
|
{"seconds", 30 * time.Second, "30s ago"},
|
||||||
|
{"minutes", 5 * time.Minute, "5m ago"},
|
||||||
|
{"hours", 2*time.Hour + 15*time.Minute, "2h 15m ago"},
|
||||||
|
{"days", 48*time.Hour + 3*time.Hour, "2d 3h ago"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var input time.Time
|
||||||
|
if tt.dur > 0 {
|
||||||
|
input = time.Now().Add(-tt.dur)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := handlers.RelTime(input)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf(
|
||||||
|
"RelTime(%v) = %q, want %q",
|
||||||
|
tt.dur, got, tt.want,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExpiryDays(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// 10 days from now.
|
||||||
|
future := time.Now().Add(10 * 24 * time.Hour)
|
||||||
|
|
||||||
|
days := handlers.ExpiryDays(future)
|
||||||
|
if days < 9 || days > 10 {
|
||||||
|
t.Errorf("expected ~10 days, got %d", days)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Already expired.
|
||||||
|
past := time.Now().Add(-24 * time.Hour)
|
||||||
|
|
||||||
|
days = handlers.ExpiryDays(past)
|
||||||
|
if days != 0 {
|
||||||
|
t.Errorf("expected 0 for expired, got %d", days)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatRecords(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
got := handlers.FormatRecords(nil)
|
||||||
|
if got != "-" {
|
||||||
|
t.Errorf("expected -, got %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
got = handlers.FormatRecords(map[string][]string{
|
||||||
|
"A": {"1.2.3.4"},
|
||||||
|
})
|
||||||
|
|
||||||
|
if got != "A: 1.2.3.4" {
|
||||||
|
t.Errorf("unexpected format: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
18
internal/handlers/export_test.go
Normal file
18
internal/handlers/export_test.go
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// RelTime exports relTime for testing.
|
||||||
|
func RelTime(t time.Time) string {
|
||||||
|
return relTime(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExpiryDays exports expiryDays for testing.
|
||||||
|
func ExpiryDays(t time.Time) int {
|
||||||
|
return expiryDays(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatRecords exports formatRecords for testing.
|
||||||
|
func FormatRecords(records map[string][]string) string {
|
||||||
|
return formatRecords(records)
|
||||||
|
}
|
||||||
@@ -11,6 +11,8 @@ import (
|
|||||||
"sneak.berlin/go/dnswatcher/internal/globals"
|
"sneak.berlin/go/dnswatcher/internal/globals"
|
||||||
"sneak.berlin/go/dnswatcher/internal/healthcheck"
|
"sneak.berlin/go/dnswatcher/internal/healthcheck"
|
||||||
"sneak.berlin/go/dnswatcher/internal/logger"
|
"sneak.berlin/go/dnswatcher/internal/logger"
|
||||||
|
"sneak.berlin/go/dnswatcher/internal/notify"
|
||||||
|
"sneak.berlin/go/dnswatcher/internal/state"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Params contains dependencies for Handlers.
|
// Params contains dependencies for Handlers.
|
||||||
@@ -20,6 +22,8 @@ type Params struct {
|
|||||||
Logger *logger.Logger
|
Logger *logger.Logger
|
||||||
Globals *globals.Globals
|
Globals *globals.Globals
|
||||||
Healthcheck *healthcheck.Healthcheck
|
Healthcheck *healthcheck.Healthcheck
|
||||||
|
State *state.State
|
||||||
|
Notify *notify.Service
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handlers provides HTTP request handlers.
|
// Handlers provides HTTP request handlers.
|
||||||
@@ -28,6 +32,8 @@ type Handlers struct {
|
|||||||
params *Params
|
params *Params
|
||||||
globals *globals.Globals
|
globals *globals.Globals
|
||||||
hc *healthcheck.Healthcheck
|
hc *healthcheck.Healthcheck
|
||||||
|
state *state.State
|
||||||
|
notifyHistory *notify.AlertHistory
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new Handlers instance.
|
// New creates a new Handlers instance.
|
||||||
@@ -37,6 +43,8 @@ func New(_ fx.Lifecycle, params Params) (*Handlers, error) {
|
|||||||
params: ¶ms,
|
params: ¶ms,
|
||||||
globals: params.Globals,
|
globals: params.Globals,
|
||||||
hc: params.Healthcheck,
|
hc: params.Healthcheck,
|
||||||
|
state: params.State,
|
||||||
|
notifyHistory: params.Notify.History(),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
386
internal/handlers/templates/dashboard.html
Normal file
386
internal/handlers/templates/dashboard.html
Normal file
@@ -0,0 +1,386 @@
|
|||||||
|
<!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>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<script>
|
||||||
|
tailwind.config = {
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
surface: {
|
||||||
|
950: "#0c1222",
|
||||||
|
900: "#111827",
|
||||||
|
800: "#1a2332",
|
||||||
|
700: "#243044",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
</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>
|
||||||
@@ -34,6 +34,7 @@ func NewTestService(transport http.RoundTripper) *Service {
|
|||||||
return &Service{
|
return &Service{
|
||||||
log: slog.New(slog.DiscardHandler),
|
log: slog.New(slog.DiscardHandler),
|
||||||
transport: transport,
|
transport: transport,
|
||||||
|
history: NewAlertHistory(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
62
internal/notify/history.go
Normal file
62
internal/notify/history.go
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
package notify
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// maxAlertHistory is the maximum number of alerts to retain.
|
||||||
|
const maxAlertHistory = 100
|
||||||
|
|
||||||
|
// AlertEntry represents a single notification that was sent.
|
||||||
|
type AlertEntry struct {
|
||||||
|
Timestamp time.Time
|
||||||
|
Title string
|
||||||
|
Message string
|
||||||
|
Priority string
|
||||||
|
}
|
||||||
|
|
||||||
|
// AlertHistory is a thread-safe ring buffer that stores
|
||||||
|
// the most recent alerts.
|
||||||
|
type AlertHistory struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
entries [maxAlertHistory]AlertEntry
|
||||||
|
count int
|
||||||
|
index int
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAlertHistory creates a new empty AlertHistory.
|
||||||
|
func NewAlertHistory() *AlertHistory {
|
||||||
|
return &AlertHistory{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add records a new alert entry in the ring buffer.
|
||||||
|
func (h *AlertHistory) Add(entry AlertEntry) {
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
|
||||||
|
h.entries[h.index] = entry
|
||||||
|
h.index = (h.index + 1) % maxAlertHistory
|
||||||
|
|
||||||
|
if h.count < maxAlertHistory {
|
||||||
|
h.count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recent returns the stored alerts in reverse chronological
|
||||||
|
// order (newest first). Returns at most maxAlertHistory entries.
|
||||||
|
func (h *AlertHistory) Recent() []AlertEntry {
|
||||||
|
h.mu.RLock()
|
||||||
|
defer h.mu.RUnlock()
|
||||||
|
|
||||||
|
result := make([]AlertEntry, h.count)
|
||||||
|
|
||||||
|
for i := range h.count {
|
||||||
|
// Walk backwards from the most recent entry.
|
||||||
|
idx := (h.index - 1 - i + maxAlertHistory) %
|
||||||
|
maxAlertHistory
|
||||||
|
result[i] = h.entries[idx]
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
88
internal/notify/history_test.go
Normal file
88
internal/notify/history_test.go
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
package notify_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/dnswatcher/internal/notify"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAlertHistoryEmpty(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
h := notify.NewAlertHistory()
|
||||||
|
|
||||||
|
entries := h.Recent()
|
||||||
|
if len(entries) != 0 {
|
||||||
|
t.Fatalf("expected 0 entries, got %d", len(entries))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertHistoryAddAndRecent(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
h := notify.NewAlertHistory()
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
|
||||||
|
h.Add(notify.AlertEntry{
|
||||||
|
Timestamp: now.Add(-2 * time.Minute),
|
||||||
|
Title: "first",
|
||||||
|
Message: "msg1",
|
||||||
|
Priority: "info",
|
||||||
|
})
|
||||||
|
|
||||||
|
h.Add(notify.AlertEntry{
|
||||||
|
Timestamp: now.Add(-1 * time.Minute),
|
||||||
|
Title: "second",
|
||||||
|
Message: "msg2",
|
||||||
|
Priority: "warning",
|
||||||
|
})
|
||||||
|
|
||||||
|
entries := h.Recent()
|
||||||
|
if len(entries) != 2 {
|
||||||
|
t.Fatalf("expected 2 entries, got %d", len(entries))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Newest first.
|
||||||
|
if entries[0].Title != "second" {
|
||||||
|
t.Errorf(
|
||||||
|
"expected newest first, got %q", entries[0].Title,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if entries[1].Title != "first" {
|
||||||
|
t.Errorf(
|
||||||
|
"expected oldest second, got %q", entries[1].Title,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertHistoryOverflow(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
h := notify.NewAlertHistory()
|
||||||
|
|
||||||
|
const totalEntries = 110
|
||||||
|
|
||||||
|
// Fill beyond capacity.
|
||||||
|
for i := range totalEntries {
|
||||||
|
h.Add(notify.AlertEntry{
|
||||||
|
Timestamp: time.Now().UTC(),
|
||||||
|
Title: "alert",
|
||||||
|
Message: "msg",
|
||||||
|
Priority: string(rune('0' + i%10)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
entries := h.Recent()
|
||||||
|
|
||||||
|
const maxHistory = 100
|
||||||
|
|
||||||
|
if len(entries) != maxHistory {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected %d entries, got %d",
|
||||||
|
maxHistory, len(entries),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -112,6 +112,7 @@ type Service struct {
|
|||||||
ntfyURL *url.URL
|
ntfyURL *url.URL
|
||||||
slackWebhookURL *url.URL
|
slackWebhookURL *url.URL
|
||||||
mattermostWebhookURL *url.URL
|
mattermostWebhookURL *url.URL
|
||||||
|
history *AlertHistory
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new notify Service.
|
// New creates a new notify Service.
|
||||||
@@ -123,6 +124,7 @@ func New(
|
|||||||
log: params.Logger.Get(),
|
log: params.Logger.Get(),
|
||||||
transport: http.DefaultTransport,
|
transport: http.DefaultTransport,
|
||||||
config: params.Config,
|
config: params.Config,
|
||||||
|
history: NewAlertHistory(),
|
||||||
}
|
}
|
||||||
|
|
||||||
if params.Config.NtfyTopic != "" {
|
if params.Config.NtfyTopic != "" {
|
||||||
@@ -167,19 +169,42 @@ func New(
|
|||||||
return svc, nil
|
return svc, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// History returns the alert history for reading recent alerts.
|
||||||
|
func (svc *Service) History() *AlertHistory {
|
||||||
|
return svc.history
|
||||||
|
}
|
||||||
|
|
||||||
// SendNotification sends a notification to all configured
|
// SendNotification sends a notification to all configured
|
||||||
// endpoints.
|
// endpoints and records it in the alert history.
|
||||||
func (svc *Service) SendNotification(
|
func (svc *Service) SendNotification(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
title, message, priority string,
|
title, message, priority string,
|
||||||
) {
|
) {
|
||||||
if svc.ntfyURL != nil {
|
svc.history.Add(AlertEntry{
|
||||||
|
Timestamp: time.Now().UTC(),
|
||||||
|
Title: title,
|
||||||
|
Message: message,
|
||||||
|
Priority: priority,
|
||||||
|
})
|
||||||
|
|
||||||
|
svc.dispatchNtfy(ctx, title, message, priority)
|
||||||
|
svc.dispatchSlack(ctx, title, message, priority)
|
||||||
|
svc.dispatchMattermost(ctx, title, message, priority)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (svc *Service) dispatchNtfy(
|
||||||
|
ctx context.Context,
|
||||||
|
title, message, priority string,
|
||||||
|
) {
|
||||||
|
if svc.ntfyURL == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
notifyCtx := context.WithoutCancel(ctx)
|
notifyCtx := context.WithoutCancel(ctx)
|
||||||
|
|
||||||
err := svc.sendNtfy(
|
err := svc.sendNtfy(
|
||||||
notifyCtx,
|
notifyCtx, svc.ntfyURL,
|
||||||
svc.ntfyURL,
|
|
||||||
title, message, priority,
|
title, message, priority,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -189,15 +214,21 @@ func (svc *Service) SendNotification(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (svc *Service) dispatchSlack(
|
||||||
|
ctx context.Context,
|
||||||
|
title, message, priority string,
|
||||||
|
) {
|
||||||
|
if svc.slackWebhookURL == nil {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if svc.slackWebhookURL != nil {
|
|
||||||
go func() {
|
go func() {
|
||||||
notifyCtx := context.WithoutCancel(ctx)
|
notifyCtx := context.WithoutCancel(ctx)
|
||||||
|
|
||||||
err := svc.sendSlack(
|
err := svc.sendSlack(
|
||||||
notifyCtx,
|
notifyCtx, svc.slackWebhookURL,
|
||||||
svc.slackWebhookURL,
|
|
||||||
title, message, priority,
|
title, message, priority,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -207,15 +238,21 @@ func (svc *Service) SendNotification(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (svc *Service) dispatchMattermost(
|
||||||
|
ctx context.Context,
|
||||||
|
title, message, priority string,
|
||||||
|
) {
|
||||||
|
if svc.mattermostWebhookURL == nil {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if svc.mattermostWebhookURL != nil {
|
|
||||||
go func() {
|
go func() {
|
||||||
notifyCtx := context.WithoutCancel(ctx)
|
notifyCtx := context.WithoutCancel(ctx)
|
||||||
|
|
||||||
err := svc.sendSlack(
|
err := svc.sendSlack(
|
||||||
notifyCtx,
|
notifyCtx, svc.mattermostWebhookURL,
|
||||||
svc.mattermostWebhookURL,
|
|
||||||
title, message, priority,
|
title, message, priority,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -225,7 +262,6 @@ func (svc *Service) SendNotification(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (svc *Service) sendNtfy(
|
func (svc *Service) sendNtfy(
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ func (s *Server) SetupRoutes() {
|
|||||||
s.router.Use(s.mw.CORS())
|
s.router.Use(s.mw.CORS())
|
||||||
s.router.Use(chimw.Timeout(requestTimeout))
|
s.router.Use(chimw.Timeout(requestTimeout))
|
||||||
|
|
||||||
|
// Dashboard (read-only web UI)
|
||||||
|
s.router.Get("/", s.handlers.HandleDashboard())
|
||||||
|
|
||||||
// Health check
|
// Health check
|
||||||
s.router.Get("/health", s.handlers.HandleHealthCheck())
|
s.router.Get("/health", s.handlers.HandleHealthCheck())
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user