initial commit

This commit is contained in:
developer committed 2026-09-27 11:20:34 +00:00
1 parent 5b935301e5
commit 85453852d5
105 files changed
+24846

No files matched your search

+490
View File
@@ -0,0 +1,490 @@
/**
* 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'
const TIP_BG = '#0f172a'
/** 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
}
/** `useToast()` shape is the UI kit's business; this just needs a call. */
type ToastLike = {
success?: (message: string) => void
error?: (message: string) => void
toast?: (message: string) => void
push?: (message: string) => void
}
function notify(toast: unknown, kind: 'ok' | 'fail', message: string): void {
const t = toast as ToastLike
const fn = kind === 'ok' ? (t.success ?? t.toast ?? t.push) : (t.error ?? t.toast ?? t.push)
fn?.call(t, message)
}
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 size={14} />}
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 size={16} />}
/>
<StatCard
label="Cache hit rate"
value={formatRate(hitRate)}
hint="higher is better"
icon={<Gauge size={16} />}
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 size={16} />}
/>
<StatCard
label={latencyLabel}
value={latencyValue != null ? formatMs(latencyValue) : '—'}
hint={p99 != null ? 'from the server' : 'slowest upstream p95'}
icon={<Timer size={16} />}
/>
</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>
)
}