1import { createMemo, createSignal, For, onCleanup, Show, type JSX } from "solid-js";
2import type { Metric, Series } from "../types/model.ts";
3import { queries } from "../api.ts";
4import { lastGood, Loaded } from "./Loaded.tsx";
5import { TabBar } from "./TabBar.tsx";
6import { TimeChart, type ChartProps } from "./TimeChart.tsx";
7
8const RANGES = [["1h", 3600], ["6h", 21600], ["24h", 86400], ["7d", 604800]] as const;
9
10export 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. */
23function 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
39export 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. */
49export 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
60interface MetricCardProps extends Omit<ChartProps, "series" | "colors" | "onHover">, MetricQuery {
61 title: string;
62 sub?: string;
63 actions?: JSX.Element;
64}
65
66export 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}