476 lines
17 KiB
TypeScript
476 lines
17 KiB
TypeScript
/**
|
|
* Dashboard — the "is it working" page.
|
|
*
|
|
* 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 server builds that payload, so every field is read defensively:
|
|
* a node running an older build simply shows less, it never throws.
|
|
*/
|
|
|
|
import { useMemo } from '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 { useApi } from '../hooks/useApi'
|
|
import { useAutoRefresh } from '../hooks/useAutoRefresh'
|
|
import { TIME_RANGES, useTimeRange } from '../hooks/useTimeRange'
|
|
import {
|
|
formatClock,
|
|
formatCompact,
|
|
formatMs,
|
|
formatNumber,
|
|
formatPercent,
|
|
formatRate,
|
|
formatUptime,
|
|
} from '../lib/format'
|
|
import { Badge, Button, Card, CardBody, EmptyState, Progress, StatCard, Table } 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 parts of the dashboard payload that older daemons may not send. */
|
|
type DashboardView = Partial<DashboardData> & {
|
|
degraded_reason?: string
|
|
reason?: string
|
|
message?: string
|
|
latency?: { p50_ms?: number; p95_ms?: number; p99_ms?: number }
|
|
recent?: LogItem[]
|
|
}
|
|
|
|
type Row = Record<string, ReactNode>
|
|
|
|
function view(data: DashboardData | null): DashboardView {
|
|
return (data ?? {}) as DashboardView
|
|
}
|
|
|
|
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'
|
|
if (health === 'degraded') return 'warn'
|
|
return 'danger'
|
|
}
|
|
|
|
function seriesOf(d: DashboardView) {
|
|
return d.series ?? []
|
|
}
|
|
|
|
export default function Dashboard() {
|
|
const { range, setRange } = useTimeRange('1h')
|
|
const { data, error, loading, refetch } = useApi(() => getDashboard(range), [range])
|
|
useAutoRefresh(refetch, 5000)
|
|
const d = view(data)
|
|
|
|
const series = useMemo(() => seriesOf(d), [d])
|
|
const upstreams = useMemo(() => d.upstreams ?? [], [d])
|
|
const slowest = useMemo(
|
|
() => upstreams.reduce((max, u) => Math.max(max, u.latency_ms || 0), 0),
|
|
[upstreams],
|
|
)
|
|
const topBlocked = useMemo(() => d.top_blocked ?? [], [d])
|
|
const topQueried = useMemo(() => d.top_queried ?? [], [d])
|
|
|
|
const hitRate = d.cache_hit_rate ?? 0
|
|
const p99 = d.latency?.p99_ms
|
|
const p95Fallback = useMemo(() => {
|
|
const values = upstreams.map((u) => u.p95_ms || 0).filter((v) => v > 0)
|
|
return values.length > 0 ? Math.max(...values) : undefined
|
|
}, [upstreams])
|
|
const latencyValue = p99 ?? p95Fallback
|
|
const latencyLabel = p99 != null ? 'p99 latency' : 'p95 latency'
|
|
const degraded = d.health != null && d.health !== 'ok'
|
|
const degradedReason =
|
|
d.degraded_reason ?? d.reason ?? d.message ?? 'no reason reported by the server'
|
|
|
|
if (error) return <PageError error={error} onRetry={refetch} />
|
|
|
|
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 },
|
|
]
|
|
|
|
const upstreamRows: Row[] = upstreams.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 ? formatRate(u.fail_rate) : '—'}</span>
|
|
),
|
|
queries: <span className="font-mono text-xs">{formatNumber(u.queries)}</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."
|
|
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}
|
|
>
|
|
Refresh
|
|
</Button>
|
|
</div>
|
|
}
|
|
/>
|
|
|
|
{degraded && (
|
|
<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"
|
|
>
|
|
<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>
|
|
</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="Queries / s"
|
|
value={formatCompact(d.queries_per_sec ?? 0)}
|
|
hint={`${formatNumber(d.queries_total ?? 0)} total`}
|
|
icon={Activity}
|
|
/>
|
|
<StatCard
|
|
label="Cache hit rate"
|
|
value={formatRate(hitRate)}
|
|
hint="higher is better"
|
|
icon={Gauge}
|
|
tone={hitRate >= 0.7 ? 'ok' : hitRate >= 0.4 ? 'warn' : 'danger'}
|
|
/>
|
|
<StatCard
|
|
label="Blocked"
|
|
value={formatPercent(d.blocked_pct ?? 0)}
|
|
hint={`${formatNumber(d.blocked_total ?? 0)} queries`}
|
|
icon={Ban}
|
|
/>
|
|
<StatCard
|
|
label={latencyLabel}
|
|
value={latencyValue != null ? formatMs(latencyValue) : '—'}
|
|
hint={p99 != null ? 'from the server' : 'slowest upstream p95'}
|
|
icon={Timer}
|
|
/>
|
|
</div>
|
|
|
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
|
version {d.version || 'unknown'} · up {formatUptime(d.uptime_seconds ?? 0)}
|
|
{d.node_id ? ` · node ${d.node_id}` : ''} · 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>
|
|
</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>
|
|
|
|
<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 ?? 'down'} />}
|
|
>
|
|
<CardBody>
|
|
<div className="overflow-x-auto">
|
|
<Table
|
|
columns={upstreamColumns}
|
|
rows={upstreamRows}
|
|
rowKey={(row) => String(row.__key)}
|
|
empty="No upstreams are configured."
|
|
loading={loading && upstreams.length === 0}
|
|
/>
|
|
</div>
|
|
</CardBody>
|
|
</Card>
|
|
</div>
|
|
|
|
<Card title="Top queried" subtitle="Most requested names in range">
|
|
<CardBody>
|
|
{topQueried.length === 0 ? (
|
|
<EmptyState title="No query data" description="Nothing was resolved in this range." />
|
|
) : (
|
|
<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'}>
|
|
{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>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</CardBody>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|