initial commit
This commit is contained in:
1 parent
5b935301e5
commit
85453852d5
105 files changed
+24846
No files matched your search
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user