| 1 | import { createMemo, createSignal, For, onCleanup, Show, type JSX } from "solid-js"; |
| 2 | import type { Metric, Series } from "../types/model.ts"; |
| 3 | import { queries } from "../api.ts"; |
| 4 | import { lastGood, Loaded } from "./Loaded.tsx"; |
| 5 | import { TabBar } from "./TabBar.tsx"; |
| 6 | import { TimeChart, type ChartProps } from "./TimeChart.tsx"; |
| 7 | |
| 8 | const RANGES = [["1h", 3600], ["6h", 21600], ["24h", 86400], ["7d", 604800]] as const; |
| 9 | |
| 10 | export function RangePicker(props: { value: number; onChange: (value: number) => void }) { |
| 11 | return ( |
| 12 | <TabBar label="Time range"> |
| 13 | <For each={RANGES}> |
| 14 | {([label, seconds]) => ( |
| 15 | <button aria-pressed={props.value === seconds} onClick={() => props.onChange(seconds)}>{label}</button> |
| 16 | )} |
| 17 | </For> |
| 18 | </TabBar> |
| 19 | ); |
| 20 | } |
| 21 | |
| 22 | /** Keeps the heaviest series and folds the rest into "other" so no slot color repeats. */ |
| 23 | function fold(series: Series[], keep = 5) { |
| 24 | const mean = (item: Series) => item.v.reduce<number>((sum, v) => sum + (v ?? 0), 0) / (item.v.length || 1); |
| 25 | const ranked = [...series].sort((a, b) => mean(b) - mean(a)); |
| 26 | const top = new Set(ranked.slice(0, keep)); |
| 27 | const kept = series.filter((item) => top.has(item)); |
| 28 | const rest = series.filter((item) => !top.has(item)); |
| 29 | const colors = kept.map((_, i) => `var(--series-${i + 1})`); |
| 30 | if (!rest.length) return { series: kept, colors }; |
| 31 | const other: Series = { |
| 32 | name: "other", |
| 33 | t: rest[0]!.t, |
| 34 | v: rest[0]!.t.map((_, j) => rest.reduce((sum, item) => sum + (item.v[j] ?? 0), 0)), |
| 35 | }; |
| 36 | return { series: [...kept, other], colors: [...colors, "var(--other)"] }; |
| 37 | } |
| 38 | |
| 39 | export interface MetricQuery { |
| 40 | metric: Metric; |
| 41 | range: number; |
| 42 | service?: string; |
| 43 | /** Series per service, named by `names`, the heaviest kept and the rest folded into "other". */ |
| 44 | split?: boolean; |
| 45 | names?: Record<string, string>; |
| 46 | } |
| 47 | |
| 48 | /** A metric over the range, refreshed every 30 seconds; `shape` names and folds the loaded series for a chart. */ |
| 49 | export function useMetric(props: MetricQuery) { |
| 50 | const [data, { refetch }] = queries.metric.use(() => ({ metric: props.metric, range: props.range, service: props.service })); |
| 51 | const timer = setInterval(refetch, 30_000); |
| 52 | onCleanup(() => clearInterval(timer)); |
| 53 | const shape = (loaded: Series[]) => { |
| 54 | const series = loaded.map((item) => ({ ...item, name: props.names?.[item.name] ?? item.name })); |
| 55 | return props.split ? fold(series) : { series, colors: undefined }; |
| 56 | }; |
| 57 | return { data, refetch, shape }; |
| 58 | } |
| 59 | |
| 60 | interface MetricCardProps extends Omit<ChartProps, "series" | "colors" | "onHover">, MetricQuery { |
| 61 | title: string; |
| 62 | sub?: string; |
| 63 | actions?: JSX.Element; |
| 64 | } |
| 65 | |
| 66 | export function MetricCard(props: MetricCardProps) { |
| 67 | const { data, refetch, shape } = useMetric(props); |
| 68 | const [hover, setHover] = createSignal<number | null>(null); |
| 69 | const latest = lastGood(data); |
| 70 | // A single series has no legend, so its value, latest or hovered, sits in the title. |
| 71 | const reading = () => { |
| 72 | const series = latest(); |
| 73 | if (series?.length !== 1) return; |
| 74 | const values = series[0]!.v; |
| 75 | const value = hover() === null ? values.findLast((v) => v !== null) : values[hover()!]; |
| 76 | return value == null ? undefined : props.format(value); |
| 77 | }; |
| 78 | return ( |
| 79 | <div class="card metric"> |
| 80 | <div class="card-title"> |
| 81 | {props.title} |
| 82 | <Show when={reading()}>{(value) => <span class="reading">{value()}</span>}</Show> |
| 83 | <Show when={props.sub}><span class="sub">{props.sub}</span></Show> |
| 84 | <span class="spacer" /> |
| 85 | {props.actions} |
| 86 | </div> |
| 87 | <Loaded data={data} what="this chart" retry={refetch} skeleton={<div class="skeleton" style={{ height: "196px" }} />}> |
| 88 | {(latest) => { |
| 89 | const shaped = createMemo(() => shape(latest())); |
| 90 | return <TimeChart series={shaped().series} colors={shaped().colors} format={props.format} stacked={props.stacked} max={props.max} onHover={setHover} />; |
| 91 | }} |
| 92 | </Loaded> |
| 93 | </div> |
| 94 | ); |
| 95 | } |