From 01202f9785626977f20d53cc031a926cefe0d933 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mats=20T=C3=B6rnberg?= Date: Fri, 16 Oct 2026 09:56:26 +0200 Subject: [PATCH] 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 --- deploy/config.example.yaml | 5 + internal/api/api.go | 1 + internal/api/links.go | 10 + internal/app/links_test.go | 27 ++ internal/config/config.go | 21 + internal/config/links_test.go | 25 ++ internal/console/console.go | 1 + web/package-lock.json | 396 +----------------- web/package.json | 3 +- web/src/App.tsx | 4 - web/src/components/layout/AppShell.tsx | 6 +- web/src/lib/api.ts | 195 +-------- web/src/lib/types.ts | 165 ++------ web/src/pages/Cache.tsx | 408 ------------------ web/src/pages/Dashboard.tsx | 549 +++++++------------------ web/src/pages/Logs.tsx | 437 -------------------- web/src/pages/NotFound.tsx | 4 +- 17 files changed, 277 insertions(+), 1980 deletions(-) create mode 100644 internal/api/links.go create mode 100644 internal/app/links_test.go create mode 100644 internal/config/links_test.go delete mode 100644 web/src/pages/Cache.tsx delete mode 100644 web/src/pages/Logs.tsx 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'],