diff --git a/deploy/config.example.yaml b/deploy/config.example.yaml index c589b89..a5a3044 100644 --- a/deploy/config.example.yaml +++ b/deploy/config.example.yaml @@ -201,6 +201,11 @@ api: # client cannot spoof its identity past the ACL or the rate limiter. trusted_proxies: [] cors_origins: [] + # Optional buttons on the console's Status page. The resolver keeps no traffic + # history or query log of its own; these point at where they live. + # links: + # grafana: https://grafana.example/d/fancy-dns + # logs: https://grafana.example/explore?left=... auth: # none | apikey | basic | oidc mode: apikey diff --git a/internal/api/api.go b/internal/api/api.go index a6a8219..e40519d 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -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)) diff --git a/internal/api/links.go b/internal/api/links.go new file mode 100644 index 0000000..0123dd7 --- /dev/null +++ b/internal/api/links.go @@ -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) +} diff --git a/internal/app/links_test.go b/internal/app/links_test.go new file mode 100644 index 0000000..cb043cc --- /dev/null +++ b/internal/app/links_test.go @@ -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")) +} diff --git a/internal/config/config.go b/internal/config/config.go index d732fd2..edb2e85 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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: diff --git a/internal/config/links_test.go b/internal/config/links_test.go new file mode 100644 index 0000000..4a77b74 --- /dev/null +++ b/internal/config/links_test.go @@ -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) + } +} diff --git a/internal/console/console.go b/internal/console/console.go index 8832851..72023b0 100644 --- a/internal/console/console.go +++ b/internal/console/console.go @@ -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", diff --git a/web/package-lock.json b/web/package-lock.json index 2fbb040..b81e2cc 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -12,8 +12,7 @@ "lucide-react": "^1.48.0", "react": "^19.2.8", "react-dom": "^19.2.8", - "react-router-dom": "^7.18.4", - "recharts": "^3.10.1" + "react-router-dom": "^7.18.4" }, "devDependencies": { "@tailwindcss/vite": "^4.3.3", @@ -434,32 +433,6 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@reduxjs/toolkit": { - "version": "2.12.0", - "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz", - "integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==", - "license": "MIT", - "dependencies": { - "@standard-schema/spec": "^1.0.0", - "@standard-schema/utils": "^0.3.0", - "immer": "^11.0.0", - "redux": "^5.0.1", - "redux-thunk": "^3.1.0", - "reselect": "^5.1.0" - }, - "peerDependencies": { - "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", - "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" - }, - "peerDependenciesMeta": { - "react": { - "optional": true - }, - "react-redux": { - "optional": true - } - } - }, "node_modules/@rolldown/binding-android-arm-eabi": { "version": "1.2.11", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.11.tgz", @@ -740,18 +713,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@standard-schema/spec": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", - "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "license": "MIT" - }, - "node_modules/@standard-schema/utils": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", - "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", - "license": "MIT" - }, "node_modules/@tailwindcss/node": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", @@ -1309,69 +1270,6 @@ "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, - "node_modules/@types/d3-array": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", - "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", - "license": "MIT" - }, - "node_modules/@types/d3-color": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", - "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", - "license": "MIT" - }, - "node_modules/@types/d3-ease": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", - "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", - "license": "MIT" - }, - "node_modules/@types/d3-interpolate": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", - "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", - "license": "MIT", - "dependencies": { - "@types/d3-color": "*" - } - }, - "node_modules/@types/d3-path": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", - "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", - "license": "MIT" - }, - "node_modules/@types/d3-scale": { - "version": "4.0.9", - "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", - "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", - "license": "MIT", - "dependencies": { - "@types/d3-time": "*" - } - }, - "node_modules/@types/d3-shape": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.2.0.tgz", - "integrity": "sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==", - "license": "MIT", - "dependencies": { - "@types/d3-path": "*" - } - }, - "node_modules/@types/d3-time": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", - "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", - "license": "MIT" - }, - "node_modules/@types/d3-timer": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", - "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", - "license": "MIT" - }, "node_modules/@types/node": { "version": "24.19.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.0.tgz", @@ -1386,7 +1284,7 @@ "version": "19.3.0", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -1402,12 +1300,6 @@ "@types/react": "^19.3.0" } }, - "node_modules/@types/use-sync-external-store": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", - "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", - "license": "MIT" - }, "node_modules/@vitejs/plugin-react": { "version": "6.1.1", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz", @@ -1464,134 +1356,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/d3-array": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", - "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", - "license": "ISC", - "dependencies": { - "internmap": "1 - 2" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-color": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", - "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-ease": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", - "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-format": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", - "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-interpolate": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", - "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", - "license": "ISC", - "dependencies": { - "d3-color": "1 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-path": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", - "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-scale": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", - "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", - "license": "ISC", - "dependencies": { - "d3-array": "2.10.0 - 3", - "d3-format": "1 - 3", - "d3-interpolate": "1.2.0 - 3", - "d3-time": "2.1.1 - 3", - "d3-time-format": "2 - 4" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-shape": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", - "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", - "license": "ISC", - "dependencies": { - "d3-path": "^3.1.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-time": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", - "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", - "license": "ISC", - "dependencies": { - "d3-array": "2 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-time-format": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", - "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", - "license": "ISC", - "dependencies": { - "d3-time": "1 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-timer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", - "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/decimal.js-light": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", - "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "dev": true, "license": "MIT" }, "node_modules/detect-libc": { @@ -1618,24 +1383,6 @@ "node": ">=10.13.0" } }, - "node_modules/es-toolkit": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.52.0.tgz", - "integrity": "sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==", - "license": "MIT", - "workspaces": [ - "docs", - "benchmarks", - "tests/types", - "tests/browser-compat" - ] - }, - "node_modules/eventemitter3": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", - "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", - "license": "MIT" - }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1676,25 +1423,6 @@ "dev": true, "license": "ISC" }, - "node_modules/immer": { - "version": "11.1.18", - "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.18.tgz", - "integrity": "sha512-EQyQtLiYW029lyoczMl/Hh4Xu7cDecSc58JRYpHyL4tIAu3eqd1yJzQX04d2BZHDkzFFvm6qJEJWOtfDSWAXbQ==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/immer" - } - }, - "node_modules/internmap": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", - "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, "node_modules/jiti": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", @@ -2135,36 +1863,6 @@ "react": "^19.3.0" } }, - "node_modules/react-is": { - "version": "19.3.0", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.3.0.tgz", - "integrity": "sha512-UpMYezM4v5/18F28aC66AEsjXIgE02kyEMH6yLdgLXu/UTfa1Ntwck/nNLrbqJsEXW7gPb0coNO9FQse9WTovA==", - "license": "MIT", - "peer": true - }, - "node_modules/react-redux": { - "version": "9.3.0", - "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz", - "integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==", - "license": "MIT", - "dependencies": { - "@types/use-sync-external-store": "^0.0.6", - "use-sync-external-store": "^1.4.0" - }, - "peerDependencies": { - "@types/react": "^18.2.25 || ^19", - "react": "^18.0 || ^19", - "redux": "^5.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "redux": { - "optional": true - } - } - }, "node_modules/react-router": { "version": "7.18.4", "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.4.tgz", @@ -2203,57 +1901,6 @@ "react-dom": ">=18" } }, - "node_modules/recharts": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.10.1.tgz", - "integrity": "sha512-QXFrvt6IVcw7eeZCoyXTwkIJAX3Dv1nyVhMicXJ47GsGDDpcN8z6o644DibE9XjpBTThtsomLKnTV6lc+cVFUA==", - "license": "MIT", - "workspaces": [ - "www" - ], - "dependencies": { - "@reduxjs/toolkit": "^1.9.0 || 2.x.x", - "clsx": "^2.1.1", - "decimal.js-light": "^2.5.1", - "es-toolkit": "^1.39.3", - "eventemitter3": "^5.0.1", - "immer": "^11.1.8", - "react-redux": "8.x.x || 9.x.x", - "reselect": "5.2.0", - "tiny-invariant": "^1.3.3", - "use-sync-external-store": "^1.2.2", - "victory-vendor": "^37.0.2" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, - "node_modules/redux": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", - "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT" - }, - "node_modules/redux-thunk": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", - "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", - "license": "MIT", - "peerDependencies": { - "redux": "^5.0.0" - } - }, - "node_modules/reselect": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz", - "integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==", - "license": "MIT" - }, "node_modules/rolldown": { "version": "1.2.11", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.11.tgz", @@ -2331,12 +1978,6 @@ "url": "https://opencollective.com/webpack" } }, - "node_modules/tiny-invariant": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", - "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", - "license": "MIT" - }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -2375,37 +2016,6 @@ "dev": true, "license": "MIT" }, - "node_modules/use-sync-external-store": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.7.0.tgz", - "integrity": "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==", - "license": "MIT", - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, - "node_modules/victory-vendor": { - "version": "37.3.6", - "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", - "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", - "license": "MIT AND ISC", - "dependencies": { - "@types/d3-array": "^3.0.3", - "@types/d3-ease": "^3.0.0", - "@types/d3-interpolate": "^3.0.1", - "@types/d3-scale": "^4.0.2", - "@types/d3-shape": "^3.1.0", - "@types/d3-time": "^3.0.0", - "@types/d3-timer": "^3.0.0", - "d3-array": "^3.1.6", - "d3-ease": "^3.0.1", - "d3-interpolate": "^3.0.1", - "d3-scale": "^4.0.2", - "d3-shape": "^3.1.0", - "d3-time": "^3.0.0", - "d3-timer": "^3.0.1" - } - }, "node_modules/vite": { "version": "8.3.1", "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.1.tgz", diff --git a/web/package.json b/web/package.json index 7e802ab..c1ebd46 100644 --- a/web/package.json +++ b/web/package.json @@ -14,8 +14,7 @@ "lucide-react": "^1.48.0", "react": "^19.2.8", "react-dom": "^19.2.8", - "react-router-dom": "^7.18.4", - "recharts": "^3.10.1" + "react-router-dom": "^7.18.4" }, "devDependencies": { "@tailwindcss/vite": "^4.3.3", diff --git a/web/src/App.tsx b/web/src/App.tsx index e8753f3..8fd0fc0 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -13,12 +13,10 @@ import { BrowserRouter, Route, Routes } from 'react-router-dom' import { ToastProvider } from './components/ui' import { AppShell } from './components/layout' import Blocklists from './pages/Blocklists.tsx' -import Cache from './pages/Cache.tsx' import Clients from './pages/Clients.tsx' import Dashboard from './pages/Dashboard.tsx' import Domains from './pages/Domains.tsx' import Login from './pages/Login.tsx' -import Logs from './pages/Logs.tsx' import LocalZones from './pages/LocalZones.tsx' import Lookup from './pages/Lookup.tsx' import NotFound from './pages/NotFound.tsx' @@ -60,8 +58,6 @@ function AppRoutes() { } /> } /> } /> - } /> - } /> } /> } /> diff --git a/web/src/components/layout/AppShell.tsx b/web/src/components/layout/AppShell.tsx index 6730d09..8ba332c 100644 --- a/web/src/components/layout/AppShell.tsx +++ b/web/src/components/layout/AppShell.tsx @@ -3,7 +3,6 @@ import type { ReactNode } from 'react' import { NavLink, useLocation, useNavigate } from 'react-router-dom' import type { LucideIcon } from 'lucide-react' import { - Database, FileCode2, Globe, LayoutDashboard, @@ -15,7 +14,6 @@ import { Network, MapPin, PanelLeftClose, - ScrollText, Search, Settings, Users, @@ -38,7 +36,7 @@ interface NavItem { } const NAV: NavItem[] = [ - { to: '/', label: 'Dashboard', icon: LayoutDashboard, end: true }, + { to: '/', label: 'Status', icon: LayoutDashboard, end: true }, { to: '/blocklists', label: 'Blocklists', icon: ListChecks }, { to: '/domains', label: 'Domains', icon: Globe }, { to: '/lookup', label: 'Lookup', icon: Search }, @@ -47,8 +45,6 @@ const NAV: NavItem[] = [ { to: '/zones', label: 'Zones', icon: Layers }, { to: '/clients', label: 'Clients', icon: Users }, { to: '/upstreams', label: 'Upstreams', icon: Network }, - { to: '/logs', label: 'Logs', icon: ScrollText }, - { to: '/cache', label: 'Cache', icon: Database }, { to: '/settings', label: 'Settings', icon: Settings }, ] diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 97179a3..e93ca80 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -11,10 +11,8 @@ import type { AddDomainResponse, BlocklistsResponse, CacheResponse, - CacheTopResponse, ClientsPayload, ClientsResponse, - Dashboard, DomainCheckResponse, DomainFilters, DomainsResponse, @@ -23,17 +21,12 @@ import type { LoginInput, LoginResponse, LocalZonesResponse, - LogFilters, - LogItem, - LogsResponse, - LiveStats, OkResponse, RefreshBlocklistsResponse, ReloadSettingsResponse, SaveResponse, SaveSettingsResponse, SettingsResponse, - StreamReady, ToggleBlocklistResponse, UpstreamsPayload, UpstreamsResponse, @@ -43,6 +36,8 @@ import type { ZoneRecordInput, ZonesResponse, ZoneWriteResponse, + Links, + StatusResponse, } from './types.ts' const TOKEN_KEY = 'fancydns.token' @@ -85,14 +80,6 @@ export function clearToken(): void { } } -export type LogStreamHandlers = { - onEntry: (entry: LogItem) => void - onStats: (stats: LiveStats) => void - onReady: (info: StreamReady) => void - onError: (message: string) => void - onOpen: () => void -} - /** Query values the server should not see at all. */ type ParamValue = string | number | boolean | null | undefined @@ -228,8 +215,13 @@ export function whoami(): Promise { return apiFetch('/api/whoami') } -export function getDashboard(range: string): Promise { - return apiFetch(apiUrl('/api/dashboard', { range })) +export function getStatus(): Promise { + return apiFetch('/api/status') +} + +/** Optional pointers to the dashboards and log views; each may be empty. */ +export function getLinks(): Promise { + return apiFetch('/api/links') } export function getBlocklists(): Promise { @@ -362,22 +354,10 @@ export function putUpstreams(payload: UpstreamsPayload): Promise { }) } -export function getLogs(filters: LogFilters = {}): Promise { - return apiFetch(apiUrl('/api/logs', { ...filters })) -} - -export function clearLogs(): Promise { - return apiFetch('/api/logs/clear', { method: 'POST' }) -} - export function getCache(): Promise { return apiFetch('/api/cache') } -export function getCacheTop(limit = 50): Promise { - return apiFetch(apiUrl('/api/cache/top', { limit })) -} - export function flushCache(): Promise { return apiFetch('/api/cache/flush', { method: 'POST' }) } @@ -418,160 +398,3 @@ export function validateSettings(yaml: string): Promise { export function reloadSettings(): Promise { return apiFetch('/api/settings/reload', { method: 'POST' }) } - -const STREAM_PATH = '/api/logs/stream' -const RECONNECT_MIN_MS = 1000 -const RECONNECT_MAX_MS = 15000 - -/** - * Subscribes to the live query log. - * - * Native `EventSource` cannot send the `X-API-Key` header and the daemon - * accepts no cookie, so the stream is read with `fetch` plus a minimal SSE - * parser. That costs a hand-rolled reconnect, which is what EventSource would - * have given us for free; the handler contract is unchanged. - * - * Returns a teardown function that is safe to call more than once. - */ -export function streamLogs(handlers: LogStreamHandlers): () => void { - const controller = new AbortController() - let stopped = false - let backoff = RECONNECT_MIN_MS - let timer: ReturnType | undefined - let attempt = 0 - // One error report per outage streak: a flaky link must not toast every 15s. - let reported = false - - const open = (): void => { - if (stopped) return - void run() - } - - const run = async (): Promise => { - if (stopped) return - const token = getToken() - const headers = new Headers({ Accept: 'text/event-stream' }) - if (token) headers.set('Authorization', `Bearer ${token}`) - - try { - const res = await fetch(STREAM_PATH, { - headers, - credentials: 'same-origin', - signal: controller.signal, - cache: 'no-store', - }) - - if (res.status === 401) { - clearToken() - sendToLogin() - handlers.onError('unauthorized: the live log stream needs a valid session') - return - } - if (!res.ok || !res.body) { - const message = res.ok - ? 'the live log stream is not supported by this browser' - : `the live log stream failed with HTTP ${res.status}` - if (!reported) { - reported = true - handlers.onError(message) - } - schedule() - return - } - - reported = false - attempt = 0 - backoff = RECONNECT_MIN_MS - handlers.onOpen() - await readSse(res.body, handlers, controller.signal) - if (stopped) return - // A clean end of stream still means the connection is gone. - schedule() - } catch (err) { - if (stopped) return - if (controller.signal.aborted) return - const message = err instanceof Error ? err.message : 'the live log stream dropped' - if (!reported) { - reported = true - handlers.onError(message) - } - schedule() - } - } - - const schedule = (): void => { - if (stopped) return - attempt += 1 - const delay = Math.min(RECONNECT_MAX_MS, backoff) - backoff = Math.min(RECONNECT_MAX_MS, Math.round(backoff * 1.8)) - timer = setTimeout(open, delay) - } - - open() - - return () => { - if (stopped) return - stopped = true - if (timer !== undefined) clearTimeout(timer) - controller.abort() - } -} - -/** Splits an SSE byte stream into `event:`/`data:` frames. */ -async function readSse( - body: ReadableStream, - handlers: LogStreamHandlers, - signal: AbortSignal, -): Promise { - const reader = body.getReader() - const decoder = new TextDecoder() - let buffer = '' - - const handle = (frame: string): void => { - let event = 'message' - const data: string[] = [] - for (const rawLine of frame.split('\n')) { - const line = rawLine.replace(/\r$/, '') - if (line === '' || line.startsWith(':')) continue - if (line.startsWith('event:')) event = line.slice(6).trim() - else if (line.startsWith('data:')) data.push(line.slice(5).replace(/^ /, '')) - } - if (data.length === 0) return - let payload: unknown - try { - payload = JSON.parse(data.join('\n')) - } catch { - return - } - if (event === 'entry') { - handlers.onEntry(payload as LogItem) - } else if (event === 'stats') { - handlers.onStats(payload as LiveStats) - } else if (event === 'ready') { - handlers.onReady(payload as StreamReady) - } - } - - try { - for (;;) { - if (signal.aborted) return - const { done, value } = await reader.read() - if (done) break - buffer += decoder.decode(value, { stream: true }) - let split = buffer.indexOf('\n\n') - while (split !== -1) { - const frame = buffer.slice(0, split) - buffer = buffer.slice(split + 2) - handle(frame) - split = buffer.indexOf('\n\n') - } - } - if (buffer.trim() !== '') handle(buffer) - } finally { - try { - reader.releaseLock() - } catch { - /* already released by the abort */ - } - } -} diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 23a2e40..139e5f7 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -21,7 +21,7 @@ export type Health = 'ok' | 'degraded' | 'down' /** One upstream server's circuit-breaker view. */ export type UpstreamHealth = 'up' | 'down' | 'degraded' -/** The decision the pipeline reached for a query (qlog.Action). */ +/** The decision the pipeline reached for a query (accesslog.Action). */ export type LogAction = 'allow' | 'block' | 'redirect' | 'cached' | 'error' | 'drop' | 'refused' /** What a rule does to the name it matches (blocklist.ParseAction). */ export type DomainAction = @@ -53,34 +53,6 @@ export interface Whoami { base_path: string } -// --- GET /api/dashboard ---------------------------------------------------- - -/** One 5s bucket of the rolling counters. `t` is a unix timestamp in seconds. */ -export interface SeriesPoint { - t: number - queries: number - blocked: number - errors: number - cache_hits: number -} - -/** Dashboard top_blocked[] entry. `qtype` is a number; there is no name field. */ -export interface TopBlockedDomain { - domain: string - count: number - list: string - qtype: number -} - -/** Dashboard top_queried[] entry. */ -export interface TopQueriedDomain { - domain: string - count: number - qtype: number - blocked: boolean -} - -/** A bound listener, as reported by both /api/dashboard and /api/status. */ export interface ListenerInfo { transport: ListenerTransport | string addr: string @@ -102,96 +74,36 @@ export interface TransportStat { received: number } -export interface Dashboard { - /** "ok", "degraded" or "down"; anything else is reported as degraded. */ - health: Health - /** A semicolon-joined list of the problems behind a degraded health. */ - degraded_reason: string +/** GET /api/status: what this resolver is made of, not what it has served. */ +export interface StatusResponse { version: string - node_id: string - uptime_seconds: number - queries_total: number - queries_per_sec: number - errors_total: number - /** Queries in the pipeline right now. */ - inflight: number - /** 0..1: hits / (hits + misses). */ - cache_hit_rate: number - blocked_total: number - /** 0..100 here, but 0..1 in the SSE stats frame — see formatFraction. */ - blocked_pct: number - /** Compiled blocklist rules and live cache entries, for the header line. */ - blocklist_rules: number - cache_entries: number - config_version: number - /** Config swaps so far; the cluster uses it to detect divergence. */ - generation: number - series: SeriesPoint[] - top_blocked: TopBlockedDomain[] - top_queried: TopQueriedDomain[] - /** Runtime view of the pool, the same objects GET /api/upstreams serves. */ - upstreams: UpstreamStat[] - listeners: ListenerInfo[] - transports: Record - /** Only present when the daemon has no query log at all. */ - recent?: LogItem[] -} - -// --- GET /api/logs and the SSE entry frame --------------------------------- - -/** - * One logged query. Every key is always present — the Go struct has no - * `omitempty` — so an absent ACL group is an empty string, not a missing key. - * `qtype`/`rcode` are numbers; `qtype_name`/`rcode_name` are their spellings. - */ -export interface LogItem { - id: number - /** RFC 3339. */ - ts: string - client: string - client_group: string - name: string - qtype: number - qtype_name: string - rcode: number - rcode_name: string - action: LogAction - response: string - duration_ms: number - upstream: string - list: string - protocol: string - blocked: boolean -} - -export interface LogsResponse { - items: LogItem[] - total: number - offset: number - limit: number - has_more: boolean -} - -/** - * The `stats` frame of GET /api/logs/stream. `blocked_pct` and - * `cache_hit_rate` are 0..1 fractions here, where the dashboard reports - * blocked_pct as 0..100 — so formatFraction, never formatPercentPoints. - */ -export interface LiveStats { - qps: number - blocked_pct: number - cache_hit_rate: number - queries_total: number - blocked_total: number - errors_total: number - inflight: number uptime_s: number + started: string + config: { + version: number + generation: number + path: string + last_error: string + writable: boolean + reload_ready: boolean + } + components: { + listeners?: ListenerInfo[] + transports?: Record + /** Reasons the node is degraded; empty when it is healthy. */ + degraded?: string[] + blocklist?: { rules: number; sources: number; built_at: string; duplicates: number } + local_zones?: { zones: string[]; records: number } + cache?: CacheResponse + access_log?: { enabled: boolean; dropped: number } + } } -/** The `ready` frame of the stream. */ -export interface StreamReady { - at?: string - version?: string +/** GET /api/links: each value is an http(s) URL or empty. */ +export interface Links { + grafana: string + logs: string + metrics: string } // --- GET /api/blocklists --------------------------------------------------- @@ -516,18 +428,6 @@ export interface CacheResponse { * tracked a lookup count or a last-seen timestamp, so the console must not * invent columns for them. */ -export interface CacheTopEntry { - name: string - qtype: number - qtype_name: string - hits: number - bytes: number -} - -export interface CacheTopResponse { - items: CacheTopEntry[] -} - // --- GET /api/settings ----------------------------------------------------- export interface SettingsResponse { @@ -632,17 +532,6 @@ export interface DomainFilters { per_page?: number } -export interface LogFilters { - limit?: number - offset?: number - q?: string - action?: string - client?: string - rcode?: number - blocked?: boolean - since?: string -} - export interface AddDomainInput { name: string action: DomainAction diff --git a/web/src/pages/Cache.tsx b/web/src/pages/Cache.tsx deleted file mode 100644 index 976730b..0000000 --- a/web/src/pages/Cache.tsx +++ /dev/null @@ -1,408 +0,0 @@ -/** - * Cache — what the resolver keeps in memory between queries. - * - * Everything here is a read of `/api/cache` plus the top-50 ranking, refreshed - * on a slow 15s tick. The only mutation is the flush, which is destructive - * enough to sit behind a confirmation: it costs a cold cache, not a restart. - */ - -import { useMemo, useState } from 'react' -import type { ReactNode } from 'react' -import { - Cell, - Pie, - PieChart, - ResponsiveContainer, - Tooltip as ChartTooltip, -} from 'recharts' -import { Database, FileClock, Gauge, HardDrive, Layers, RefreshCw, Timer } from 'lucide-react' - -import { flushCache, getCache, getCacheTop } from '../lib/api' -import { useWhoami } from '../lib/session.ts' -import type { CachePersisted, CacheTopEntry } from '../lib/types' -import { useApi, useAsyncAction } from '../hooks/useApi' -import { useAutoRefresh } from '../hooks/useAutoRefresh' -import { - formatBytes, - formatFraction, - formatMs, - formatNumber, - formatQtype, - formatRelative, -} from '../lib/format' -import { - Badge, - Button, - Card, - CardBody, - ConfirmDialog, - EmptyState, - StatCard, - Table, - useToast, -} from '../components/ui' -import { PageError, PageHeader } from '../components/layout' - -type Row = Record - -const REFRESH_MS = 15000 -const TOP_LIMIT = 50 - -/** Fixed hex so the donut reads on both themes, like the dashboard charts. */ -const HITS = '#34d399' -const MISSES = '#f87171' - -/** What the page renders before the first response lands. */ -const EMPTY_PERSISTED: CachePersisted = { - enabled: false, - path: '', - bytes: 0, - entries: 0, - errors: 0, - last_snapshot: '', - loaded: false, - writes: 0, -} - -type PieSlice = { name: string; value: number; fill: string } - -type TipProps = { - active?: boolean - payload?: { name?: string; value?: number | string; payload?: PieSlice }[] -} - -function ChartTip({ active, payload }: TipProps) { - if (!active || payload === undefined || payload.length === 0) return null - return ( -
-
    - {payload.map((item) => ( -
  • -
  • - ))} -
-
- ) -} - -export default function Cache() { - const { toast } = useToast() - const { canWrite } = useWhoami() - const notify = (title: string, description?: string, tone: 'ok' | 'fail' = 'fail'): void => - toast({ title, description, tone: tone === 'ok' ? 'ok' : 'danger' }) - - const { data, error, loading, refetch } = useApi(() => getCache(), []) - const { - data: top, - error: topError, - loading: topLoading, - refetch: refetchTop, - } = useApi(() => getCacheTop(TOP_LIMIT), []) - - useAutoRefresh(() => { - refetch() - refetchTop() - }, REFRESH_MS) - - const [confirmFlush, setConfirmFlush] = useState(false) - const flush = useAsyncAction(async () => flushCache()) - - const hits = data?.hits ?? 0 - const misses = data?.misses ?? 0 - const lookups = hits + misses - const hitRate = data?.hit_rate ?? 0 - - const pie: PieSlice[] = useMemo( - () => [ - { name: 'hits', value: hits, fill: HITS }, - { name: 'misses', value: misses, fill: MISSES }, - ], - [hits, misses], - ) - - const persisted: CachePersisted = data?.persisted ?? EMPTY_PERSISTED - const persistErrors = persisted.errors - - const entries: CacheTopEntry[] = useMemo(() => top?.items ?? [], [top]) - - const onFlush = async (): Promise => { - const res = await flush.run() - if (res === null) { - notify('Could not flush the cache', flush.error ?? 'the server rejected the request') - return - } - setConfirmFlush(false) - notify( - 'Cache flushed', - `${formatNumber(res.entries ?? 0)} ${res.entries === 1 ? 'entry' : 'entries'} dropped`, - 'ok', - ) - refetch() - refetchTop() - } - - if (error) return - - // The cache counts hits and bytes per name and tracks no timestamp at all, - // so those are the only two columns the ranking can honestly show. - const topColumns = [ - { key: 'name', header: 'Name', width: '54%' }, - { key: 'qtype', header: 'Type', width: '10%' }, - { key: 'hits', header: 'Lookups', width: '18%', align: 'right' as const }, - { key: 'bytes', header: 'Size', width: '18%', align: 'right' as const }, - ] - - const topRows: Row[] = entries.map((entry) => ({ - __key: `${entry.name}-${entry.qtype}`, - name: ( - - {entry.name} - - ), - qtype: {formatQtype(entry.qtype, entry.qtype_name)}, - hits: {formatNumber(entry.hits)}, - bytes: {formatBytes(entry.bytes)}, - })) - - return ( -
- setConfirmFlush(true)} - loading={flush.pending} - disabled={!canWrite} - title={canWrite ? undefined : 'This API key is read-only'} - > - Flush cache - - } - /> - -
- - - - - - -
- -
- - -
-
- - - - {pie.map((slice) => ( - - ))} - - } /> - - -
-
-

{formatFraction(hitRate, 0)}

-

hit rate

-
-
-
-
    -
  • -
  • -
  • -
  • -
  • - Negative TTL - - {formatMs(data?.negative_ttl_ms ?? 0)} - -
  • - {lookups === 0 && ( -
  • - Nothing has been looked up since the daemon started. -
  • - )} -
-
-
-
- - - {persisted.enabled ? 'enabled' : 'disabled'} - - } - > - -
-
-
Path
-
- {persisted.path || '—'} -
-
-
-
Size
-
- {formatBytes(persisted.bytes ?? 0)} -
-
-
-
Entries
-
- {formatNumber(persisted.entries ?? 0)} -
-
-
-
Writes
-
- {formatNumber(persisted.writes ?? 0)} -
-
-
-
Errors
-
0 - ? 'tabular mt-0.5 font-mono text-sm text-danger' - : 'tabular mt-0.5 font-mono text-sm' - } - > - {formatNumber(persistErrors)} -
-
-
-
Last snapshot
-
{formatRelative(persisted.last_snapshot)}
-
-
-

- {persisted.loaded - ? 'The daemon loaded a snapshot at boot, so the cache was warm on the first query.' - : 'No snapshot was loaded at boot: the first query after a restart went upstream.'} -

-
-
-
- - auto-refresh every 15s} - > - {topError !== null ? ( - -

{topError}

-
- ) : ( -
- String(row.__key)} - loading={topLoading && entries.length === 0} - empty={ - - } - /> - - )} - - - setConfirmFlush(false)} - onConfirm={() => void onFlush()} - title="Flush the cache?" - message="Every cached answer is dropped. The next query for each name goes back to the upstreams, so expect a burst of upstream traffic." - confirmLabel="Flush cache" - destructive - pending={flush.pending} - /> - - ) -} diff --git a/web/src/pages/Dashboard.tsx b/web/src/pages/Dashboard.tsx index 4b3a123..ec50dbe 100644 --- a/web/src/pages/Dashboard.tsx +++ b/web/src/pages/Dashboard.tsx @@ -1,458 +1,185 @@ /** - * Dashboard — the "is it working" page. + * Status — what this resolver is and whether it is healthy. * - * Everything on it is a single `/api/dashboard` fetch for the selected range; - * the charts are pure functions of `series`, so they never need their own - * polling. The field names below are the ones the server sends, and they are - * pinned by `internal/app/contract_test.go` — renaming one here means renaming - * it there. Note the two units the same key arrives in: `blocked_pct` is 0..100 - * from this endpoint and 0..1 in the SSE stats frame the Logs page reads. + * The resolver keeps no traffic history and no query log: charts belong in + * Grafana and log search in the log collector, and a number held by one pod of + * several only disagrees with the others. This page therefore shows state (the + * listeners, the upstreams, what is compiled) and points at those tools when + * `api.links` names them. The field names are the server's and are pinned by + * `internal/app/contract_test.go`. */ -import { useMemo } from 'react' +import { Activity, BarChart3, ExternalLink, RefreshCw, ScrollText, Trash2 } from 'lucide-react' import type { ReactNode } from 'react' -import { - Area, - Bar, - BarChart, - CartesianGrid, - ComposedChart, - Line, - ResponsiveContainer, - Tooltip as ChartTooltip, - XAxis, - YAxis, -} from 'recharts' -import { Activity, Ban, Gauge, RefreshCw, Timer } from 'lucide-react' -import { getDashboard } from '../lib/api' -import type { Dashboard as DashboardData, LogItem, UpstreamStat } from '../lib/types' +import { flushCache, getLinks, getStatus, getUpstreams } from '../lib/api' +import type { UpstreamStat } from '../lib/types' import { useApi } from '../hooks/useApi' import { useAutoRefresh } from '../hooks/useAutoRefresh' -import { TIME_RANGES, useTimeRange } from '../hooks/useTimeRange' -import { - formatClock, - formatCompact, - formatMs, - formatNumber, - formatPercent, - formatFraction, - formatQtype, - formatUptime, -} from '../lib/format' -import { Badge, Button, Card, CardBody, EmptyState, Progress, StatCard, Table } from '../components/ui' +import { formatBytes, formatDateTime, formatFraction, formatMs, formatNumber, formatUptime } from '../lib/format' +import { Badge, Button, Card, CardBody, EmptyState, StatCard, Table, useToast } from '../components/ui' import { HealthPill, PageError, PageHeader } from '../components/layout' -/** Chart palette: fixed hex so it reads on both the light and dark theme. */ -const GRID = 'rgba(148, 163, 184, 0.22)' -const AXIS_TEXT = '#94a3b8' -const QUERIES = '#38bdf8' -const CACHED = '#34d399' -const BLOCKED = '#f59e0b' -const ERRORS = '#f87171' - -/** The typed payload; the only drift allowed is a build that predates a key. */ -const EMPTY_DASHBOARD: DashboardData = { - health: 'ok', - degraded_reason: '', - version: '', - node_id: '', - uptime_seconds: 0, - queries_total: 0, - queries_per_sec: 0, - errors_total: 0, - inflight: 0, - cache_hit_rate: 0, - blocked_total: 0, - blocked_pct: 0, - blocklist_rules: 0, - cache_entries: 0, - config_version: 0, - generation: 0, - series: [], - top_blocked: [], - top_queried: [], - upstreams: [], - listeners: [], - transports: {}, - recent: [], -} - type Row = Record -type TipProps = { - active?: boolean - label?: number | string - payload?: { name?: string; value?: number | string; color?: string }[] -} - -/** Dark tooltip content, so the chart stays legible in the light theme too. */ -function ChartTip({ active, payload, label }: TipProps) { - if (!active || !payload || payload.length === 0) return null - return ( -
-
{formatClock(Number(label) * 1000)}
-
    - {payload.map((item) => ( -
  • -
  • - ))} -
-
- ) -} - -function healthTone(health: string | undefined): 'default' | 'ok' | 'warn' | 'danger' { - if (health === 'ok') return 'ok' +function upstreamTone(health: string): 'ok' | 'warn' | 'danger' { + if (health === 'up') return 'ok' if (health === 'degraded') return 'warn' return 'danger' } -export default function Dashboard() { - const { range, setRange } = useTimeRange('1h') - const { data, error, loading, refetch } = useApi(() => getDashboard(range), [range]) - useAutoRefresh(refetch, 5000) - // Typed end to end: the server builds this payload, so there is no reason - // to read it defensively. A node running an older build shows less. - const d = data ?? EMPTY_DASHBOARD +/** Only http(s) URLs become links; the server validates, this is the second lock. */ +function safeHref(url: string): string | undefined { + return /^https?:\/\//i.test(url) ? url : undefined +} - const series = d.series - const upstreams = d.upstreams - const slowest = useMemo( - () => upstreams.reduce((max, u) => Math.max(max, u.latency_ms || 0), 0), - [upstreams], +function LinkButton({ href, icon, children }: { href: string; icon: typeof BarChart3; children: ReactNode }) { + const Icon = icon + return ( + + ) - const topBlocked = d.top_blocked - const topQueried = d.top_queried +} - const hitRate = d.cache_hit_rate - // The dashboard reports the slowest upstream's p95, because it has no - // end-to-end latency series; the number is real either way. - const p95 = useMemo(() => { - const values = upstreams.map((u) => u.p95_ms || 0).filter((v) => v > 0) - return values.length > 0 ? Math.max(...values) : undefined - }, [upstreams]) - const degraded = d.health !== 'ok' - const degradedReason = - d.degraded_reason !== '' ? d.degraded_reason : 'no reason reported by the server' +export default function Dashboard() { + const { toast } = useToast() + const status = useApi(() => getStatus(), []) + const upstreams = useApi(() => getUpstreams(), []) + const links = useApi(() => getLinks(), []) + const refetch = () => { + status.refetch() + upstreams.refetch() + } + useAutoRefresh(refetch, 10000) - if (error) return + if (status.error) return + + const s = status.data + const c = s?.components + const degraded = c?.degraded ?? [] + const health = degraded.length > 0 ? 'degraded' : 'ok' + const cache = c?.cache + const grafana = safeHref(links.data?.grafana ?? '') + const logs = safeHref(links.data?.logs ?? '') + const metrics = safeHref(links.data?.metrics ?? '') + + const onFlush = async () => { + try { + const res = await flushCache() + toast({ tone: 'ok', title: `Cache flushed (${formatNumber(res.entries)} entries)` }) + status.refetch() + } catch (err) { + toast({ tone: 'danger', title: `Flush failed: ${err instanceof Error ? err.message : String(err)}` }) + } + } const upstreamColumns = [ - { key: 'addr', header: 'Server', width: '28%' }, - { key: 'proto', header: 'Proto', width: '8%' }, - { key: 'health', header: 'Health', width: '12%' }, - { key: 'latency', header: 'Latency', width: '22%' }, - { key: 'fail', header: 'Fail rate', width: '12%', align: 'right' as const }, - { key: 'queries', header: 'Queries', width: '14%', align: 'right' as const }, + { key: 'addr', header: 'Server' }, + { key: 'proto', header: 'Proto' }, + { key: 'health', header: 'Health' }, + { key: 'latency', header: 'Latency', align: 'right' as const }, + { key: 'fail', header: 'Fail rate', align: 'right' as const }, ] - - const upstreamRows: Row[] = upstreams.map((u: UpstreamStat, i) => ({ + const upstreamRows: Row[] = (upstreams.data?.servers ?? []).map((u: UpstreamStat, i) => ({ __key: `${u.addr}-${i}`, addr: {u.addr}, proto: {u.proto}, - health: {u.health}, - latency: ( - - - {formatMs(u.latency_ms)} - - - 0.1 ? 'danger' : u.fail_rate > 0.02 ? 'warn' : 'ok'} - /> - - - ), - fail: ( - {u.fail_rate > 0 ? formatFraction(u.fail_rate) : '—'} - ), - queries: {formatNumber(u.queries)}, + health: {u.health}, + latency: {formatMs(u.latency_ms)}, + fail: {u.fail_rate > 0 ? formatFraction(u.fail_rate) : '—'}, })) - const recent: LogItem[] = d.recent ?? [] - const recentSource: { name: string; list: string; action: string; ts?: string }[] = - recent.length > 0 - ? recent.slice(0, 10).map((e) => ({ - name: e.name, - list: e.list, - action: e.action, - ts: e.ts, - })) - : topBlocked.slice(0, 10).map((e) => ({ name: e.domain, list: e.list, action: 'block' })) - return (
-
- {TIME_RANGES.map((r) => ( - - ))} -
-
} /> - {degraded && ( + {degraded.length > 0 && (
)}
+ = 0.7 ? 'ok' : hitRate >= 0.4 ? 'warn' : 'danger'} + label="Local zone records" + value={formatNumber(c?.local_zones?.records ?? 0)} + hint={`${formatNumber(c?.local_zones?.zones?.length ?? 0)} zones`} + loading={status.loading && !s} /> -
-

- version {d.version || 'unknown'} · up {formatUptime(d.uptime_seconds)} - {d.node_id !== '' ? ` · node ${d.node_id}` : ''} · {formatNumber(d.blocklist_rules)} rules - · {formatNumber(d.cache_entries)} cached · {formatNumber(d.errors_total)} errors ·{' '} - {formatNumber(d.inflight)} in flight · config v{formatNumber(d.config_version)} - {` (generation ${formatNumber(d.generation)})`} · auto-refreshing every 5s -

- -
- - -
- - - - - - } cursor={{ stroke: GRID }} /> - - - - - + + + {grafana || logs || metrics ? ( +
+ {grafana ? Dashboards : null} + {logs ? Query log : null} + {metrics ? Metrics : null}
-
-
- - - -
- - - - - - } cursor={{ stroke: GRID }} /> - - - - -
-
-
-
- -
- - -
- - - - - - value.length > 26 ? `${value.slice(0, 25)}…` : value - } - /> - } cursor={{ fill: 'rgba(148,163,184,0.12)' }} /> - - - -
-
-
- - 0 ? 'Newest entries from the server' : 'From the top-blocked ranking'} - > - - {recentSource.length === 0 ? ( - - ) : ( -
    - {recentSource.map((e, i) => ( -
  • - {e.action || 'block'} - {e.name} - {e.list ? ( - - {e.list} - - ) : null} - {e.ts ? ( - {formatClock(e.ts)} - ) : null} -
  • - ))} -
- )} -
-
-
+ ) : ( +

+ Not linked. Set api.links.grafana and{' '} + api.links.logs to show buttons here. Metrics are served at{' '} + /metrics; blocked and failed queries are written to the pod log as + JSON lines{c?.access_log && !c.access_log.enabled ? ' (the access log is currently disabled)' : ''} + {c?.access_log && c.access_log.dropped > 0 + ? `, and ${formatNumber(c.access_log.dropped)} lines have been dropped because the log stream fell behind` + : ''} + . +

+ )} +
+
- } - > +
String(row.__key)} empty="No upstreams are configured." - loading={loading && upstreams.length === 0} + loading={upstreams.loading && upstreamRows.length === 0} /> - + + Flush cache + + ) : undefined + } + > - {topQueried.length === 0 ? ( - + {(c?.listeners ?? []).length === 0 ? ( + ) : ( -
    - {topQueried.slice(0, 15).map((q) => ( -
  • - - {formatQtype(q.qtype)} - - {q.domain} - {formatNumber(q.count)} +
      + {(c?.listeners ?? []).map((l) => ( +
    • + {l.transport} + {l.addr} + {l.err ? {l.err} : null}
    • ))}
    @@ -487,6 +221,11 @@ export default function Dashboard() { + +

    + started {s ? formatDateTime(s.started) : '—'} · config v{formatNumber(s?.config.version ?? 0)} + {s?.config.last_error ? ` · last reload failed: ${s.config.last_error}` : ''} · refreshes every 10s +

    ) } diff --git a/web/src/pages/Logs.tsx b/web/src/pages/Logs.tsx deleted file mode 100644 index 93f7daa..0000000 --- a/web/src/pages/Logs.tsx +++ /dev/null @@ -1,437 +0,0 @@ -/** - * Logs — the query log, and the live tail of it. - * - * One table, two sources: the paged `/api/logs` result and the SSE stream from - * `/api/logs/stream`. While LIVE is on, streamed entries are prepended and run - * through the same filters client-side, so a live row can never contradict what - * the filter bar says. Rendering is capped: a busy node emits far faster than a - * browser can lay out rows, and an uncapped list is the tab's worst enemy. - */ - -import { useEffect, useMemo, useRef, useState } from 'react' -import type { ReactNode } from 'react' -import { Eraser, Radio, Search, Signal, WifiOff } from 'lucide-react' - -import { clearLogs, getLogs, streamLogs } from '../lib/api' -import { useWhoami } from '../lib/session.ts' -import type { LogFilters, LogItem, LiveStats } from '../lib/types' -import { useApi, useAsyncAction } from '../hooks/useApi' -import { useAutoRefresh } from '../hooks/useAutoRefresh' -import { useDebounced } from '../hooks/useDebounced' -import { - formatClock, - formatCompact, - formatFraction, - formatMs, - formatNumber, - formatQtype, - formatRcode, -} from '../lib/format' -import { cn } from '../lib/cn' -import { - Badge, - Button, - Card, - CardBody, - ConfirmDialog, - Input, - Pagination, - Select, - Switch, - Table, - useToast, -} from '../components/ui' -import { PageError, PageHeader } from '../components/layout' - -type Row = Record - -const PER_PAGE = 50 -/** Hard cap on rendered rows, live tail included. */ -const MAX_ROWS = 300 -const REFRESH_LIVE_MS = 3000 - -const ACTION_OPTIONS = [ - { value: '', label: 'All actions' }, - { value: 'allow', label: 'Allow' }, - { value: 'cached', label: 'Cached' }, - { value: 'redirect', label: 'Redirect' }, - { value: 'block', label: 'Block' }, - { value: 'error', label: 'Error' }, - { value: 'drop', label: 'Drop' }, -] - -/** The filter set, reduced to what both the server and the live tail need. */ -interface Filter { - q: string - action: string - client: string - blocked: boolean -} - -function actionTone(action: string): 'default' | 'ok' | 'warn' | 'danger' | 'info' | 'muted' { - switch (action) { - case 'allow': - return 'default' - case 'cached': - return 'info' - case 'redirect': - return 'info' - case 'block': - return 'warn' - case 'error': - return 'danger' - case 'drop': - return 'muted' - default: - return 'default' - } -} - -/** The server filters; the live tail re-implements the same rules. */ -function matches(entry: LogItem, filter: Filter): boolean { - if (filter.action !== '' && entry.action !== filter.action) return false - if (filter.blocked && entry.action !== 'block') return false - - if (filter.client !== '') { - const needle = filter.client.toLowerCase() - const hay = `${entry.client} ${entry.client_group}`.toLowerCase() - if (!hay.includes(needle)) return false - } - - if (filter.q !== '') { - const needle = filter.q.toLowerCase() - const hay = [ - entry.name, - entry.list, - entry.upstream, - entry.client, - entry.client_group, - entry.action, - ] - .join(' ') - .toLowerCase() - if (!hay.includes(needle)) return false - } - - return true -} - -export default function Logs() { - const { toast } = useToast() - const { canWrite } = useWhoami() - const notify = (title: string, description?: string, tone: 'ok' | 'fail' = 'fail'): void => - toast({ title, description, tone: tone === 'ok' ? 'ok' : 'danger' }) - - const [search, setSearch] = useState('') - const [action, setAction] = useState('') - const [client, setClient] = useState('') - const [blockedOnly, setBlockedOnly] = useState(false) - const [page, setPage] = useState(1) - const q = useDebounced(search, 300) - - const [live, setLive] = useState(false) - const [connected, setConnected] = useState(false) - const [streamError, setStreamError] = useState(null) - const [liveRows, setLiveRows] = useState([]) - const [stats, setStats] = useState(null) - const [confirmClear, setConfirmClear] = useState(false) - - const clear = useAsyncAction(async () => clearLogs()) - - // Every filter change invalidates both the current page and the live tail. - const applyFilter = (): void => { - setPage(1) - setLiveRows([]) - } - - const filters = useMemo( - () => ({ - q: q.trim() || undefined, - action: action === '' ? undefined : action, - client: client.trim() || undefined, - blocked: blockedOnly ? true : undefined, - limit: PER_PAGE, - offset: (page - 1) * PER_PAGE, - }), - [q, action, client, blockedOnly, page], - ) - - const { data, error, loading, refetch } = useApi(() => getLogs(filters), [filters]) - useAutoRefresh(refetch, REFRESH_LIVE_MS, live) - - // The stream outlives every filter change, so it reads the current filter - // through a ref instead of resubscribing on each keystroke. - const filterRef = useRef({ q: '', action: '', client: '', blocked: false }) - useEffect(() => { - filterRef.current = { q: q.trim(), action, client: client.trim(), blocked: blockedOnly } - }, [q, action, client, blockedOnly]) - - const toggleLive = (next: boolean): void => { - setLiveRows([]) - setStats(null) - setConnected(false) - setStreamError(null) - setLive(next) - } - - useEffect(() => { - if (!live) return - const stop = streamLogs({ - onEntry: (entry) => { - if (!matches(entry, filterRef.current)) return - setLiveRows((prev) => [entry, ...prev].slice(0, MAX_ROWS)) - }, - onStats: (next) => setStats(next), - onReady: () => setConnected(true), - onOpen: () => { - setConnected(true) - setStreamError(null) - }, - onError: (message) => { - setConnected(false) - setStreamError(message) - }, - }) - return stop - }, [live]) - - const items: LogItem[] = useMemo(() => data?.items ?? [], [data]) - const total = data?.total ?? items.length - const rows = useMemo( - () => (liveRows.length > 0 ? [...liveRows, ...items] : items).slice(0, MAX_ROWS), - [liveRows, items], - ) - - const onClear = async (): Promise => { - const res = await clear.run() - if (res === null) { - notify('Could not clear the log', clear.error ?? 'the server rejected the request') - return - } - setConfirmClear(false) - setLiveRows([]) - notify('Log cleared', 'the server dropped every buffered entry', 'ok') - refetch() - } - - if (error) return - - const columns = [ - { key: 'ts', header: 'Time', width: '9%' }, - { key: 'client', header: 'Client', width: '13%' }, - { key: 'group', header: 'Group', width: '10%' }, - { key: 'name', header: 'Name', width: '24%' }, - { key: 'qtype', header: 'Type', width: '6%' }, - { key: 'rcode', header: 'Rcode', width: '12%' }, - { key: 'action', header: 'Action', width: '8%' }, - { key: 'duration', header: 'Time', width: '8%', align: 'right' as const }, - { key: 'upstream', header: 'Upstream', width: '10%' }, - { key: 'list', header: 'List', width: '12%' }, - ] - - const tableRows: Row[] = rows.map((entry, index) => ({ - __key: `${entry.id}-${entry.ts}-${index}`, - ts: {formatClock(entry.ts)}, - client: ( - - {entry.client || '—'} - - ), - group: entry.client_group ? ( - {entry.client_group} - ) : ( - '—' - ), - name: ( - - {entry.name} - - ), - qtype: ( - {formatQtype(entry.qtype, entry.qtype_name)} - ), - rcode: {formatRcode(entry.rcode)}, - action: {entry.action || 'unknown'}, - duration: {formatMs(entry.duration_ms)}, - upstream: ( - - {entry.upstream || '—'} - - ), - list: ( - - {entry.list || '—'} - - ), - })) - - // The stats frame is a 1s sample. Its qps and rates are 0..1 fractions, which - // is not the unit blocked_pct has on the dashboard payload. - const qps = stats?.qps ?? 0 - const blockedPct = stats?.blocked_pct ?? 0 - const hitRate = stats?.cache_hit_rate ?? 0 - const inflight = stats?.inflight ?? 0 - - return ( -
    - - - - - -
    - } - /> - - {live && ( -
    - - - - {formatFraction(blockedPct)}{' '} - blocked - - - {formatFraction(hitRate)} cache hits - - - {formatNumber(inflight)} in flight - - sampled by the daemon every few seconds - {streamError !== null && ( - - - )} -
    - )} - - - -
    - { - setSearch(event.target.value) - applyFilter() - }} - autoComplete="off" - spellCheck={false} - /> - { - setClient(event.target.value) - applyFilter() - }} - autoComplete="off" - spellCheck={false} - /> -
    - { - setBlockedOnly(next) - applyFilter() - }} - label="Blocked only" - description="Hide allowed and cached answers" - /> -
    -
    -
    -
    - - = MAX_ROWS ? ( - capped at {MAX_ROWS} rows - ) : ( - - - ) - } - > -
    -
String(row.__key)} - loading={loading && items.length === 0 && liveRows.length === 0} - maxHeight={640} - empty="No query matched these filters." - /> - - - - - - setConfirmClear(false)} - onConfirm={() => void onClear()} - title="Clear the query log?" - message="Every buffered entry is dropped on the server. This cannot be undone, and the running log keeps filling from the next query." - confirmLabel="Clear log" - destructive - pending={clear.pending} - /> - - ) -} diff --git a/web/src/pages/NotFound.tsx b/web/src/pages/NotFound.tsx index 4eeb20b..7be1c04 100644 --- a/web/src/pages/NotFound.tsx +++ b/web/src/pages/NotFound.tsx @@ -17,7 +17,7 @@ export default function NotFound() { action={ } @@ -26,7 +26,7 @@ export default function NotFound() {
    {[ - ['/', 'Dashboard'], + ['/', 'Status'], ['/blocklists', 'Blocklists'], ['/domains', 'Domains'], ['/clients', 'Clients'],