spb/internetpressure
Public
TypeScript 36.3%
Python 31.8%
Go 18%
JavaScript 9.8%
Shell 1.9%
SQL 1.4%
CSS 0.5%
1'use client';23import { useEffect, useMemo, useState } from 'react';4import { fmt } from '@/lib/format';5import { COMPONENT_LABEL, COMPONENT_ORDER } from '@/lib/pressure';6import { useTime } from '@/lib/time';7import type { ComponentId, History, HistoryRange } from '@/lib/types';8import { AXIS_STYLE, TOOLTIP_STYLE, levelMarkArea, type EChartsOption, type LineSeriesOption } from './echarts';9import { useEChart } from './useEChart';1011const COMP_COLORS: Record<ComponentId, string> = {12 routing: '#5B8DEF',13 latency: '#E9C46A',14 dns: '#4CC9F0',15 availability: '#E76F51',16 http_tls: '#F4A261',17 path: '#B497F0',18 corroboration: '#8B98A5',19};20const RANGES: HistoryRange[] = ['1h', '6h', '24h', '7d', '30d'];2122/**23 * Pressure history: area of the scope pressure with level bands, optional component lines, range switcher.24 * `initial` is the SSR 24h response; other ranges are fetched client-side (server-side aggregation, spec §51).25 */26export function HistoryChart({ initial, scopeType = 'global', scopeId = null, height = 280, showComponents = true, title }: { initial: History | null; scopeType?: string; scopeId?: string | null; height?: number; showComponents?: boolean; title?: string }) {27 const [range, setRange] = useState<HistoryRange>(initial?.range ?? '24h');28 const [fetched, setFetched] = useState<History | null>(null);29 const [comps, setComps] = useState<Set<ComponentId>>(new Set());30 const { mode, format } = useTime();31 const useInitial = Boolean(initial && range === initial.range);32 const data = useInitial ? initial : fetched && fetched.range === range ? fetched : null;33 const loading = !useInitial && data === null;3435 useEffect(() => {36 if (useInitial) return;37 const ctrl = new AbortController();38 const qs = new URLSearchParams({ scope_type: scopeType, range });39 if (scopeId) qs.set('scope_id', scopeId);40 fetch(`/api/v1/pressure/history?${qs}`, { signal: ctrl.signal })41 .then((r) => (r.ok ? r.json() : Promise.reject(new Error(String(r.status)))))42 .then((d: History) => setFetched(d))43 .catch(() => {});44 return () => ctrl.abort();45 }, [range, scopeType, scopeId, useInitial]);4647 const option = useMemo<EChartsOption | null>(() => {48 if (!data) return null;49 const ts = data.points.map((p) => new Date(p.ts).getTime());50 const series: LineSeriesOption[] = [51 {52 name: 'Pressure',53 type: 'line',54 data: ts.map((t, i) => [t, data.points[i]!.pressure]),55 showSymbol: false,56 smooth: false,57 lineStyle: { width: 1.5, color: '#E6EDF3' },58 areaStyle: { color: 'rgba(230,237,243,0.06)' },59 markArea: levelMarkArea(0.06),60 z: 3,61 },62 ];63 for (const c of comps) {64 series.push({ name: COMPONENT_LABEL[c], type: 'line', data: ts.map((t, i) => [t, data.points[i]!.components?.[c] ?? null]), showSymbol: false, lineStyle: { width: 1, color: COMP_COLORS[c] }, connectNulls: false, z: 2 });65 }66 return {67 animation: false,68 grid: { left: 36, right: 12, top: 12, bottom: 28 },69 tooltip: {70 trigger: 'axis',71 ...TOOLTIP_STYLE,72 axisPointer: { lineStyle: { color: '#243040' } },73 formatter: (params: unknown) => {74 const arr = params as { seriesName: string; value: [number, number | null]; color: string }[];75 if (!arr.length) return '';76 const t = arr[0]!.value[0];77 return `<div style="color:#8B98A5;margin-bottom:4px">${format(t, 'short')}</div>` + arr.map((p) => `<div><span style="display:inline-block;width:8px;height:8px;background:${p.color};margin-right:6px"></span>${p.seriesName} <b style="float:right;margin-left:12px">${p.value[1] == null ? '—' : fmt(p.value[1])}</b></div>`).join('');78 },79 },80 xAxis: { type: 'time', ...AXIS_STYLE, splitLine: { show: false }, axisLabel: { ...AXIS_STYLE.axisLabel, formatter: (v: number) => format(v, range === '1h' || range === '6h' ? 'time' : range === '24h' ? 'short' : 'date').replace(' UTC', '') } },81 yAxis: { type: 'value', min: 0, max: 100, interval: 25, ...AXIS_STYLE },82 series,83 };84 // `mode` is a dependency because the axis formatter closes over the UTC/local preference.85 // eslint-disable-next-line react-hooks/exhaustive-deps86 }, [data, comps, range, mode]);8788 const { ref } = useEChart(option);8990 return (91 <div>92 <div className="mb-2 flex flex-wrap items-center justify-between gap-2">93 <div className="flex flex-wrap items-center gap-3">94 {title && <span className="text-[13px] text-ink">{title}</span>}95 <div role="radiogroup" aria-label="Range" className="inline-flex overflow-hidden rounded-[4px] border border-line text-[10.5px]">96 {RANGES.map((r) => (97 <button key={r} type="button" role="radio" aria-checked={range === r} onClick={() => setRange(r)} className={`num px-2 py-1 ${range === r ? 'bg-panel-2 text-ink' : 'text-ink-3 hover:text-ink-2'}`}>98 {r}99 </button>100 ))}101 </div>102 {loading && <span className="text-[10.5px] text-ink-3">loading…</span>}103 </div>104 {showComponents && (105 <div className="flex flex-wrap gap-1" role="group" aria-label="Component lines">106 {COMPONENT_ORDER.filter((c) => c !== 'corroboration').map((c) => {107 const on = comps.has(c);108 return (109 <button110 key={c}111 type="button"112 aria-pressed={on}113 onClick={() =>114 setComps((s) => {115 const n = new Set(s);116 if (n.has(c)) n.delete(c);117 else n.add(c);118 return n;119 })120 }121 className={`inline-flex items-center gap-1.5 rounded-[3px] border px-1.5 py-0.5 text-[10.5px] ${on ? 'border-line-2 text-ink' : 'border-line text-ink-3 hover:text-ink-2'}`}122 >123 <span className="size-1.5 rounded-full" style={{ background: COMP_COLORS[c], opacity: on ? 1 : 0.4 }} aria-hidden="true" />124 {COMPONENT_LABEL[c]}125 </button>126 );127 })}128 </div>129 )}130 </div>131 <div ref={ref} style={{ height }} className="w-full" role="img" aria-label={`Pressure history, ${range}`} />132 {data && (133 <p className="num mt-1 flex flex-wrap gap-x-4 text-[10.5px] text-ink-3">134 <span>min {fmt(data.summary.min)}</span>135 <span>avg {fmt(data.summary.avg)}</span>136 <span>137 max {fmt(data.summary.max)} at {format(data.summary.max_ts, 'short')}138 </span>139 <span>step {data.step_seconds >= 3600 ? `${data.step_seconds / 3600} h` : data.step_seconds >= 60 ? `${data.step_seconds / 60} min` : `${data.step_seconds} s`}</span>140 </p>141 )}142 </div>143 );144}145