Console: replace the dashboard, logs and cache pages with a status page and links

With the resolver no longer storing traffic history or a query log, the pages
built on them have nothing to show. The Dashboard becomes a Status page: health
and the reasons for a degraded state, uptime, what is compiled (blocklist rules,
local zone records), the cache with a flush button, per-pod upstream health and
the listeners. Logs and Cache are removed along with the SSE client, the chart
code and the recharts dependency.

api.links (grafana, logs, metrics) names where the charts and the query log
live. GET /api/links serves them and the Status page shows buttons for the ones
that are set, or says how to set them. They are rendered as hrefs, so config
validation refuses anything but http(s) and the page checks again.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
matsandClaude Sonnet 5.5 committed 2026-10-16 09:56:26 +02:00
1 parent c6c5ca4ff7
commit 01202f9785
17 files changed
+277 -1980

No files matched your search

+1
View File
@@ -235,6 +235,7 @@ func (s *Server) routes(cfg *config.Config) *http.ServeMux {
// Everything below requires a principal.
mux.Handle("GET "+base+"status", s.authed(s.handleStatus, false))
mux.Handle("GET "+base+"links", s.authed(s.handleLinks, false))
mux.Handle("GET "+base+"blocklists", s.authed(s.handleBlocklists, false))
mux.Handle("POST "+base+"blocklists/refresh", s.authed(s.handleBlocklistRefresh, true))
mux.Handle("POST "+base+"blocklists/toggle", s.authed(s.handleBlocklistToggle, true))
+10
View File
@@ -0,0 +1,10 @@
package api
import "net/http"
// handleLinks returns the configured pointers to the metrics and log tools. The
// console builds its "Dashboards and logs" buttons from it; an empty value
// means that tool is not set up.
func (s *Server) handleLinks(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, s.hooks.Store.Get().API.Links)
}
+27
View File
@@ -0,0 +1,27 @@
package app
import (
"encoding/json"
"testing"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/config"
)
func TestLinksAreServedToTheConsole(t *testing.T) {
h := newHarness(t, func(c *config.Config) {
c.API.Links.Grafana = "https://grafana.example/d/dns"
c.API.Links.Logs = "https://grafana.example/explore"
})
code, body := h.get("/api/links")
if code != 200 {
t.Fatalf("GET /api/links = %d: %s", code, body)
}
var got config.Links
if err := json.Unmarshal(body, &got); err != nil {
t.Fatal(err)
}
if got.Grafana != "https://grafana.example/d/dns" || got.Logs == "" || got.Metrics != "" {
t.Fatalf("links = %+v", got)
}
requireKeys(t, "GET /api/links", keysOf(t, body), wantKeys("grafana", "logs", "metrics"))
}
+21
View File
@@ -490,6 +490,18 @@ type API struct {
// because the handlers take no API key and pprof/cmdline leaks the
// command line.
Pprof bool `yaml:"pprof"`
// Links point the console at the tools that show what the resolver itself
// does not keep: traffic charts and the query log. All optional.
Links Links `yaml:"links"`
}
// Links are http(s) URLs the console shows as buttons. The resolver stores no
// traffic history and no query log, so these are where an operator goes for
// them (a Grafana dashboard, a log view in Loki).
type Links struct {
Grafana string `yaml:"grafana" json:"grafana"`
Logs string `yaml:"logs" json:"logs"`
Metrics string `yaml:"metrics" json:"metrics"`
}
type Auth struct {
@@ -1199,6 +1211,15 @@ func (c *Config) Validate() error {
add("cluster.valkey.addrs must not be empty when cluster.enabled is true")
}
}
for name, link := range map[string]string{
"grafana": c.API.Links.Grafana, "logs": c.API.Links.Logs, "metrics": c.API.Links.Metrics,
} {
// The console renders these as hrefs, so a scheme other than http(s)
// (javascript:, data:) is refused here rather than trusted there.
if link != "" && !strings.HasPrefix(link, "https://") && !strings.HasPrefix(link, "http://") {
add("api.links.%s must be an http(s) URL", name)
}
}
switch c.API.Auth.Mode {
case "none", "apikey", "basic", "oidc":
default:
+25
View File
@@ -0,0 +1,25 @@
package config
import (
"strings"
"testing"
)
// TestLinksRefuseOtherSchemes: the console renders these as hrefs, so a
// javascript: or data: URL in the config must not get that far.
func TestLinksRefuseOtherSchemes(t *testing.T) {
for _, bad := range []string{"javascript:alert(1)", "data:text/html,x", "//grafana.example", "grafana.example/d/1"} {
c := Default()
c.API.Links.Grafana = bad
err := c.Validate()
if err == nil || !strings.Contains(err.Error(), "api.links.grafana") {
t.Errorf("%q accepted: %v", bad, err)
}
}
c := Default()
c.Block.Enabled = false
c.API.Links = Links{Grafana: "https://g.example/d/1", Logs: "http://loki.internal/x"}
if err := c.Validate(); err != nil {
t.Errorf("valid links rejected: %v", err)
}
}
+1
View File
@@ -78,6 +78,7 @@ func (c *Console) Handler() (http.Handler, error) {
for _, p := range []string{
base + "/auth/",
"GET " + base + "/whoami",
"GET " + base + "/links",
"GET " + base + "/healthz",
"PUT " + base + "/clients",
"POST " + base + "/zones/records",