portal-mojo 0.2.13 → 0.2.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -27,6 +27,12 @@ height; embedded/page hosts have no modal minimum or maximum and may omit
27
27
  `onClose`. A flush modal has a fallback close control while its body loads or
28
28
  fails; successful DetailView bodies supply the normal header close.
29
29
 
30
+ `modal.open(render, { signal })` optionally binds a dialog to an AbortSignal.
31
+ Already-aborted signals resolve null without adding an entry. Later abort removes
32
+ only that entry immediately and resolves null, even before `ModalHost` mounts;
33
+ normal close removes the listener. Owner disposal bypasses `canDismiss` because
34
+ it ends the data scope. An owner must also abort its controller on unmount.
35
+
30
36
  ## Executable inventory
31
37
 
32
38
  `scripts/admin-modal-inventory.json` records file, owner, launcher/helper, body,
package/docs/charts.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  ```ts
4
4
  import {
5
- SeriesChart, MetricsChart, MiniChart, MetricsMiniWidget,
5
+ SeriesChart, MetricsChart, MetricsChartView, MiniChart, MetricsMiniWidget,
6
6
  KPITile, KPIStrip, CircularProgress, PieChart,
7
7
  showSeriesStats, showSeriesData, exportChartPng,
8
8
  computeSeriesStats, computeTrend, deltaBadge, // pure math
@@ -67,7 +67,16 @@ daterange triple — `/api/metrics/fetch` ignores it silently. web-mojo sent
67
67
 
68
68
  Legend entries toggle series; hover shows the crosshair index tooltip;
69
69
  ticks are "nice"-rounded; container width is measured (no viewBox
70
- smearing).
70
+ smearing). The left margin measures formatted Y-axis labels before paint and
71
+ expands for values such as `100.0 MB`, preserving their leading digits inside
72
+ clipped containers. Short percentage labels retain the compact 48px margin;
73
+ switching formatters or hiding Y labels restores it. Plot geometry, numeric X
74
+ ticks and pointer inspection share the adjusted origin.
75
+ The margin is capped at 40% of chart width (at most 160px), preserving plot
76
+ space for exceptional formatters. Longer labels shorten with ellipsis inside
77
+ a clipping boundary; SVG titles and accessible labels retain the full text.
78
+ Measurement always uses the full hidden text, so resizing cannot alternate
79
+ between full and abbreviated widths.
71
80
 
72
81
  Sparse histories can opt into numeric X positions without changing categorical
73
82
  callers:
@@ -100,6 +109,16 @@ callers:
100
109
  before/after the samples. Out-of-window samples are omitted from drawing,
101
110
  inspection, and automatic Y scaling. Omit it to fit the finite X values;
102
111
  a single distinct X is centered. Invalid/reversed domains fall back to fit.
112
+ - Numeric axes use 2–6 evenly spaced ticks over that domain, including its
113
+ endpoints, independent of clustered sample positions. `xValueFormatter?:
114
+ (value: number) => string` formats those ticks in the same units as `xValues`;
115
+ for epoch seconds, for example, return `new Date(value * 1000).toLocaleTimeString([], {
116
+ hour: '2-digit', minute: '2-digit' })`. Without a formatter, ticks show numeric
117
+ values. Width and label length reduce tick density; very long labels shorten
118
+ with ellipsis inside nonoverlapping slots and retain full text in SVG titles.
119
+ Inspection tooltips and accessible readouts always retain the original
120
+ `labels`. No tick adds a sample or moves a data point. Categorical axes retain
121
+ their existing label thinning, and `showXLabels={false}` hides either axis.
103
122
  - `maxGap?: number` breaks line/area runs when the distance between consecutive
104
123
  numeric X values exceeds the limit, in the **same units** as `xValues`.
105
124
  It does not synthesize timestamps or missing buckets. Omit it for no extra
@@ -174,6 +193,78 @@ contracts alongside categorical stacked/grouped bars and container resize.
174
193
  `exportChartPng`), `withDelta` endpoint switching (KPIStrip covers the
175
194
  KPI use-case).
176
195
 
196
+ ## MetricsChartView (caller-owned data)
197
+
198
+ `MetricsChart` renders this exact header, toolbar, legend, chart and dialogs.
199
+ Use the view when a caller already owns a private snapshot or a different report
200
+ endpoint. It has no query, cache, transport, timer or polling lifecycle.
201
+
202
+ ```tsx
203
+ import { MetricsChartView, metricsQuickWindow, QUICK_RANGES } from 'portal-mojo/charts';
204
+ import type { MetricsChartWindow } from 'portal-mojo/charts';
205
+
206
+ const [window, setWindow] = useState<MetricsChartWindow>(() => metricsQuickWindow('1h', 'minutes'));
207
+ <MetricsChartView
208
+ title="Resource history" window={window} onWindowChange={setWindow}
209
+ labels={snapshot.labels} datasets={snapshot.datasets}
210
+ xValues={snapshot.timestamps} xDomain={[window.dt_start, window.dt_end]}
211
+ maxGap={180} minValue={0} maxValue={100} smoothing={0}
212
+ valueFormatter={(n) => `${n.toFixed(1)}%`}
213
+ loading={loading} isFetching={refreshing} error={error}
214
+ onRefresh={refreshSnapshot} onRetry={refreshSnapshot}
215
+ allowedRanges={QUICK_RANGES.filter((r) => r.value !== '30d')}
216
+ allowedGranularities={['minutes', 'hours', 'days']}
217
+ minDate={retentionStart} maxDate={latestSupportedTime}
218
+ signal={scope.signal} height={240} className="resource-chart"
219
+ />
220
+ ```
221
+
222
+ - `window` is controlled: `{dt_start, dt_end, granularity, range?}`. Bounds are
223
+ **epoch seconds**. `range` is the active quick-range identity; absent means
224
+ custom. `onWindowChange` emits one complete window for a quick range,
225
+ custom calendar Apply, or granularity change. The caller writes it back,
226
+ clears stale data and performs its request. The view does not request on mount.
227
+ - `onRefresh` and `onRetry` are independent callbacks (Retry defaults to
228
+ Refresh). Refresh calls the owner once and never changes the window itself.
229
+ For rolling windows the owner may call `metricsQuickWindow(window.range,
230
+ window.granularity)` to re-anchor, then load exactly once. The fetch-owning
231
+ `MetricsChart` adapter retains its automatic re-anchoring and `refreshSignal`.
232
+ - `allowedRanges` takes `{value, label, durationMs}[]` (default `QUICK_RANGES`).
233
+ The quick-range helper also accepts one such object. `allowedGranularities`
234
+ narrows the standard vocabulary, then range gating disables unsuitable
235
+ buckets. An explicit empty list exposes no available granularities.
236
+ `minDate` and `maxDate` remove unsupported quick/day presets and restrict
237
+ the calendar; partial first/last days are clamped at Apply to exact epochs.
238
+ The caller still validates its source contract before loading.
239
+ - All `SeriesChartProps` pass through, including sparse values, numeric X,
240
+ domains, gap thresholds, exact Y bounds, stacking, accessible inspection,
241
+ labels and legend toggles. Stats/table/CSV exclude nonfinite or out-of-domain
242
+ X positions when numeric X is provided. They never synthesize samples.
243
+ - `defaultType` defaults to line. To control type externally, supply
244
+ `chartType` and `onTypeChange`; otherwise the view owns this display choice.
245
+ Existing `showGranularity`, `showDateRange`, `showTypeSwitch`, `showRefresh`,
246
+ `showStats`, `showDataTable` flags default true. `disabled` disables controls;
247
+ loading and error states disable captured-data dialogs. `height` is the SVG
248
+ plot's pixel height, without scaling its text; `className` supports a host's
249
+ fixed-height/flex layout. Narrow containers use the granularity select.
250
+ `content` optionally replaces only the plot with an owner-rendered report
251
+ table; the same toolbar, status overlays and abort boundary remain. Hide
252
+ `showTypeSwitch`/`showDataTable` when an inline report already serves those
253
+ actions. `showGranularity={false}` also omits bucket nouns from dialogs.
254
+ - `signal` must belong to the immutable data scope. Abort blanks the entire
255
+ view and removes its dialogs from the modal stack; unmount, replacement
256
+ signal, changed payload/window/bounds or loading/error/disabled transition
257
+ also dispose open dialogs. Equivalent arrays preserve them. The caller
258
+ remains responsible for clearing its data and cancelling its transport/cache.
259
+ A keyed snapshot generation is appropriate when replacement data is equal.
260
+ - Render the usual `ModalHost`. Abort before the host mounts still disposes
261
+ pending entries. Already-aborted signals cannot reopen private data.
262
+
263
+ Showcase → Charts includes owned-window, loading, error, narrow-container and
264
+ snapshot-expiry examples. `npm run verify:metrics-chart` mounts the real view
265
+ and legacy wrapper, covering callbacks, request counts, retention, sparse
266
+ stats/CSV, replacement/unmount/abort and pre-host modal disposal.
267
+
177
268
  ## MiniChart (sparkline primitive)
178
269
 
179
270
  ```tsx
@@ -316,15 +407,22 @@ Pure helpers (`parsePieInput`, `buildPieGeometry`, `goldenAngleColor`,
316
407
  ## Stats + view-data dialogs (shared)
317
408
 
318
409
  ```ts
319
- showSeriesStats({ title, labels, datasets, granularity, formatter });
320
- showSeriesData({ title, labels, datasets, granularity, formatter }); // + Download CSV
410
+ showSeriesStats({ title, labels, datasets, granularity, formatter, signal });
411
+ showSeriesData({ title, labels, datasets, granularity, formatter, signal }); // + Download CSV
321
412
  ```
322
413
 
323
414
  Awaitable native-`<dialog>`s (modal manager). Stats: per-series
324
415
  Latest/Min/Max/Avg/Median/Sum with the "Hourly · N points" header. Data:
325
416
  one row per label, one column per series; CSV downloads as
326
417
  `<title-slug>-<date>.csv` (RFC-safe quoting via `buildCsv`). Empty data
327
- renders "No data to display." — never a blank dialog.
418
+ renders "No data to display." — never a blank dialog. `signal` is optional;
419
+ abort removes the dialog even if its host is not mounted. Numeric zero remains
420
+ zero. Null, undefined and nonfinite observations show **Unavailable** and export
421
+ as empty CSV cells. Aggregates use finite observations only; each series lists
422
+ its observed Samples count. Latest means the final slot, so a missing final
423
+ observation stays unavailable instead of substituting an earlier reading.
424
+ `computeSeriesStats` keeps numeric result field types: unavailable results are
425
+ `NaN`; always guard custom formatters with `Number.isFinite` (the dialogs do).
328
426
 
329
427
  ## exportChartPng
330
428
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portal-mojo",
3
- "version": "0.2.13",
3
+ "version": "0.2.14",
4
4
  "description": "React portal toolkit for django-mojo — typed client + params store, mission-control UI, dependency-free SVG charts, admin section bundles.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -1,29 +1,12 @@
1
- // MetricsChart — the django-mojo metrics surface: fetches /api/metrics/fetch
2
- // and owns the control bar. Ported from web-mojo's MetricsChart:
3
- // · inline granularity toggle MIN HR DAY WK MO (collapses to a <select>)
4
- // · quick ranges 1H 24H 7D 30D + Custom range… dialog
5
- // · chart type switch line / bar / area (bars stack by default)
6
- // · stats summary + view-data dialogs (native <dialog>, CSV download)
7
- // · refresh + loading/error overlays with retry
8
- //
9
- // Wire note (C2): /api/metrics/fetch takes `dt_start`/`dt_end` (epoch
10
- // SECONDS — objict.parse_date reads digit strings as epochs). web-mojo sent
11
- // `dr_start`/`dr_end` here, which the metrics endpoint silently IGNORES
12
- // (`dr_*` is the model-LIST daterange triple) — its custom ranges never
13
- // reached the backend. This port sends dt_*. The `range` param rides along
14
- // for quick ranges because the shipped mock windows off it; each side
15
- // ignores the other's param.
1
+ // Query-owning django-mojo adapter over the shared MetricsChartView.
16
2
  import { useEffect, useRef, useState } from 'react';
17
3
  import { useQuery } from '@tanstack/react-query';
18
4
  import { mojoMetrics, type MetricsResponse } from '../client/client';
19
5
  import type { Params } from '../client/types';
20
- import { DateRangePicker } from '../ui/date/DateRangePicker';
21
- import { modal } from '../ui/modal';
22
- import { showSeriesData, showSeriesStats } from './chart-dialogs';
23
- import { SeriesChart, type ChartType } from './SeriesChart';
24
- import { granularitiesForSpanMs, quickRangeWindow, ymdRangeToEpochSeconds } from './stats';
25
-
26
- export interface Granularity { value: string; label: string; short: string }
6
+ import type { ChartType } from './SeriesChart';
7
+ import { MetricsChartView } from './MetricsChartView';
8
+ import { metricsGranularities, metricsQuickWindow, type MetricsChartWindow } from './metrics-window';
9
+ export { GRANULARITIES, QUICK_RANGES, type Granularity } from './metrics-window';
27
10
 
28
11
  export interface MetricsFetchParams extends Params {
29
12
  slugs: string;
@@ -37,35 +20,6 @@ export interface MetricsFetchParams extends Params {
37
20
 
38
21
  export type MetricsSeriesLoader = (params: MetricsFetchParams) => Promise<MetricsResponse>;
39
22
 
40
- export const GRANULARITIES: Granularity[] = [
41
- { value: 'minutes', label: 'Minutes', short: 'MIN' },
42
- { value: 'hours', label: 'Hours', short: 'HR' },
43
- { value: 'days', label: 'Days', short: 'DAY' },
44
- { value: 'weeks', label: 'Weeks', short: 'WK' },
45
- { value: 'months', label: 'Months', short: 'MO' },
46
- ];
47
-
48
- export const QUICK_RANGES = [
49
- { value: '1h', label: '1H' },
50
- { value: '24h', label: '24H' },
51
- { value: '7d', label: '7D' },
52
- { value: '30d', label: '30D' },
53
- ];
54
-
55
- /** Granularities that make sense for a range — mirrors the backend's buckets. */
56
- const RANGE_GRANULARITY: Record<string, string[]> = {
57
- '1h': ['minutes'],
58
- '24h': ['minutes', 'hours'],
59
- '7d': ['hours', 'days'],
60
- '30d': ['days', 'weeks', 'months'],
61
- };
62
-
63
- const TYPES: { value: ChartType; icon: string; title: string }[] = [
64
- { value: 'line', icon: 'bi-graph-up', title: 'Line' },
65
- { value: 'bar', icon: 'bi-bar-chart-fill', title: 'Bar (stacked)' },
66
- { value: 'area', icon: 'bi-graph-up-arrow', title: 'Area' },
67
- ];
68
-
69
23
  /** 'api_calls' → 'Api Calls' — the default legend name for an unmapped slug. */
70
24
  function humanizeSlug(slug: string): string {
71
25
  return slug.split(/[_-]/).map((w) => (w ? w[0]!.toUpperCase() + w.slice(1) : w)).join(' ');
@@ -102,38 +56,6 @@ export function remapMetricSeriesIdentity(response: MetricsResponse, requestedSl
102
56
  };
103
57
  }
104
58
 
105
- /** The active window: a quick range anchored at pick time, or custom days. */
106
- type RangeState =
107
- | { kind: 'quick'; value: string; anchor: number }
108
- | { kind: 'custom'; start: string; end: string };
109
-
110
- // ── Custom-range dialog (DateRangePicker inline + explicit Apply) ─────
111
- function RangeDialog({ initial, onDone }: {
112
- initial: { start: string; end: string };
113
- onDone: (range: { start: string; end: string } | null) => void;
114
- }) {
115
- const [range, setRange] = useState(initial);
116
- const complete = !!range.start && !!range.end;
117
- return (
118
- <div className="modal-pad chart-range-dialog">
119
- <h2 className="modal-title">Custom date range</h2>
120
- <DateRangePicker
121
- inline
122
- presets="default"
123
- start={range.start}
124
- end={range.end}
125
- onChange={(e) => setRange({ start: e.start, end: e.end })}
126
- />
127
- <div className="modal-actions">
128
- <button className="btn" onClick={() => onDone(null)}>Cancel</button>
129
- <button className="btn btn-primary" disabled={!complete} onClick={() => onDone(range)}>
130
- Apply
131
- </button>
132
- </div>
133
- </div>
134
- );
135
- }
136
-
137
59
  export interface MetricsChartProps {
138
60
  title: string;
139
61
  slugs: string[];
@@ -178,92 +100,29 @@ export interface MetricsChartProps {
178
100
  refreshSignal?: unknown;
179
101
  }
180
102
 
181
- function constrainGranularities(values: readonly string[], configured?: readonly string[]): string[] {
182
- if (!configured) return [...values];
183
- const supported = new Set(GRANULARITIES.map((item) => item.value));
184
- return values.filter((value) => configured.includes(value) && supported.has(value));
185
- }
186
-
187
103
  export function MetricsChart({
188
- title,
189
- slugs,
190
- seriesLabels = {},
191
- preserveSeriesLabels = false,
192
- account = 'global',
193
- defaultRange = '24h',
194
- defaultGranularity = 'hours',
195
- defaultType = 'line',
196
- height = 280,
197
- valueFormatter,
198
- apiParams,
199
- loadSeries = mojoMetrics,
200
- seriesCacheKey = 'metrics/fetch',
201
- childKind,
202
- breakdown,
203
- showGranularity = true,
204
- showDateRange = true,
205
- showTypeSwitch = true,
206
- showRefresh = true,
207
- showStats = true,
208
- showDataTable = true,
209
- allowedGranularities,
210
- refreshSignal,
104
+ title, slugs, seriesLabels = {}, preserveSeriesLabels = false, account = 'global',
105
+ defaultRange = '24h', defaultGranularity = 'hours', defaultType = 'line',
106
+ height = 280, valueFormatter, apiParams, loadSeries = mojoMetrics,
107
+ seriesCacheKey = 'metrics/fetch', childKind, breakdown, allowedGranularities,
108
+ refreshSignal, ...controls
211
109
  }: MetricsChartProps) {
212
- const [range, setRange] = useState<RangeState>(() => ({ kind: 'quick', value: defaultRange, anchor: Date.now() }));
213
- const [granularity, setGranularity] = useState(defaultGranularity);
214
- const [chartType, setChartType] = useState<ChartType>(defaultType);
215
-
216
- // ── Window → wire params + granularity gating ─────────────────────
217
- let allowed: string[];
218
- let dtStart: number;
219
- let dtEnd: number;
220
- if (range.kind === 'quick') {
221
- allowed = RANGE_GRANULARITY[range.value] ?? ['days'];
222
- const w = quickRangeWindow(range.value, range.anchor);
223
- dtStart = Math.floor(w.startMs / 1000);
224
- dtEnd = Math.floor(w.endMs / 1000);
225
- } else {
226
- const epochs = ymdRangeToEpochSeconds(range.start, range.end);
227
- // The dialog can only apply a complete pair; a bad pair degrades to
228
- // the default quick window rather than crashing the fetch.
229
- if (!epochs) {
230
- console.warn('MetricsChart: invalid custom range — falling back to 24h', range);
231
- const w = quickRangeWindow('24h');
232
- dtStart = Math.floor(w.startMs / 1000);
233
- dtEnd = Math.floor(w.endMs / 1000);
234
- allowed = RANGE_GRANULARITY['24h']!;
235
- } else {
236
- dtStart = epochs.dtStart;
237
- dtEnd = epochs.dtEnd;
238
- allowed = granularitiesForSpanMs((dtEnd - dtStart) * 1000);
239
- }
240
- }
241
- const configured = constrainGranularities(GRANULARITIES.map((item) => item.value), allowedGranularities);
242
- const constrained = constrainGranularities(allowed, allowedGranularities);
243
- const available = constrained.length > 0 ? constrained : configured.length > 0 ? configured : constrainGranularities(allowed);
244
- const effective = available.includes(granularity) ? granularity : available[available.length - 1]!;
245
-
110
+ const [window, setWindow] = useState<MetricsChartWindow>(() => metricsQuickWindow(defaultRange, defaultGranularity));
111
+ const available = metricsGranularities(window, allowedGranularities);
112
+ const effective = available.includes(window.granularity) ? window.granularity : available.at(-1) ?? window.granularity;
246
113
  const wire: MetricsFetchParams = {
247
114
  ...apiParams,
248
115
  ...(childKind ? { child_kind: childKind } : {}),
249
116
  ...(breakdown !== undefined ? { breakdown } : {}),
250
- slugs: slugs.join(','),
251
- granularity: effective,
252
- account,
253
- // `range` windows the shipped mock; dt_start/dt_end (epoch seconds)
254
- // window the real backend. Each ignores the other's param.
255
- ...(range.kind === 'quick' ? { range: range.value } : {}),
256
- dt_start: dtStart,
257
- dt_end: dtEnd,
117
+ slugs: slugs.join(','), granularity: effective, account,
118
+ ...(window.range ? { range: window.range } : {}),
119
+ dt_start: window.dt_start, dt_end: window.dt_end,
258
120
  };
259
-
260
121
  const query = useQuery({
261
122
  queryKey: ['metrics', seriesCacheKey, wire],
262
123
  queryFn: () => loadSeries(wire),
263
124
  select: (res) => {
264
- const identitySafe = loadSeries === mojoMetrics
265
- ? remapMetricSeriesIdentity(res, slugs)
266
- : res;
125
+ const identitySafe = loadSeries === mojoMetrics ? remapMetricSeriesIdentity(res, slugs) : res;
267
126
  return {
268
127
  ...identitySafe,
269
128
  datasets: identitySafe.datasets.map((d) => ({
@@ -273,40 +132,13 @@ export function MetricsChart({
273
132
  };
274
133
  },
275
134
  });
276
-
277
- // Switching range re-points granularity at the nearest sensible bucket
278
- // rather than firing a request the backend would bucket differently.
279
- const pickRange = (next: string) => {
280
- setRange({ kind: 'quick', value: next, anchor: Date.now() });
281
- const rangeGranularities = RANGE_GRANULARITY[next] ?? ['days'];
282
- const constrainedNext = constrainGranularities(rangeGranularities, allowedGranularities);
283
- const ok = constrainedNext.length > 0 ? constrainedNext : configured.length > 0 ? configured : rangeGranularities;
284
- if (!ok.includes(granularity)) setGranularity(ok[ok.length - 1]!);
285
- };
286
-
287
- const openCustomRange = async () => {
288
- const initial = range.kind === 'custom' ? { start: range.start, end: range.end } : { start: '', end: '' };
289
- const picked = await modal.open<{ start: string; end: string } | null>((close) => (
290
- <RangeDialog initial={initial} onDone={close} />
291
- ), { size: 'md' });
292
- if (!picked) return;
293
- setRange({ kind: 'custom', start: picked.start, end: picked.end });
294
- const epochs = ymdRangeToEpochSeconds(picked.start, picked.end);
295
- if (epochs) {
296
- const rangeGranularities = granularitiesForSpanMs((epochs.dtEnd - epochs.dtStart) * 1000);
297
- const constrainedNext = constrainGranularities(rangeGranularities, allowedGranularities);
298
- const ok = constrainedNext.length > 0 ? constrainedNext : configured.length > 0 ? configured : rangeGranularities;
299
- if (!ok.includes(granularity)) setGranularity(ok[ok.length - 1]!);
300
- }
301
- };
302
-
303
- // Refresh re-anchors a quick window at NOW (web-mojo kept the stale
304
- // window — deliberate fix, documented in charts.md).
305
135
  const refresh = () => {
306
- if (range.kind === 'quick') setRange({ ...range, anchor: Math.max(Date.now(), range.anchor + 1) });
136
+ const next = window.range ? metricsQuickWindow(window.range, effective) : window;
137
+ // A pick and refresh in the same second have the same query key: refetch
138
+ // once. Otherwise the re-anchored key itself owns the one request.
139
+ if (next.dt_start !== window.dt_start || next.dt_end !== window.dt_end) setWindow(next);
307
140
  else void query.refetch();
308
141
  };
309
-
310
142
  const priorRefreshSignal = useRef(refreshSignal);
311
143
  useEffect(() => {
312
144
  if (Object.is(priorRefreshSignal.current, refreshSignal)) return;
@@ -314,146 +146,12 @@ export function MetricsChart({
314
146
  refresh();
315
147
  }, [refreshSignal]);
316
148
 
317
- const openStats = () => {
318
- void showSeriesStats({
319
- title: `${title} — Stats`,
320
- labels: query.data?.labels ?? [],
321
- datasets: query.data?.datasets ?? [],
322
- granularity: effective,
323
- formatter: valueFormatter,
324
- });
325
- };
326
- const openData = () => {
327
- void showSeriesData({
328
- title,
329
- labels: query.data?.labels ?? [],
330
- datasets: query.data?.datasets ?? [],
331
- granularity: effective,
332
- formatter: valueFormatter,
333
- });
334
- };
335
-
336
- const customActive = range.kind === 'custom';
337
- const customTitle = customActive ? `Custom range: ${range.start} – ${range.end}` : 'Custom range…';
338
-
339
- return (
340
- <div className="panel panel-pad chart-panel">
341
- <div className="chart-head">
342
- <div>
343
- <div className="eyebrow">Metrics</div>
344
- <h3 className="panel-subtitle">
345
- {title}
346
- {customActive && <span className="chart-range-chip">{range.start} – {range.end}</span>}
347
- </h3>
348
- </div>
349
- <div className="chart-controls">
350
- {showGranularity && (
351
- <>
352
- <div className="seg seg-xs gran-toggle" role="group" aria-label="Granularity">
353
- {GRANULARITIES.filter((g) => !allowedGranularities || configured.length === 0 || configured.includes(g.value)).map((g) => {
354
- const enabled = available.includes(g.value);
355
- return (
356
- <button
357
- key={g.value}
358
- className={`seg-btn${effective === g.value ? ' seg-active' : ''}`}
359
- disabled={!enabled}
360
- title={enabled ? g.label : `${g.label} — not available for this range`}
361
- onClick={() => setGranularity(g.value)}
362
- >
363
- {g.short}
364
- </button>
365
- );
366
- })}
367
- </div>
368
- <select
369
- className="input input-compact gran-select"
370
- value={effective}
371
- onChange={(e) => setGranularity(e.target.value)}
372
- aria-label="Granularity"
373
- >
374
- {GRANULARITIES.filter((g) => available.includes(g.value)).map((g) => (
375
- <option key={g.value} value={g.value}>{g.label}</option>
376
- ))}
377
- </select>
378
- </>
379
- )}
380
-
381
- {showDateRange && (
382
- <div className="seg seg-xs" role="group" aria-label="Date range">
383
- {QUICK_RANGES.map((r) => (
384
- <button
385
- key={r.value}
386
- className={`seg-btn${range.kind === 'quick' && range.value === r.value ? ' seg-active' : ''}`}
387
- onClick={() => pickRange(r.value)}
388
- >
389
- {r.label}
390
- </button>
391
- ))}
392
- <button
393
- className={`seg-btn${customActive ? ' seg-active' : ''}`}
394
- title={customTitle}
395
- aria-label="Custom date range"
396
- onClick={() => void openCustomRange()}
397
- >
398
- <i className="bi bi-calendar-range" />
399
- </button>
400
- </div>
401
- )}
402
-
403
- {showTypeSwitch && (
404
- <div className="seg seg-xs" role="group" aria-label="Chart type">
405
- {TYPES.map((t) => (
406
- <button
407
- key={t.value}
408
- className={`seg-btn${chartType === t.value ? ' seg-active' : ''}`}
409
- title={t.title}
410
- onClick={() => setChartType(t.value)}
411
- >
412
- <i className={`bi ${t.icon}`} />
413
- </button>
414
- ))}
415
- </div>
416
- )}
417
-
418
- {showStats && (
419
- <button className="btn-icon" title="Stats" aria-label="Stats" onClick={openStats}>
420
- <i className="bi bi-info-circle" />
421
- </button>
422
- )}
423
- {showDataTable && (
424
- <button className="btn-icon" title="View data" aria-label="View data" onClick={openData}>
425
- <i className="bi bi-table" />
426
- </button>
427
- )}
428
- {showRefresh && (
429
- <button className="btn-icon" title="Refresh" aria-label="Refresh" onClick={refresh}>
430
- <i className={`bi bi-arrow-repeat${query.isFetching ? ' spin' : ''}`} />
431
- </button>
432
- )}
433
- </div>
434
- </div>
435
-
436
- <div className="chart-body" style={{ minHeight: height }}>
437
- {query.isPending ? (
438
- <div className="chart-overlay"><span className="skel skel-block" /></div>
439
- ) : query.isError ? (
440
- <div className="chart-overlay">
441
- <div className="chart-error">
442
- <i className="bi bi-exclamation-triangle" />
443
- <span>{query.error instanceof Error ? query.error.message : 'Failed to load metrics'}</span>
444
- <button className="btn btn-compact" onClick={() => void query.refetch()}>Retry</button>
445
- </div>
446
- </div>
447
- ) : (
448
- <SeriesChart
449
- labels={query.data!.labels}
450
- datasets={query.data!.datasets}
451
- chartType={chartType}
452
- height={height}
453
- valueFormatter={valueFormatter}
454
- />
455
- )}
456
- </div>
457
- </div>
458
- );
149
+ return <MetricsChartView {...controls}
150
+ title={title} window={{ ...window, granularity: effective }} onWindowChange={setWindow}
151
+ labels={query.data?.labels ?? []} datasets={query.data?.datasets ?? []}
152
+ defaultType={defaultType} height={height} valueFormatter={valueFormatter}
153
+ allowedGranularities={allowedGranularities} onRefresh={refresh}
154
+ onRetry={() => { void query.refetch(); }} loading={query.isPending}
155
+ isFetching={query.isFetching} error={query.isError ? query.error : undefined}
156
+ />;
459
157
  }