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

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

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

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

No files matched your search

+3 -393
View File
@@ -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",
+1 -2
View File
@@ -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",
-4
View File
@@ -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() {
<Route path="/zones" element={<Zones />} />
<Route path="/clients" element={<Clients />} />
<Route path="/upstreams" element={<Upstreams />} />
<Route path="/logs" element={<Logs />} />
<Route path="/cache" element={<Cache />} />
<Route path="/settings" element={<Settings />} />
<Route path="*" element={<NotFound />} />
</Routes>
+1 -5
View File
@@ -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 },
]
+9 -186
View File
@@ -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<Whoami> {
return apiFetch<Whoami>('/api/whoami')
}
export function getDashboard(range: string): Promise<Dashboard> {
return apiFetch<Dashboard>(apiUrl('/api/dashboard', { range }))
export function getStatus(): Promise<StatusResponse> {
return apiFetch<StatusResponse>('/api/status')
}
/** Optional pointers to the dashboards and log views; each may be empty. */
export function getLinks(): Promise<Links> {
return apiFetch<Links>('/api/links')
}
export function getBlocklists(): Promise<BlocklistsResponse> {
@@ -362,22 +354,10 @@ export function putUpstreams(payload: UpstreamsPayload): Promise<SaveResponse> {
})
}
export function getLogs(filters: LogFilters = {}): Promise<LogsResponse> {
return apiFetch<LogsResponse>(apiUrl('/api/logs', { ...filters }))
}
export function clearLogs(): Promise<OkResponse> {
return apiFetch<OkResponse>('/api/logs/clear', { method: 'POST' })
}
export function getCache(): Promise<CacheResponse> {
return apiFetch<CacheResponse>('/api/cache')
}
export function getCacheTop(limit = 50): Promise<CacheTopResponse> {
return apiFetch<CacheTopResponse>(apiUrl('/api/cache/top', { limit }))
}
export function flushCache(): Promise<FlushCacheResponse> {
return apiFetch<FlushCacheResponse>('/api/cache/flush', { method: 'POST' })
}
@@ -418,160 +398,3 @@ export function validateSettings(yaml: string): Promise<ValidateResponse> {
export function reloadSettings(): Promise<ReloadSettingsResponse> {
return apiFetch<ReloadSettingsResponse>('/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<typeof setTimeout> | 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<void> => {
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<Uint8Array>,
handlers: LogStreamHandlers,
signal: AbortSignal,
): Promise<void> {
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 */
}
}
}
+27 -138
View File
@@ -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<string, TransportStat>
/** 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<string, TransportStat>
/** 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
-408
View File
@@ -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<string, ReactNode>
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 (
<div className="rounded-md border border-slate-700 bg-slate-900 px-3 py-2 text-xs shadow-lg">
<ul className="space-y-0.5">
{payload.map((item) => (
<li key={item.name} className="flex items-center gap-2 text-slate-100">
<span
aria-hidden="true"
className="inline-block h-2 w-2 shrink-0 rounded-full"
style={{ background: item.payload?.fill }}
/>
<span className="text-slate-400">{item.name}</span>
<span className="ml-auto font-mono">{formatNumber(Number(item.value ?? 0))}</span>
</li>
))}
</ul>
</div>
)
}
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<void> => {
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 <PageError error={error} onRetry={refetch} />
// 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: (
<span className="block max-w-[420px] truncate font-mono text-xs" title={entry.name}>
{entry.name}
</span>
),
qtype: <span className="font-mono text-xs">{formatQtype(entry.qtype, entry.qtype_name)}</span>,
hits: <span className="font-mono text-xs">{formatNumber(entry.hits)}</span>,
bytes: <span className="font-mono text-xs">{formatBytes(entry.bytes)}</span>,
}))
return (
<div className="space-y-4">
<PageHeader
title="Cache"
description="Answer reuse, its lifetime, and what survives a restart."
actions={
<Button
variant="danger"
icon={RefreshCw}
onClick={() => setConfirmFlush(true)}
loading={flush.pending}
disabled={!canWrite}
title={canWrite ? undefined : 'This API key is read-only'}
>
Flush cache
</Button>
}
/>
<div className="grid grid-cols-2 gap-3 lg:grid-cols-3 xl:grid-cols-6">
<StatCard
label="Entries"
value={formatNumber(data?.entries ?? 0)}
icon={Database}
hint={data?.enabled === false ? 'cache disabled' : undefined}
loading={loading && data === null}
/>
<StatCard
label="Size"
value={data?.bytes_human || formatBytes(data?.bytes ?? 0)}
icon={HardDrive}
hint={`budget of ${formatNumber(data?.size ?? 0)} entries`}
/>
<StatCard
label="Shards"
value={formatNumber(data?.shards ?? 0)}
icon={Layers}
/>
<StatCard
label="Max TTL"
value={formatMs(data?.max_ttl_ms ?? 0)}
icon={Timer}
hint={`min ${formatMs(data?.min_ttl_ms ?? 0)}`}
/>
<StatCard
label="Serve stale"
value={formatMs(data?.serve_stale_ms ?? 0)}
icon={FileClock}
hint={data?.serve_stale_ms ? 'upstream timeouts tolerated' : 'never serve stale'}
tone={data?.serve_stale_ms ? 'default' : 'warn'}
/>
<StatCard
label="Evictions"
value={formatNumber(data?.evictions ?? 0)}
icon={Gauge}
hint={`${formatNumber(data?.stale_hits ?? 0)} stale hits`}
/>
</div>
<div className="grid grid-cols-1 gap-3 xl:grid-cols-2">
<Card
title="Hit rate"
subtitle={`${formatFraction(hitRate)} of ${formatNumber(lookups)} lookups served from memory`}
>
<CardBody>
<div className="flex flex-col items-center gap-3 sm:flex-row">
<div className="relative h-48 w-48 shrink-0">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={pie}
dataKey="value"
nameKey="name"
innerRadius="62%"
outerRadius="92%"
paddingAngle={1}
stroke="none"
isAnimationActive={false}
>
{pie.map((slice) => (
<Cell key={slice.name} fill={slice.fill} />
))}
</Pie>
<ChartTooltip content={<ChartTip />} />
</PieChart>
</ResponsiveContainer>
<div className="pointer-events-none absolute inset-0 grid place-items-center">
<div className="text-center">
<p className="tabular text-xl font-semibold text-fg">{formatFraction(hitRate, 0)}</p>
<p className="text-xs text-fg-muted">hit rate</p>
</div>
</div>
</div>
<ul className="w-full min-w-0 space-y-2 text-sm">
<li className="flex items-center gap-2">
<span
aria-hidden="true"
className="size-2.5 shrink-0 rounded-full"
style={{ background: HITS }}
/>
<span className="text-fg-muted">Hits</span>
<span className="tabular ml-auto font-mono text-xs">
{formatNumber(hits)}
</span>
</li>
<li className="flex items-center gap-2">
<span
aria-hidden="true"
className="size-2.5 shrink-0 rounded-full"
style={{ background: MISSES }}
/>
<span className="text-fg-muted">Misses</span>
<span className="tabular ml-auto font-mono text-xs">
{formatNumber(misses)}
</span>
</li>
<li className="flex items-center gap-2 border-t border-line pt-2 text-xs text-fg-muted">
<span>Negative TTL</span>
<span className="tabular ml-auto font-mono">
{formatMs(data?.negative_ttl_ms ?? 0)}
</span>
</li>
{lookups === 0 && (
<li className="text-xs text-fg-subtle">
Nothing has been looked up since the daemon started.
</li>
)}
</ul>
</div>
</CardBody>
</Card>
<Card
title="Persisted file"
subtitle={persisted.enabled ? 'Snapshots survive a restart' : 'Snapshots are turned off'}
actions={
<Badge tone={persisted.enabled ? 'ok' : 'muted'}>
{persisted.enabled ? 'enabled' : 'disabled'}
</Badge>
}
>
<CardBody>
<dl className="grid grid-cols-2 gap-x-4 gap-y-3 sm:grid-cols-3">
<div className="col-span-2 min-w-0 sm:col-span-3">
<dt className="text-xs tracking-wide text-fg-muted uppercase">Path</dt>
<dd className="mt-0.5 truncate font-mono text-xs" title={persisted.path}>
{persisted.path || '—'}
</dd>
</div>
<div>
<dt className="text-xs tracking-wide text-fg-muted uppercase">Size</dt>
<dd className="tabular mt-0.5 font-mono text-sm">
{formatBytes(persisted.bytes ?? 0)}
</dd>
</div>
<div>
<dt className="text-xs tracking-wide text-fg-muted uppercase">Entries</dt>
<dd className="tabular mt-0.5 font-mono text-sm">
{formatNumber(persisted.entries ?? 0)}
</dd>
</div>
<div>
<dt className="text-xs tracking-wide text-fg-muted uppercase">Writes</dt>
<dd className="tabular mt-0.5 font-mono text-sm">
{formatNumber(persisted.writes ?? 0)}
</dd>
</div>
<div>
<dt className="text-xs tracking-wide text-fg-muted uppercase">Errors</dt>
<dd
className={
persistErrors > 0
? 'tabular mt-0.5 font-mono text-sm text-danger'
: 'tabular mt-0.5 font-mono text-sm'
}
>
{formatNumber(persistErrors)}
</dd>
</div>
<div className="col-span-2 sm:col-span-1">
<dt className="text-xs tracking-wide text-fg-muted uppercase">Last snapshot</dt>
<dd className="mt-0.5 text-sm">{formatRelative(persisted.last_snapshot)}</dd>
</div>
</dl>
<p className="mt-3 rounded-lg border border-line bg-surface-2 px-3 py-2 text-xs text-fg-muted">
{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.'}
</p>
</CardBody>
</Card>
</div>
<Card
title="Most requested"
subtitle={`Top ${TOP_LIMIT} names by the cache's own hit counter`}
padded={false}
actions={<Badge tone="muted">auto-refresh every 15s</Badge>}
>
{topError !== null ? (
<CardBody>
<p className="text-sm text-danger">{topError}</p>
</CardBody>
) : (
<div className="overflow-x-auto">
<Table
columns={topColumns}
rows={topRows}
rowKey={(row) => String(row.__key)}
loading={topLoading && entries.length === 0}
empty={
<EmptyState
icon={Database}
title="The cache is empty"
description="Nothing has been cached yet, so there is no ranking to show."
/>
}
/>
</div>
)}
</Card>
<ConfirmDialog
open={confirmFlush}
onClose={() => 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}
/>
</div>
)
}
+144 -405
View File
@@ -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<string, ReactNode>
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 (
<div className="rounded-md border border-slate-700 bg-slate-900 px-3 py-2 text-xs shadow-lg">
<div className="mb-1 font-mono text-slate-400">{formatClock(Number(label) * 1000)}</div>
<ul className="space-y-0.5">
{payload.map((item) => (
<li key={item.name} className="flex items-center gap-2 text-slate-100">
<span
aria-hidden="true"
className="inline-block h-2 w-2 shrink-0 rounded-full"
style={{ background: item.color }}
/>
<span className="text-slate-400">{item.name}</span>
<span className="ml-auto font-mono">{formatNumber(Number(item.value ?? 0))}</span>
</li>
))}
</ul>
</div>
)
}
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 (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 rounded-md border border-line px-3 py-1.5 text-sm font-medium text-fg hover:bg-surface-2"
>
<Icon size={16} aria-hidden="true" />
{children}
<ExternalLink size={13} aria-hidden="true" className="text-fg-subtle" />
</a>
)
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 <PageError error={error} onRetry={refetch} />
if (status.error) return <PageError error={status.error} onRetry={status.refetch} />
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: <span className="font-mono text-xs">{u.addr}</span>,
proto: <Badge tone="muted">{u.proto}</Badge>,
health: <Badge tone={healthTone(u.health)}>{u.health}</Badge>,
latency: (
<span className="flex items-center gap-2">
<span className="w-12 shrink-0 font-mono text-xs text-slate-500 dark:text-slate-400">
{formatMs(u.latency_ms)}
</span>
<span className="w-full min-w-[60px]">
<Progress
value={u.latency_ms}
max={slowest || 1}
tone={u.fail_rate > 0.1 ? 'danger' : u.fail_rate > 0.02 ? 'warn' : 'ok'}
/>
</span>
</span>
),
fail: (
<span className="font-mono text-xs">{u.fail_rate > 0 ? formatFraction(u.fail_rate) : '—'}</span>
),
queries: <span className="font-mono text-xs">{formatNumber(u.queries)}</span>,
health: <Badge tone={upstreamTone(u.health)}>{u.health}</Badge>,
latency: <span className="font-mono text-xs">{formatMs(u.latency_ms)}</span>,
fail: <span className="font-mono text-xs">{u.fail_rate > 0 ? formatFraction(u.fail_rate) : '—'}</span>,
}))
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 (
<div className="space-y-4">
<PageHeader
title="Dashboard"
description="Live traffic, cache efficiency and upstream health."
title="Status"
description="What this resolver is running, and whether it is healthy."
actions={
<div className="flex items-center gap-2">
<div
className="flex overflow-hidden rounded-md border border-slate-300 dark:border-slate-700"
role="group"
aria-label="Time range"
>
{TIME_RANGES.map((r) => (
<button
key={r}
type="button"
onClick={() => setRange(r)}
aria-pressed={range === r}
className={
range === r
? 'bg-slate-900 px-2.5 py-1 text-xs font-medium text-white dark:bg-slate-100 dark:text-slate-900'
: 'px-2.5 py-1 text-xs text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800'
}
>
{r}
</button>
))}
</div>
<Button
variant="outline"
size="sm"
icon={RefreshCw}
onClick={refetch}
loading={loading}
>
<HealthPill health={health} />
<Button variant="outline" size="sm" icon={RefreshCw} onClick={refetch} loading={status.loading}>
Refresh
</Button>
</div>
}
/>
{degraded && (
{degraded.length > 0 && (
<div
role="alert"
className="flex items-start gap-3 rounded-lg border border-amber-500/50 bg-amber-500/10 px-4 py-3 text-sm text-amber-900 dark:text-amber-200"
className="flex items-start gap-3 rounded-lg border border-warn/40 bg-warn-soft px-4 py-3 text-sm text-warn"
>
<Activity size={18} className="mt-0.5 shrink-0" aria-hidden="true" />
<div className="min-w-0">
<p className="font-semibold">The server reports degraded service</p>
<p className="mt-0.5 break-words opacity-90">{degradedReason}</p>
<ul className="mt-0.5 list-disc pl-5 break-words">
{degraded.map((reason) => (
<li key={reason}>{reason}</li>
))}
</ul>
</div>
<Badge tone={healthTone(d.health)}>{d.health}</Badge>
</div>
)}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
<StatCard label="Uptime" value={s ? formatUptime(s.uptime_s) : '—'} hint={s?.version || undefined} loading={status.loading && !s} />
<StatCard
label="Queries / s"
value={formatCompact(d.queries_per_sec)}
hint={`${formatNumber(d.queries_total)} total`}
icon={Activity}
label="Blocklist rules"
value={formatNumber(c?.blocklist?.rules ?? 0)}
hint={`${formatNumber(c?.blocklist?.sources ?? 0)} sources`}
loading={status.loading && !s}
/>
<StatCard
label="Cache hit rate"
value={formatFraction(hitRate)}
hint="higher is better"
icon={Gauge}
tone={hitRate >= 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}
/>
<StatCard
label="Blocked"
value={formatPercent(d.blocked_pct)}
hint={`${formatNumber(d.blocked_total)} queries`}
icon={Ban}
/>
<StatCard
label="p95 latency"
value={p95 != null ? formatMs(p95) : '—'}
hint="slowest upstream"
icon={Timer}
label="Cache"
value={cache?.enabled ? formatNumber(cache.entries) : 'off'}
hint={cache?.enabled ? `${formatBytes(cache.bytes)} · ${formatFraction(cache.hit_rate)} hit rate on this pod` : undefined}
loading={status.loading && !s}
/>
</div>
<p className="text-xs text-slate-500 dark:text-slate-400">
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
</p>
<div className="grid grid-cols-1 gap-3 xl:grid-cols-2">
<Card title="Queries" subtitle={`Last ${range} · total, cache hits and errors`}>
<CardBody>
<div className="h-64 w-full">
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={series} margin={{ top: 4, right: 8, bottom: 0, left: 0 }}>
<CartesianGrid stroke={GRID} strokeDasharray="3 3" vertical={false} />
<XAxis
dataKey="t"
tickFormatter={formatClock}
tick={{ fill: AXIS_TEXT, fontSize: 11 }}
stroke={GRID}
minTickGap={24}
/>
<YAxis
tickFormatter={formatCompact}
tick={{ fill: AXIS_TEXT, fontSize: 11 }}
stroke={GRID}
width={48}
/>
<ChartTooltip content={<ChartTip />} cursor={{ stroke: GRID }} />
<Area
type="monotone"
dataKey="queries"
name="queries"
stroke={QUERIES}
fill={QUERIES}
fillOpacity={0.18}
strokeWidth={1.5}
isAnimationActive={false}
/>
<Line
type="monotone"
dataKey="cache_hits"
name="cached"
stroke={CACHED}
strokeWidth={1.5}
dot={false}
isAnimationActive={false}
/>
<Line
type="monotone"
dataKey="errors"
name="errors"
stroke={ERRORS}
strokeWidth={1.5}
dot={false}
isAnimationActive={false}
/>
</ComposedChart>
</ResponsiveContainer>
<Card
title="Traffic and logs"
subtitle="The resolver stores neither. Charts come from its Prometheus metrics, queries from its access log."
>
<CardBody>
{grafana || logs || metrics ? (
<div className="flex flex-wrap gap-2">
{grafana ? <LinkButton href={grafana} icon={BarChart3}>Dashboards</LinkButton> : null}
{logs ? <LinkButton href={logs} icon={ScrollText}>Query log</LinkButton> : null}
{metrics ? <LinkButton href={metrics} icon={Activity}>Metrics</LinkButton> : null}
</div>
</CardBody>
</Card>
<Card title="Blocked rate" subtitle={`Blocked queries per bucket, last ${range}`}>
<CardBody>
<div className="h-64 w-full">
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={series} margin={{ top: 4, right: 8, bottom: 0, left: 0 }}>
<CartesianGrid stroke={GRID} strokeDasharray="3 3" vertical={false} />
<XAxis
dataKey="t"
tickFormatter={formatClock}
tick={{ fill: AXIS_TEXT, fontSize: 11 }}
stroke={GRID}
minTickGap={24}
/>
<YAxis
tickFormatter={formatCompact}
tick={{ fill: AXIS_TEXT, fontSize: 11 }}
stroke={GRID}
width={48}
/>
<ChartTooltip content={<ChartTip />} cursor={{ stroke: GRID }} />
<Bar
dataKey="blocked"
name="blocked"
fill={BLOCKED}
radius={[3, 3, 0, 0]}
isAnimationActive={false}
/>
<Line
type="monotone"
dataKey="queries"
name="queries"
stroke={QUERIES}
strokeWidth={1.5}
dot={false}
isAnimationActive={false}
/>
</ComposedChart>
</ResponsiveContainer>
</div>
</CardBody>
</Card>
</div>
<div className="grid grid-cols-1 gap-3 xl:grid-cols-2">
<Card title="Top blocked domains" subtitle="Ranked by hits in the selected range">
<CardBody>
<div className="h-72 w-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={topBlocked}
layout="vertical"
margin={{ top: 0, right: 16, bottom: 0, left: 0 }}
>
<CartesianGrid stroke={GRID} strokeDasharray="3 3" horizontal={false} />
<XAxis
type="number"
tickFormatter={formatCompact}
tick={{ fill: AXIS_TEXT, fontSize: 11 }}
stroke={GRID}
/>
<YAxis
type="category"
dataKey="domain"
width={180}
tick={{ fill: AXIS_TEXT, fontSize: 11 }}
stroke={GRID}
tickFormatter={(value: string) =>
value.length > 26 ? `${value.slice(0, 25)}…` : value
}
/>
<ChartTooltip content={<ChartTip />} cursor={{ fill: 'rgba(148,163,184,0.12)' }} />
<Bar dataKey="count" name="blocked" fill={BLOCKED} radius={[0, 3, 3, 0]} isAnimationActive={false} />
</BarChart>
</ResponsiveContainer>
</div>
</CardBody>
</Card>
<Card
title="Recent blocks"
subtitle={recent.length > 0 ? 'Newest entries from the server' : 'From the top-blocked ranking'}
>
<CardBody>
{recentSource.length === 0 ? (
<EmptyState
title="Nothing blocked yet"
description="No blocked queries in the selected range."
/>
) : (
<ul className="divide-y divide-slate-200 dark:divide-slate-800">
{recentSource.map((e, i) => (
<li key={`${e.name}-${i}`} className="flex items-center gap-3 px-4 py-2">
<Badge tone="warn">{e.action || 'block'}</Badge>
<span className="min-w-0 flex-1 truncate font-mono text-xs">{e.name}</span>
{e.list ? (
<span className="hidden truncate text-xs text-slate-500 sm:inline dark:text-slate-400">
{e.list}
</span>
) : null}
{e.ts ? (
<span className="font-mono text-xs text-slate-400">{formatClock(e.ts)}</span>
) : null}
</li>
))}
</ul>
)}
</CardBody>
</Card>
</div>
) : (
<p className="text-sm text-fg-muted">
Not linked. Set <code className="font-mono">api.links.grafana</code> and{' '}
<code className="font-mono">api.links.logs</code> to show buttons here. Metrics are served at{' '}
<code className="font-mono">/metrics</code>; 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`
: ''}
.
</p>
)}
</CardBody>
</Card>
<div className="grid grid-cols-1 gap-3 xl:grid-cols-3">
<div className="xl:col-span-2">
<Card
title="Upstreams"
subtitle="Health, latency relative to the slowest server, and fail rate"
actions={<HealthPill health={d.health} />}
>
<Card title="Upstreams" subtitle="Per-pod health as seen by the pod answering this page">
<CardBody>
<div className="overflow-x-auto">
<Table
@@ -460,26 +187,33 @@ export default function Dashboard() {
rows={upstreamRows}
rowKey={(row) => String(row.__key)}
empty="No upstreams are configured."
loading={loading && upstreams.length === 0}
loading={upstreams.loading && upstreamRows.length === 0}
/>
</div>
</CardBody>
</Card>
</div>
<Card title="Top queried" subtitle="Most requested names in range">
<Card
title="Listeners"
actions={
cache?.enabled ? (
<Button variant="outline" size="sm" icon={Trash2} onClick={onFlush}>
Flush cache
</Button>
) : undefined
}
>
<CardBody>
{topQueried.length === 0 ? (
<EmptyState title="No query data" description="Nothing was resolved in this range." />
{(c?.listeners ?? []).length === 0 ? (
<EmptyState title="No listeners" description="Nothing is bound for DNS." />
) : (
<ul className="divide-y divide-slate-200 dark:divide-slate-800">
{topQueried.slice(0, 15).map((q) => (
<li key={`${q.domain}-${q.qtype}`} className="flex items-center gap-2 px-4 py-2 text-sm">
<Badge tone={q.blocked ? 'warn' : 'default'}>
{formatQtype(q.qtype)}
</Badge>
<span className="min-w-0 flex-1 truncate font-mono text-xs">{q.domain}</span>
<span className="font-mono text-xs text-slate-500">{formatNumber(q.count)}</span>
<ul className="divide-y divide-line">
{(c?.listeners ?? []).map((l) => (
<li key={`${l.transport}-${l.addr}`} className="flex items-center gap-2 py-2 text-sm">
<Badge tone={l.running ? 'ok' : 'danger'}>{l.transport}</Badge>
<span className="min-w-0 flex-1 truncate font-mono text-xs">{l.addr}</span>
{l.err ? <span className="truncate text-xs text-danger">{l.err}</span> : null}
</li>
))}
</ul>
@@ -487,6 +221,11 @@ export default function Dashboard() {
</CardBody>
</Card>
</div>
<p className="text-xs text-fg-subtle">
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
</p>
</div>
)
}
-437
View File
@@ -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<string, ReactNode>
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<string | null>(null)
const [liveRows, setLiveRows] = useState<LogItem[]>([])
const [stats, setStats] = useState<LiveStats | null>(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<LogFilters>(
() => ({
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<Filter>({ 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<LogItem[]>(
() => (liveRows.length > 0 ? [...liveRows, ...items] : items).slice(0, MAX_ROWS),
[liveRows, items],
)
const onClear = async (): Promise<void> => {
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 <PageError error={error} onRetry={refetch} />
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: <span className="tabular font-mono text-xs text-fg-muted">{formatClock(entry.ts)}</span>,
client: (
<span className="block max-w-[180px] truncate font-mono text-xs">
{entry.client || '—'}
</span>
),
group: entry.client_group ? (
<Badge tone="muted">{entry.client_group}</Badge>
) : (
'—'
),
name: (
<span className="block max-w-[320px] truncate font-mono text-xs" title={entry.name}>
{entry.name}
</span>
),
qtype: (
<span className="font-mono text-xs">{formatQtype(entry.qtype, entry.qtype_name)}</span>
),
rcode: <span className="font-mono text-xs text-fg-muted">{formatRcode(entry.rcode)}</span>,
action: <Badge tone={actionTone(entry.action)}>{entry.action || 'unknown'}</Badge>,
duration: <span className="font-mono text-xs">{formatMs(entry.duration_ms)}</span>,
upstream: (
<span className="block max-w-[160px] truncate font-mono text-xs text-fg-muted">
{entry.upstream || '—'}
</span>
),
list: (
<span className="block max-w-[200px] truncate text-xs text-fg-muted" title={entry.list}>
{entry.list || '—'}
</span>
),
}))
// 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 (
<div className="space-y-4">
<PageHeader
title="Logs"
description="Every query the daemon answered, with the rule that decided it."
actions={
<div className="flex flex-wrap items-center gap-2">
<Badge
tone={live ? (connected ? 'ok' : 'danger') : 'muted'}
className="gap-1.5"
>
<span
aria-hidden="true"
className={cn(
'size-1.5 rounded-full',
live ? (connected ? 'animate-pulse-soft bg-ok' : 'animate-pulse-soft bg-danger') : 'bg-fg-subtle',
)}
/>
{live ? (connected ? 'LIVE' : 'DISCONNECTED') : 'PAUSED'}
</Badge>
<Switch
checked={live}
onChange={toggleLive}
label="Live tail"
description="Stream new entries over SSE"
/>
<Button
variant="outline"
icon={Eraser}
onClick={() => setConfirmClear(true)}
loading={clear.pending}
disabled={!canWrite}
title={canWrite ? undefined : 'This API key is read-only'}
>
Clear
</Button>
</div>
}
/>
{live && (
<div className="flex flex-wrap items-center gap-x-5 gap-y-1 rounded-lg border border-line bg-surface px-4 py-2.5 text-xs shadow-panel">
<span className="inline-flex items-center gap-1.5 text-fg-muted">
<Signal size={13} aria-hidden="true" />
<span className="tabular font-mono text-fg">{formatCompact(qps)}</span> qps
</span>
<span className="text-fg-muted">
<span className="tabular font-mono text-fg">{formatFraction(blockedPct)}</span>{' '}
blocked
</span>
<span className="text-fg-muted">
<span className="tabular font-mono text-fg">{formatFraction(hitRate)}</span> cache hits
</span>
<span className="text-fg-muted">
<span className="tabular font-mono text-fg">{formatNumber(inflight)}</span> in flight
</span>
<span className="text-fg-subtle">sampled by the daemon every few seconds</span>
{streamError !== null && (
<span className="text-danger" title={streamError}>
<WifiOff size={13} className="mr-1 inline align-[-2px]" aria-hidden="true" />
{streamError}
</span>
)}
</div>
)}
<Card title="Filters" subtitle="Free text covers name, list, upstream and client.">
<CardBody>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
<Input
label="Search"
icon={Search}
placeholder="example.com, ads, 10.0.0.0/8"
value={search}
onChange={(event) => {
setSearch(event.target.value)
applyFilter()
}}
autoComplete="off"
spellCheck={false}
/>
<Select
label="Action"
options={ACTION_OPTIONS}
value={action}
onChange={(event) => {
setAction(event.target.value)
applyFilter()
}}
/>
<Input
label="Client"
placeholder="address or group name"
value={client}
onChange={(event) => {
setClient(event.target.value)
applyFilter()
}}
autoComplete="off"
spellCheck={false}
/>
<div className="flex items-end pb-1">
<Switch
checked={blockedOnly}
onChange={(next) => {
setBlockedOnly(next)
applyFilter()
}}
label="Blocked only"
description="Hide allowed and cached answers"
/>
</div>
</div>
</CardBody>
</Card>
<Card
title="Query log"
subtitle={`${rows.length} of ${formatNumber(total)} matching entries`}
padded={false}
actions={
rows.length >= MAX_ROWS ? (
<Badge tone="warn">capped at {MAX_ROWS} rows</Badge>
) : (
<Badge tone="muted">
<Radio size={11} aria-hidden="true" />
{live ? 'streaming' : 'paused'}
</Badge>
)
}
>
<div className="overflow-x-auto">
<Table
columns={columns}
rows={tableRows}
rowKey={(row) => String(row.__key)}
loading={loading && items.length === 0 && liveRows.length === 0}
maxHeight={640}
empty="No query matched these filters."
/>
</div>
</Card>
<Pagination
page={page}
perPage={PER_PAGE}
total={total}
onPage={setPage}
/>
<ConfirmDialog
open={confirmClear}
onClose={() => 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}
/>
</div>
)
}
+2 -2
View File
@@ -17,7 +17,7 @@ export default function NotFound() {
action={
<Link to="/">
<Button variant="primary" icon={LayoutDashboard}>
Back to the dashboard
Back to the status page
</Button>
</Link>
}
@@ -26,7 +26,7 @@ export default function NotFound() {
<CardBody>
<ul className="grid grid-cols-1 gap-1.5 text-sm sm:grid-cols-2 lg:grid-cols-4">
{[
['/', 'Dashboard'],
['/', 'Status'],
['/blocklists', 'Blocklists'],
['/domains', 'Domains'],
['/clients', 'Clients'],