SPB Git
6.7 KB · 148 lines tsx
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 [data, setData] = useState<History | null>(initial);29  const [loading, setLoading] = useState(false);30  const [comps, setComps] = useState<Set<ComponentId>>(new Set());31  const { mode, format } = useTime();3233  useEffect(() => {34    if (initial && range === initial.range) {35      setData(initial);36      return;37    }38    const ctrl = new AbortController();39    setLoading(true);40    const qs = new URLSearchParams({ scope_type: scopeType, range });41    if (scopeId) qs.set('scope_id', scopeId);42    fetch(`/api/v1/pressure/history?${qs}`, { signal: ctrl.signal })43      .then((r) => (r.ok ? r.json() : Promise.reject(new Error(String(r.status)))))44      .then((d: History) => setData(d))45      .catch(() => {})46      .finally(() => setLoading(false));47    return () => ctrl.abort();48  }, [range, scopeType, scopeId, initial]);4950  const option = useMemo<EChartsOption | null>(() => {51    if (!data) return null;52    const ts = data.points.map((p) => new Date(p.ts).getTime());53    const series: LineSeriesOption[] = [54      {55        name: 'Pressure',56        type: 'line',57        data: ts.map((t, i) => [t, data.points[i]!.pressure]),58        showSymbol: false,59        smooth: false,60        lineStyle: { width: 1.5, color: '#E6EDF3' },61        areaStyle: { color: 'rgba(230,237,243,0.06)' },62        markArea: levelMarkArea(0.06),63        z: 3,64      },65    ];66    for (const c of comps) {67      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 });68    }69    return {70      animation: false,71      grid: { left: 36, right: 12, top: 12, bottom: 28 },72      tooltip: {73        trigger: 'axis',74        ...TOOLTIP_STYLE,75        axisPointer: { lineStyle: { color: '#243040' } },76        formatter: (params: unknown) => {77          const arr = params as { seriesName: string; value: [number, number | null]; color: string }[];78          if (!arr.length) return '';79          const t = arr[0]!.value[0];80          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('');81        },82      },83      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', '') } },84      yAxis: { type: 'value', min: 0, max: 100, interval: 25, ...AXIS_STYLE },85      series,86    };87    // `mode` is a dependency because the axis formatter closes over the UTC/local preference.88    // eslint-disable-next-line react-hooks/exhaustive-deps89  }, [data, comps, range, mode]);9091  const { ref } = useEChart(option);9293  return (94    <div>95      <div className="mb-2 flex flex-wrap items-center justify-between gap-2">96        <div className="flex flex-wrap items-center gap-3">97          {title && <span className="text-[13px] text-ink">{title}</span>}98          <div role="radiogroup" aria-label="Range" className="inline-flex overflow-hidden rounded-[4px] border border-line text-[10.5px]">99            {RANGES.map((r) => (100              <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'}`}>101                {r}102              </button>103            ))}104          </div>105          {loading && <span className="text-[10.5px] text-ink-3">loading…</span>}106        </div>107        {showComponents && (108          <div className="flex flex-wrap gap-1" role="group" aria-label="Component lines">109            {COMPONENT_ORDER.filter((c) => c !== 'corroboration').map((c) => {110              const on = comps.has(c);111              return (112                <button113                  key={c}114                  type="button"115                  aria-pressed={on}116                  onClick={() =>117                    setComps((s) => {118                      const n = new Set(s);119                      if (n.has(c)) n.delete(c);120                      else n.add(c);121                      return n;122                    })123                  }124                  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'}`}125                >126                  <span className="size-1.5 rounded-full" style={{ background: COMP_COLORS[c], opacity: on ? 1 : 0.4 }} aria-hidden="true" />127                  {COMPONENT_LABEL[c]}128                </button>129              );130            })}131          </div>132        )}133      </div>134      <div ref={ref} style={{ height }} className="w-full" role="img" aria-label={`Pressure history, ${range}`} />135      {data && (136        <p className="num mt-1 flex flex-wrap gap-x-4 text-[10.5px] text-ink-3">137          <span>min {fmt(data.summary.min)}</span>138          <span>avg {fmt(data.summary.avg)}</span>139          <span>140            max {fmt(data.summary.max)} at {format(data.summary.max_ts, 'short')}141          </span>142          <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>143        </p>144      )}145    </div>146  );147}148
Files · be6a3c4c9e1838cf98ba8aa0b95cc0ee163b31bc
Menu

Theme