diff --git a/Makefile b/Makefile
index fe266fc..0aadca3 100644
--- a/Makefile
+++ b/Makefile
@@ -2,8 +2,7 @@
BINARY := dnswatcher
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
-BUILDARCH := $(shell go env GOARCH)
-LDFLAGS := -X main.Version=$(VERSION) -X main.Buildarch=$(BUILDARCH)
+LDFLAGS := -X main.Version=$(VERSION)
all: check build
diff --git a/README.md b/README.md
index c5d104b..eb52839 100644
--- a/README.md
+++ b/README.md
@@ -124,13 +124,41 @@ includes:
- State is written atomically (write to temp file, then rename) to prevent
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.
+
+All assets (CSS) are embedded in the binary and served from the
+application itself. The dashboard makes zero external HTTP requests —
+no CDN dependencies or third-party resources are loaded at runtime.
+
### HTTP API
dnswatcher exposes a lightweight HTTP API for operational visibility:
| Endpoint | Description |
|---------------------------------------|--------------------------------|
-| `GET /health` | Health check (JSON) |
+| `GET /` | Web dashboard (HTML) |
+| `GET /s/...` | Static assets (embedded CSS) |
+| `GET /.well-known/healthcheck` | Health check (JSON) |
+| `GET /health` | Health check (JSON, legacy) |
| `GET /api/v1/status` | Current monitoring state |
| `GET /metrics` | Prometheus metrics (optional) |
@@ -143,7 +171,7 @@ cmd/dnswatcher/main.go Entry point (uber/fx bootstrap)
internal/
config/config.go Viper-based configuration
- globals/globals.go Build-time variables (version, arch)
+ globals/globals.go Build-time variables (version)
logger/logger.go slog structured logging (TTY detection)
healthcheck/healthcheck.go Health check service
middleware/middleware.go HTTP middleware (logging, CORS, metrics auth)
@@ -335,11 +363,10 @@ make clean # Remove build artifacts
### Build-Time Variables
-Version and architecture are injected via `-ldflags`:
+Version is injected via `-ldflags`:
```sh
-go build -ldflags "-X main.Version=$(git describe --tags --always) \
- -X main.Buildarch=$(go env GOARCH)" ./cmd/dnswatcher
+go build -ldflags "-X main.Version=$(git describe --tags --always)" ./cmd/dnswatcher
```
---
diff --git a/cmd/dnswatcher/main.go b/cmd/dnswatcher/main.go
index 03b38da..baff13b 100644
--- a/cmd/dnswatcher/main.go
+++ b/cmd/dnswatcher/main.go
@@ -25,15 +25,13 @@ import (
//
//nolint:gochecknoglobals // build-time variables
var (
- Appname = "dnswatcher"
- Version string
- Buildarch string
+ Appname = "dnswatcher"
+ Version string
)
func main() {
globals.SetAppname(Appname)
globals.SetVersion(Version)
- globals.SetBuildarch(Buildarch)
fx.New(
fx.Provide(
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
index c460d56..0ee5c83 100644
--- a/internal/config/config_test.go
+++ b/internal/config/config_test.go
@@ -19,9 +19,8 @@ func newTestParams(t *testing.T) config.Params {
t.Helper()
g := &globals.Globals{
- Appname: "dnswatcher",
- Version: "test",
- Buildarch: "amd64",
+ Appname: "dnswatcher",
+ Version: "test",
}
l, err := logger.New(nil, logger.Params{Globals: g})
diff --git a/internal/globals/globals.go b/internal/globals/globals.go
index 02ce645..0b51cff 100644
--- a/internal/globals/globals.go
+++ b/internal/globals/globals.go
@@ -12,17 +12,15 @@ import (
//
//nolint:gochecknoglobals // Required for ldflags injection at build time
var (
- mu sync.RWMutex
- appname string
- version string
- buildarch string
+ mu sync.RWMutex
+ appname string
+ version string
)
// Globals holds build-time variables for dependency injection.
type Globals struct {
- Appname string
- Version string
- Buildarch string
+ Appname string
+ Version string
}
// New creates a new Globals instance from package-level variables.
@@ -31,9 +29,8 @@ func New(_ fx.Lifecycle) (*Globals, error) {
defer mu.RUnlock()
return &Globals{
- Appname: appname,
- Version: version,
- Buildarch: buildarch,
+ Appname: appname,
+ Version: version,
}, nil
}
@@ -52,11 +49,3 @@ func SetVersion(ver string) {
version = ver
}
-
-// SetBuildarch sets the build architecture.
-func SetBuildarch(arch string) {
- mu.Lock()
- defer mu.Unlock()
-
- buildarch = arch
-}
diff --git a/internal/handlers/dashboard.go b/internal/handlers/dashboard.go
new file mode 100644
index 0000000..6d4ac57
--- /dev/null
+++ b/internal/handlers/dashboard.go
@@ -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)
+}
diff --git a/internal/handlers/dashboard_test.go b/internal/handlers/dashboard_test.go
new file mode 100644
index 0000000..554cf9d
--- /dev/null
+++ b/internal/handlers/dashboard_test.go
@@ -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)
+ }
+}
diff --git a/internal/handlers/export_test.go b/internal/handlers/export_test.go
new file mode 100644
index 0000000..53165b9
--- /dev/null
+++ b/internal/handlers/export_test.go
@@ -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)
+}
diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go
index 474c1bc..1ecd7e2 100644
--- a/internal/handlers/handlers.go
+++ b/internal/handlers/handlers.go
@@ -11,6 +11,8 @@ import (
"sneak.berlin/go/dnswatcher/internal/globals"
"sneak.berlin/go/dnswatcher/internal/healthcheck"
"sneak.berlin/go/dnswatcher/internal/logger"
+ "sneak.berlin/go/dnswatcher/internal/notify"
+ "sneak.berlin/go/dnswatcher/internal/state"
)
// Params contains dependencies for Handlers.
@@ -20,23 +22,29 @@ type Params struct {
Logger *logger.Logger
Globals *globals.Globals
Healthcheck *healthcheck.Healthcheck
+ State *state.State
+ Notify *notify.Service
}
// Handlers provides HTTP request handlers.
type Handlers struct {
- log *slog.Logger
- params *Params
- globals *globals.Globals
- hc *healthcheck.Healthcheck
+ log *slog.Logger
+ params *Params
+ globals *globals.Globals
+ hc *healthcheck.Healthcheck
+ state *state.State
+ notifyHistory *notify.AlertHistory
}
// New creates a new Handlers instance.
func New(_ fx.Lifecycle, params Params) (*Handlers, error) {
return &Handlers{
- log: params.Logger.Get(),
- params: ¶ms,
- globals: params.Globals,
- hc: params.Healthcheck,
+ log: params.Logger.Get(),
+ params: ¶ms,
+ globals: params.Globals,
+ hc: params.Healthcheck,
+ state: params.State,
+ notifyHistory: params.Notify.History(),
}, nil
}
diff --git a/internal/handlers/templates/dashboard.html b/internal/handlers/templates/dashboard.html
new file mode 100644
index 0000000..5ee6063
--- /dev/null
+++ b/internal/handlers/templates/dashboard.html
@@ -0,0 +1,370 @@
+
+
+
+
+
+
+ dnswatcher
+
+
+
+
+ {{/* ---- Header ---- */}}
+
+
+ dnswatcher
+
+
+ state updated {{ .StateAge }} · page generated
+ {{ .GeneratedAt }} UTC · auto-refresh 30s
+
+
+
+ {{/* ---- Summary bar ---- */}}
+
+
+
+ Domains
+
+
+ {{ len .Snapshot.Domains }}
+
+
+
+
+ Hostnames
+
+
+ {{ len .Snapshot.Hostnames }}
+
+
+
+
+ Ports
+
+
+ {{ len .Snapshot.Ports }}
+
+
+
+
+ Certificates
+
+
+ {{ len .Snapshot.Certificates }}
+
+
+
+
+ {{/* ---- Domains ---- */}}
+
+
+ Domains
+
+ {{ if .Snapshot.Domains }}
+
+
+
+
+ | Domain |
+ Nameservers |
+ Checked |
+
+
+
+ {{ range $name, $ds := .Snapshot.Domains }}
+
+ |
+ {{ $name }}
+ |
+
+ {{ joinStrings $ds.Nameservers ", " }}
+ |
+
+ {{ relTime $ds.LastChecked }}
+ |
+
+ {{ end }}
+
+
+
+ {{ else }}
+
+ No domains configured.
+
+ {{ end }}
+
+
+ {{/* ---- Hostnames ---- */}}
+
+
+ Hostnames
+
+ {{ if .Snapshot.Hostnames }}
+
+
+
+
+ | Hostname |
+ NS |
+ Status |
+ Records |
+ Checked |
+
+
+
+ {{ range $name, $hs := .Snapshot.Hostnames }}
+ {{ range $ns, $nsr := $hs.RecordsByNameserver }}
+
+ |
+ {{ $name }}
+ |
+
+ {{ $ns }}
+ |
+
+ {{ if eq $nsr.Status "ok" }}
+ ok
+ {{ else }}
+ {{ $nsr.Status }}
+ {{ end }}
+ |
+
+ {{ formatRecords $nsr.Records }}
+ |
+
+ {{ relTime $nsr.LastChecked }}
+ |
+
+ {{ end }}
+ {{ end }}
+
+
+
+ {{ else }}
+
+ No hostnames configured.
+
+ {{ end }}
+
+
+ {{/* ---- Ports ---- */}}
+
+
+ Ports
+
+ {{ if .Snapshot.Ports }}
+
+
+
+
+ | Address |
+ State |
+ Hostnames |
+ Checked |
+
+
+
+ {{ range $key, $ps := .Snapshot.Ports }}
+
+ |
+ {{ $key }}
+ |
+
+ {{ if $ps.Open }}
+ open
+ {{ else }}
+ closed
+ {{ end }}
+ |
+
+ {{ joinStrings $ps.Hostnames ", " }}
+ |
+
+ {{ relTime $ps.LastChecked }}
+ |
+
+ {{ end }}
+
+
+
+ {{ else }}
+
+ No port data yet.
+
+ {{ end }}
+
+
+ {{/* ---- Certificates ---- */}}
+
+
+ Certificates
+
+ {{ if .Snapshot.Certificates }}
+
+
+
+
+ | Endpoint |
+ Status |
+ CN |
+ Issuer |
+ Expires |
+ Checked |
+
+
+
+ {{ range $key, $cs := .Snapshot.Certificates }}
+
+ |
+ {{ $key }}
+ |
+
+ {{ if eq $cs.Status "ok" }}
+ ok
+ {{ else }}
+ {{ $cs.Status }}
+ {{ end }}
+ |
+
+ {{ $cs.CommonName }}
+ |
+
+ {{ $cs.Issuer }}
+ |
+
+ {{ if not $cs.NotAfter.IsZero }}
+ {{ $days := expiryDays $cs.NotAfter }}
+ {{ if lt $days 7 }}
+ {{ $cs.NotAfter.Format "2006-01-02" }}
+ ({{ $days }}d)
+ {{ else if lt $days 30 }}
+ {{ $cs.NotAfter.Format "2006-01-02" }}
+ ({{ $days }}d)
+ {{ else }}
+ {{ $cs.NotAfter.Format "2006-01-02" }}
+ ({{ $days }}d)
+ {{ end }}
+ {{ end }}
+ |
+
+ {{ relTime $cs.LastChecked }}
+ |
+
+ {{ end }}
+
+
+
+ {{ else }}
+
+ No certificate data yet.
+
+ {{ end }}
+
+
+ {{/* ---- Recent Alerts ---- */}}
+
+
+ Recent Alerts ({{ len .Alerts }})
+
+ {{ if .Alerts }}
+
+ {{ range .Alerts }}
+
+
+ {{ if eq .Priority "error" }}
+ error
+ {{ else if eq .Priority "warning" }}
+ warning
+ {{ else if eq .Priority "success" }}
+ success
+ {{ else }}
+ info
+ {{ end }}
+
+ {{ .Title }}
+
+
+ {{ .Timestamp.Format "2006-01-02 15:04:05" }} UTC
+ ({{ relTime .Timestamp }})
+
+
+
+ {{ .Message }}
+
+
+ {{ end }}
+
+ {{ else }}
+
+ No alerts recorded since last restart.
+
+ {{ end }}
+
+
+ {{/* ---- Footer ---- */}}
+
+ dnswatcher · monitoring {{ len .Snapshot.Domains }} domains +
+ {{ len .Snapshot.Hostnames }} hostnames
+
+
+
+
diff --git a/internal/logger/logger.go b/internal/logger/logger.go
index 0bcdd28..8aaaad1 100644
--- a/internal/logger/logger.go
+++ b/internal/logger/logger.go
@@ -78,6 +78,5 @@ func (l *Logger) Identify() {
l.log.Info("starting",
"appname", l.params.Globals.Appname,
"version", l.params.Globals.Version,
- "buildarch", l.params.Globals.Buildarch,
)
}
diff --git a/internal/notify/export_test.go b/internal/notify/export_test.go
index f2671e0..0f02fdd 100644
--- a/internal/notify/export_test.go
+++ b/internal/notify/export_test.go
@@ -34,6 +34,7 @@ func NewTestService(transport http.RoundTripper) *Service {
return &Service{
log: slog.New(slog.DiscardHandler),
transport: transport,
+ history: NewAlertHistory(),
}
}
diff --git a/internal/notify/history.go b/internal/notify/history.go
new file mode 100644
index 0000000..53a9b97
--- /dev/null
+++ b/internal/notify/history.go
@@ -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
+}
diff --git a/internal/notify/history_test.go b/internal/notify/history_test.go
new file mode 100644
index 0000000..a60804d
--- /dev/null
+++ b/internal/notify/history_test.go
@@ -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),
+ )
+ }
+}
diff --git a/internal/notify/notify.go b/internal/notify/notify.go
index c61e092..b36be7c 100644
--- a/internal/notify/notify.go
+++ b/internal/notify/notify.go
@@ -112,6 +112,7 @@ type Service struct {
ntfyURL *url.URL
slackWebhookURL *url.URL
mattermostWebhookURL *url.URL
+ history *AlertHistory
}
// New creates a new notify Service.
@@ -123,6 +124,7 @@ func New(
log: params.Logger.Get(),
transport: http.DefaultTransport,
config: params.Config,
+ history: NewAlertHistory(),
}
if params.Config.NtfyTopic != "" {
@@ -167,65 +169,99 @@ func New(
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
-// endpoints.
+// endpoints and records it in the alert history.
func (svc *Service) SendNotification(
ctx context.Context,
title, message, priority string,
) {
- if svc.ntfyURL != nil {
- go func() {
- notifyCtx := context.WithoutCancel(ctx)
+ svc.history.Add(AlertEntry{
+ Timestamp: time.Now().UTC(),
+ Title: title,
+ Message: message,
+ Priority: priority,
+ })
- err := svc.sendNtfy(
- notifyCtx,
- svc.ntfyURL,
- title, message, priority,
- )
- if err != nil {
- svc.log.Error(
- "failed to send ntfy notification",
- "error", err,
- )
- }
- }()
+ 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
}
- if svc.slackWebhookURL != nil {
- go func() {
- notifyCtx := context.WithoutCancel(ctx)
+ go func() {
+ notifyCtx := context.WithoutCancel(ctx)
- err := svc.sendSlack(
- notifyCtx,
- svc.slackWebhookURL,
- title, message, priority,
+ err := svc.sendNtfy(
+ notifyCtx, svc.ntfyURL,
+ title, message, priority,
+ )
+ if err != nil {
+ svc.log.Error(
+ "failed to send ntfy notification",
+ "error", err,
)
- if err != nil {
- svc.log.Error(
- "failed to send slack notification",
- "error", err,
- )
- }
- }()
+ }
+ }()
+}
+
+func (svc *Service) dispatchSlack(
+ ctx context.Context,
+ title, message, priority string,
+) {
+ if svc.slackWebhookURL == nil {
+ return
}
- if svc.mattermostWebhookURL != nil {
- go func() {
- notifyCtx := context.WithoutCancel(ctx)
+ go func() {
+ notifyCtx := context.WithoutCancel(ctx)
- err := svc.sendSlack(
- notifyCtx,
- svc.mattermostWebhookURL,
- title, message, priority,
+ err := svc.sendSlack(
+ notifyCtx, svc.slackWebhookURL,
+ title, message, priority,
+ )
+ if err != nil {
+ svc.log.Error(
+ "failed to send slack notification",
+ "error", err,
)
- if err != nil {
- svc.log.Error(
- "failed to send mattermost notification",
- "error", err,
- )
- }
- }()
+ }
+ }()
+}
+
+func (svc *Service) dispatchMattermost(
+ ctx context.Context,
+ title, message, priority string,
+) {
+ if svc.mattermostWebhookURL == nil {
+ return
}
+
+ go func() {
+ notifyCtx := context.WithoutCancel(ctx)
+
+ err := svc.sendSlack(
+ notifyCtx, svc.mattermostWebhookURL,
+ title, message, priority,
+ )
+ if err != nil {
+ svc.log.Error(
+ "failed to send mattermost notification",
+ "error", err,
+ )
+ }
+ }()
}
func (svc *Service) sendNtfy(
diff --git a/internal/server/routes.go b/internal/server/routes.go
index cd07ba1..fa99177 100644
--- a/internal/server/routes.go
+++ b/internal/server/routes.go
@@ -1,11 +1,14 @@
package server
import (
+ "net/http"
"time"
"github.com/go-chi/chi/v5"
chimw "github.com/go-chi/chi/v5/middleware"
"github.com/prometheus/client_golang/prometheus/promhttp"
+
+ "sneak.berlin/go/dnswatcher/static"
)
// requestTimeout is the maximum duration for handling a request.
@@ -22,7 +25,25 @@ func (s *Server) SetupRoutes() {
s.router.Use(s.mw.CORS())
s.router.Use(chimw.Timeout(requestTimeout))
- // Health check
+ // Dashboard (read-only web UI)
+ s.router.Get("/", s.handlers.HandleDashboard())
+
+ // Static assets (embedded CSS/JS)
+ s.router.Mount(
+ "/s",
+ http.StripPrefix(
+ "/s",
+ http.FileServer(http.FS(static.Static)),
+ ),
+ )
+
+ // Health check (standard well-known path)
+ s.router.Get(
+ "/.well-known/healthcheck",
+ s.handlers.HandleHealthCheck(),
+ )
+
+ // Legacy health check (keep for backward compatibility)
s.router.Get("/health", s.handlers.HandleHealthCheck())
// API v1 routes
diff --git a/static/css/tailwind.min.css b/static/css/tailwind.min.css
new file mode 100644
index 0000000..ade9507
--- /dev/null
+++ b/static/css/tailwind.min.css
@@ -0,0 +1 @@
+*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.17 | MIT License | https://tailwindcss.com*/*,:after,:before{border:0 solid #e5e7eb;box-sizing:border-box}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-tap-highlight-color:transparent}body{line-height:inherit;margin:0}hr{border-top-width:1px;color:inherit;height:0}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-size:1em;font-variation-settings:normal}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{border-collapse:collapse;border-color:inherit;text-indent:0}button,input,optgroup,select,textarea{color:inherit;font-family:inherit;font-feature-settings:inherit;font-size:100%;font-variation-settings:inherit;font-weight:inherit;letter-spacing:inherit;line-height:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{color:#9ca3af;opacity:1}input::placeholder,textarea::placeholder{color:#9ca3af;opacity:1}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{height:auto;max-width:100%}[hidden]:where(:not([hidden=until-found])){display:none}.mx-auto{margin-left:auto;margin-right:auto}.mb-1{margin-bottom:.25rem}.mb-3{margin-bottom:.75rem}.mb-8{margin-bottom:2rem}.ml-auto{margin-left:auto}.mt-1{margin-top:.25rem}.mt-8{margin-top:2rem}.inline-block{display:inline-block}.flex{display:flex}.table{display:table}.grid{display:grid}.min-h-screen{min-height:100vh}.w-full{width:100%}.max-w-6xl{max-width:72rem}.max-w-xs{max-width:20rem}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.items-center{align-items:center}.gap-3{gap:.75rem}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.5rem*var(--tw-space-y-reverse));margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)))}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse:0;border-bottom-width:calc(1px*var(--tw-divide-y-reverse));border-top-width:calc(1px*(1 - var(--tw-divide-y-reverse)))}.divide-slate-800>:not([hidden])~:not([hidden]){--tw-divide-opacity:1;border-color:rgb(30 41 59/var(--tw-divide-opacity,1))}.overflow-x-auto{overflow-x:auto}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-line{white-space:pre-line}.break-all{word-break:break-all}.rounded{border-radius:.25rem}.rounded-lg{border-radius:.5rem}.border{border-width:1px}.border-b{border-bottom-width:1px}.border-t{border-top-width:1px}.border-amber-700\/30{border-color:rgba(180,83,9,.3)}.border-amber-700\/40{border-color:rgba(180,83,9,.4)}.border-blue-700\/30{border-color:rgba(29,78,216,.3)}.border-blue-700\/40{border-color:rgba(29,78,216,.4)}.border-red-700\/30{border-color:rgba(185,28,28,.3)}.border-red-700\/40{border-color:rgba(185,28,28,.4)}.border-slate-700\/50{border-color:rgba(51,65,85,.5)}.border-slate-800{--tw-border-opacity:1;border-color:rgb(30 41 59/var(--tw-border-opacity,1))}.border-teal-700\/30{border-color:rgba(15,118,110,.3)}.border-teal-700\/40{border-color:rgba(15,118,110,.4)}.bg-amber-900\/50{background-color:rgba(120,53,15,.5)}.bg-blue-900\/50{background-color:rgba(30,58,138,.5)}.bg-red-900\/50{background-color:rgba(127,29,29,.5)}.bg-slate-950{--tw-bg-opacity:1;background-color:rgb(2 6 23/var(--tw-bg-opacity,1))}.bg-surface-800{--tw-bg-opacity:1;background-color:rgb(26 35 50/var(--tw-bg-opacity,1))}.bg-surface-950{--tw-bg-opacity:1;background-color:rgb(12 18 34/var(--tw-bg-opacity,1))}.bg-teal-900\/50{background-color:rgba(19,78,74,.5)}.p-4{padding:1rem}.px-1\.5{padding-left:.375rem;padding-right:.375rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.py-0\.5{padding-bottom:.125rem;padding-top:.125rem}.py-2{padding-bottom:.5rem;padding-top:.5rem}.py-3{padding-bottom:.75rem;padding-top:.75rem}.py-8{padding-bottom:2rem;padding-top:2rem}.pb-2{padding-bottom:.5rem}.pl-0\.5{padding-left:.125rem}.pt-4{padding-top:1rem}.text-left{text-align:left}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.italic{font-style:italic}.tracking-tight{letter-spacing:-.025em}.tracking-wider{letter-spacing:.05em}.text-amber-400{--tw-text-opacity:1;color:rgb(251 191 36/var(--tw-text-opacity,1))}.text-blue-400{--tw-text-opacity:1;color:rgb(96 165 250/var(--tw-text-opacity,1))}.text-red-400{--tw-text-opacity:1;color:rgb(248 113 113/var(--tw-text-opacity,1))}.text-slate-200{--tw-text-opacity:1;color:rgb(226 232 240/var(--tw-text-opacity,1))}.text-slate-300{--tw-text-opacity:1;color:rgb(203 213 225/var(--tw-text-opacity,1))}.text-slate-400{--tw-text-opacity:1;color:rgb(148 163 184/var(--tw-text-opacity,1))}.text-slate-500{--tw-text-opacity:1;color:rgb(100 116 139/var(--tw-text-opacity,1))}.text-slate-600{--tw-text-opacity:1;color:rgb(71 85 105/var(--tw-text-opacity,1))}.text-slate-700{--tw-text-opacity:1;color:rgb(51 65 85/var(--tw-text-opacity,1))}.text-teal-300{--tw-text-opacity:1;color:rgb(94 234 212/var(--tw-text-opacity,1))}.text-teal-400{--tw-text-opacity:1;color:rgb(45 212 191/var(--tw-text-opacity,1))}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.hover\:bg-surface-800\/50:hover{background-color:rgba(26,35,50,.5)}@media (min-width:640px){.sm\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}
\ No newline at end of file
diff --git a/static/static.go b/static/static.go
new file mode 100644
index 0000000..28ee133
--- /dev/null
+++ b/static/static.go
@@ -0,0 +1,10 @@
+// Package static provides embedded static assets.
+package static
+
+import "embed"
+
+// Static contains the embedded static assets (CSS, JS) served
+// at the /s/ URL prefix.
+//
+//go:embed css
+var Static embed.FS