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.
- package/docs/admin-modals.md +6 -0
- package/docs/charts.md +103 -5
- package/package.json +1 -1
- package/src/charts/MetricsChart.tsx +29 -331
- package/src/charts/MetricsChartView.tsx +280 -0
- package/src/charts/SeriesChart.tsx +99 -18
- package/src/charts/chart-dialogs.tsx +13 -7
- package/src/charts/index.ts +2 -0
- package/src/charts/metrics-window.ts +57 -0
- package/src/charts/stats.ts +8 -6
- package/src/styles/components/charts-c2.css +15 -0
- package/src/ui/modal.tsx +6 -0
package/docs/admin-modals.md
CHANGED
|
@@ -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.
|
|
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
|
-
//
|
|
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 {
|
|
21
|
-
import {
|
|
22
|
-
import {
|
|
23
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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 [
|
|
213
|
-
const
|
|
214
|
-
const
|
|
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
|
-
|
|
252
|
-
|
|
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
|
-
|
|
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
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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
|
}
|