Console: replace the dashboard, logs and cache pages with a status page and links
With the resolver no longer storing traffic history or a query log, the pages built on them have nothing to show. The Dashboard becomes a Status page: health and the reasons for a degraded state, uptime, what is compiled (blocklist rules, local zone records), the cache with a flush button, per-pod upstream health and the listeners. Logs and Cache are removed along with the SSE client, the chart code and the recharts dependency. api.links (grafana, logs, metrics) names where the charts and the query log live. GET /api/links serves them and the Status page shows buttons for the ones that are set, or says how to set them. They are rendered as hrefs, so config validation refuses anything but http(s) and the page checks again. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c6c5ca4ff7
commit
01202f9785
17 files changed
+277
-1980
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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'],
|
||||
|
||||
Reference in new issue
Block a user