From 713a758c83c59afe0edc35bb55f8caac2914e381 Mon Sep 17 00:00:00 2001 From: user Date: Wed, 4 Mar 2026 03:06:07 -0800 Subject: [PATCH 1/3] feat: add unauthenticated web dashboard showing monitoring state and recent alerts Add a read-only web dashboard at GET / that displays: - Summary counts for all monitored resources - Domain nameserver state with per-NS records and status - Hostname DNS records per authoritative nameserver - TCP port open/closed state with associated hostnames - TLS certificate details (CN, issuer, expiry, status) - Last 100 alerts in reverse chronological order Every data point shows relative age (e.g. '5m ago') for freshness. Page auto-refreshes every 30 seconds via meta refresh. Uses Tailwind CSS via CDN for a dark, technical aesthetic with saturated teals and blues on dark slate. Single page, no navigation. Implementation: - internal/notify/history.go: thread-safe ring buffer (last 100 alerts) - internal/notify/notify.go: record alerts in history before dispatch, refactor SendNotification into smaller dispatch helpers (funlen) - internal/handlers/dashboard.go: template rendering with embedded HTML, helper functions for relative time, record formatting, expiry days - internal/handlers/templates/dashboard.html: Tailwind-styled dashboard - internal/handlers/handlers.go: add State and Notify dependencies - internal/server/routes.go: register GET / dashboard route - README.md: document dashboard and new / endpoint No secrets (webhook URLs, API tokens, notification endpoints) are exposed in the dashboard. closes #82 --- README.md | 22 ++ internal/handlers/dashboard.go | 151 ++++++++ internal/handlers/dashboard_test.go | 80 +++++ internal/handlers/export_test.go | 18 + internal/handlers/handlers.go | 24 +- internal/handlers/templates/dashboard.html | 386 +++++++++++++++++++++ internal/notify/export_test.go | 1 + internal/notify/history.go | 62 ++++ internal/notify/history_test.go | 88 +++++ internal/notify/notify.go | 124 ++++--- internal/server/routes.go | 3 + 11 files changed, 907 insertions(+), 52 deletions(-) create mode 100644 internal/handlers/dashboard.go create mode 100644 internal/handlers/dashboard_test.go create mode 100644 internal/handlers/export_test.go create mode 100644 internal/handlers/templates/dashboard.html create mode 100644 internal/notify/history.go create mode 100644 internal/notify/history_test.go diff --git a/README.md b/README.md index c5d104b..44aebab 100644 --- a/README.md +++ b/README.md @@ -124,12 +124,34 @@ 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. + ### HTTP API dnswatcher exposes a lightweight HTTP API for operational visibility: | Endpoint | Description | |---------------------------------------|--------------------------------| +| `GET /` | Web dashboard (HTML) | | `GET /health` | Health check (JSON) | | `GET /api/v1/status` | Current monitoring state | | `GET /metrics` | Prometheus metrics (optional) | 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..8da6ba4 --- /dev/null +++ b/internal/handlers/templates/dashboard.html @@ -0,0 +1,386 @@ + + + + + + + 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 }} +
+ + + + + + + + + + {{ range $name, $ds := .Snapshot.Domains }} + + + + + + {{ end }} + +
DomainNameserversChecked
+ {{ $name }} + + {{ joinStrings $ds.Nameservers ", " }} + + {{ relTime $ds.LastChecked }} +
+
+ {{ else }} +

+ No domains configured. +

+ {{ end }} +
+ + {{/* ---- Hostnames ---- */}} +
+

+ Hostnames +

+ {{ if .Snapshot.Hostnames }} +
+ + + + + + + + + + + + {{ range $name, $hs := .Snapshot.Hostnames }} + {{ range $ns, $nsr := $hs.RecordsByNameserver }} + + + + + + + + {{ end }} + {{ end }} + +
HostnameNSStatusRecordsChecked
+ {{ $name }} + + {{ $ns }} + + {{ if eq $nsr.Status "ok" }} + ok + {{ else }} + {{ $nsr.Status }} + {{ end }} + + {{ formatRecords $nsr.Records }} + + {{ relTime $nsr.LastChecked }} +
+
+ {{ else }} +

+ No hostnames configured. +

+ {{ end }} +
+ + {{/* ---- Ports ---- */}} +
+

+ Ports +

+ {{ if .Snapshot.Ports }} +
+ + + + + + + + + + + {{ range $key, $ps := .Snapshot.Ports }} + + + + + + + {{ end }} + +
AddressStateHostnamesChecked
+ {{ $key }} + + {{ if $ps.Open }} + open + {{ else }} + closed + {{ end }} + + {{ joinStrings $ps.Hostnames ", " }} + + {{ relTime $ps.LastChecked }} +
+
+ {{ else }} +

+ No port data yet. +

+ {{ end }} +
+ + {{/* ---- Certificates ---- */}} +
+

+ Certificates +

+ {{ if .Snapshot.Certificates }} +
+ + + + + + + + + + + + + {{ range $key, $cs := .Snapshot.Certificates }} + + + + + + + + + {{ end }} + +
EndpointStatusCNIssuerExpiresChecked
+ {{ $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 }} +
+
+ {{ 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/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..7303897 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -22,6 +22,9 @@ func (s *Server) SetupRoutes() { s.router.Use(s.mw.CORS()) s.router.Use(chimw.Timeout(requestTimeout)) + // Dashboard (read-only web UI) + s.router.Get("/", s.handlers.HandleDashboard()) + // Health check s.router.Get("/health", s.handlers.HandleHealthCheck()) -- 2.49.1 From c15ca77bd71f62ae22884d4f45a6ef63864fca27 Mon Sep 17 00:00:00 2001 From: clawbot Date: Wed, 4 Mar 2026 03:20:04 -0800 Subject: [PATCH 2/3] vendor Tailwind CSS, embed all static assets in binary Remove CDN dependency (cdn.tailwindcss.com) and replace with a pre-built, minified Tailwind CSS file embedded in the Go binary via go:embed. Changes: - Add static/static.go with go:embed for css/ directory - Add static/css/tailwind.min.css (9KB, contains only classes used by the dashboard template) - Remove - + :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 -- 2.49.1 From c96ed42c319d4323c765f350337d6ec072fde687 Mon Sep 17 00:00:00 2001 From: clawbot Date: Wed, 4 Mar 2026 03:48:45 -0800 Subject: [PATCH 3/3] fix: remove Buildarch from globals, main, Makefile, logger, and tests Buildarch was erroneously included. Remove it from: - internal/globals/globals.go (struct field, package var, setter) - cmd/dnswatcher/main.go (var declaration, setter call) - Makefile (BUILDARCH var, ldflags) - internal/logger/logger.go (Identify log field) - internal/config/config_test.go (test fixture) - README.md (build command, architecture section) --- Makefile | 3 +-- README.md | 7 +++---- cmd/dnswatcher/main.go | 6 ++---- internal/config/config_test.go | 5 ++--- internal/globals/globals.go | 25 +++++++------------------ internal/logger/logger.go | 1 - 6 files changed, 15 insertions(+), 32 deletions(-) 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 7c87c2e..eb52839 100644 --- a/README.md +++ b/README.md @@ -171,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) @@ -363,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/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, ) } -- 2.49.1