SPB Git forge
15commits 1branches 0releases
29.7 MBsize
maindefault branch
10 days agolast push
TypeScript 36.3% Python 31.8% Go 18% JavaScript 9.8% Shell 1.9% SQL 1.4% CSS 0.5%
6.8 KB · 145 lines tsx
Raw Blame History
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