portal-mojo 0.2.12 → 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,75 @@ 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.
80
+
81
+ Sparse histories can opt into numeric X positions without changing categorical
82
+ callers:
83
+
84
+ ```tsx
85
+ <SeriesChart
86
+ labels={['10:01', '10:02', '10:03', '10:12', '10:13']}
87
+ xValues={[60, 120, 180, 720, 780]}
88
+ xDomain={[0, 900]}
89
+ maxGap={180}
90
+ datasets={[{ label: 'CPU', data: [12, 24, null, 40, 32] }]}
91
+ minValue={0} maxValue={100}
92
+ ariaLabel="Kiosk CPU history"
93
+ valueFormatter={(value) => `${value}%`}
94
+ onInspect={(index) => { /* selected original sample index, or null */ }}
95
+ />
96
+ ```
97
+
98
+ - `Dataset.data` accepts `(number | null | undefined)[]`. Null, undefined,
99
+ `NaN`, and infinities are unavailable: no zero substitution, connecting
100
+ line, area fill across the gap, bar, or inspection dot. Tooltips say
101
+ **Unavailable**; incomplete stacked totals do too. A one-point run always
102
+ has a dot, even above 40 labels or with routine `showDots={false}`.
103
+ - `xValues?: number[]` aligns with the original labels/data indices. Supply
104
+ ordered timestamps (seconds or milliseconds both work); labels are display
105
+ strings only. Numeric spacing uses actual values. A missing/nonfinite X
106
+ breaks the run and cannot be inspected. Reversed X movement breaks the run.
107
+ With no `xValues`, the existing evenly spaced category centers remain.
108
+ - `xDomain?: [number, number]` fixes the visible window, including empty time
109
+ before/after the samples. Out-of-window samples are omitted from drawing,
110
+ inspection, and automatic Y scaling. Omit it to fit the finite X values;
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.
122
+ - `maxGap?: number` breaks line/area runs when the distance between consecutive
123
+ numeric X values exceeds the limit, in the **same units** as `xValues`.
124
+ It does not synthesize timestamps or missing buckets. Omit it for no extra
125
+ distance threshold. Negative/nonfinite limits are ignored.
126
+ - `minValue` and `maxValue` set exact finite Y bounds; omitted ends are
127
+ automatically scaled. Geometry is clipped at the plot bounds. A reversed
128
+ pair falls back to automatic bounds. Percentage callers can use `0`/`100`.
129
+ - `ariaLabel` names the focusable SVG. Left/Right inspect adjacent original
130
+ samples; Home/End inspect the first/last visible timestamp; Escape and blur
131
+ clear inspection. A live accessible readout announces the label and each
132
+ visible series value. Pointer movement and touch use that same index and
133
+ `onInspect(index: number | null)` callback. Touch selection stays after lift;
134
+ pointer cancellation clears it. Changing series, values, labels, X positions,
135
+ or domain clears inspection; equivalent new arrays do not.
136
+
137
+ `npm run verify:series-chart` mounts the real component to cover these
138
+ contracts alongside categorical stacked/grouped bars and container resize.
71
139
 
72
140
  ## MetricsChart (the django-mojo metrics widget)
73
141
 
@@ -125,6 +193,78 @@ smearing).
125
193
  `exportChartPng`), `withDelta` endpoint switching (KPIStrip covers the
126
194
  KPI use-case).
127
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
+
128
268
  ## MiniChart (sparkline primitive)
129
269
 
130
270
  ```tsx
@@ -267,15 +407,22 @@ Pure helpers (`parsePieInput`, `buildPieGeometry`, `goldenAngleColor`,
267
407
  ## Stats + view-data dialogs (shared)
268
408
 
269
409
  ```ts
270
- showSeriesStats({ title, labels, datasets, granularity, formatter });
271
- 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
272
412
  ```
273
413
 
274
414
  Awaitable native-`<dialog>`s (modal manager). Stats: per-series
275
415
  Latest/Min/Max/Avg/Median/Sum with the "Hourly · N points" header. Data:
276
416
  one row per label, one column per series; CSV downloads as
277
417
  `<title-slug>-<date>.csv` (RFC-safe quoting via `buildCsv`). Empty data
278
- 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).
279
426
 
280
427
  ## exportChartPng
281
428
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portal-mojo",
3
- "version": "0.2.12",
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
  }