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:
1 parent
c6c5ca4ff7
commit
01202f9785
17 files changed
+277
-1980
No files matched your search
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user