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.
- package/docs/admin-modals.md +6 -0
- package/docs/charts.md +152 -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 +299 -68
- 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/styles/core.css +2 -0
- package/src/ui/modal.tsx +6 -0
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
// Shared MetricsChart presentation. The owner supplies every payload and request.
|
|
2
|
+
import { useLayoutEffect, useRef, useState, useSyncExternalStore, type ReactNode } from 'react';
|
|
3
|
+
import { DateRangePicker } from '../ui/date/DateRangePicker';
|
|
4
|
+
import { dayPresets, type PresetEntry } from '../ui/date/PresetRail';
|
|
5
|
+
import { formatByPrecision } from '../ui/date/fns';
|
|
6
|
+
import { modal } from '../ui/modal';
|
|
7
|
+
import { showSeriesData, showSeriesStats } from './chart-dialogs';
|
|
8
|
+
import { SeriesChart, type ChartType, type SeriesChartProps } from './SeriesChart';
|
|
9
|
+
import { ymdRangeToEpochSeconds } from './stats';
|
|
10
|
+
import { GRANULARITIES, QUICK_RANGES, metricsDateLabel, metricsGranularities, metricsQuickWindow, type MetricsChartWindow, type MetricsQuickRange } from './metrics-window';
|
|
11
|
+
|
|
12
|
+
const TYPES: { value: ChartType; icon: string; title: string }[] = [
|
|
13
|
+
{ value: 'line', icon: 'bi-graph-up', title: 'Line' },
|
|
14
|
+
{ value: 'bar', icon: 'bi-bar-chart-fill', title: 'Bar (stacked)' },
|
|
15
|
+
{ value: 'area', icon: 'bi-graph-up-arrow', title: 'Area' },
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
export interface MetricsChartViewProps extends SeriesChartProps {
|
|
19
|
+
title: string;
|
|
20
|
+
className?: string;
|
|
21
|
+
/** Replace the plot with an owner-rendered report; status and toolbar remain shared. */
|
|
22
|
+
content?: ReactNode;
|
|
23
|
+
window: MetricsChartWindow;
|
|
24
|
+
onWindowChange: (window: MetricsChartWindow) => void;
|
|
25
|
+
/** Refresh never fetches or changes the window itself; the owner does both. */
|
|
26
|
+
onRefresh?: () => void;
|
|
27
|
+
onRetry?: () => void;
|
|
28
|
+
loading?: boolean;
|
|
29
|
+
isFetching?: boolean;
|
|
30
|
+
error?: unknown;
|
|
31
|
+
disabled?: boolean;
|
|
32
|
+
defaultType?: ChartType;
|
|
33
|
+
onTypeChange?: (type: ChartType) => void;
|
|
34
|
+
allowedRanges?: readonly MetricsQuickRange[];
|
|
35
|
+
allowedGranularities?: readonly string[];
|
|
36
|
+
/** Exact supported history bounds, epoch seconds (including partial days). */
|
|
37
|
+
minDate?: number;
|
|
38
|
+
maxDate?: number;
|
|
39
|
+
/** Abort blanks this view and removes its open dialogs from the shared stack. */
|
|
40
|
+
signal?: AbortSignal;
|
|
41
|
+
showGranularity?: boolean;
|
|
42
|
+
showDateRange?: boolean;
|
|
43
|
+
showTypeSwitch?: boolean;
|
|
44
|
+
showRefresh?: boolean;
|
|
45
|
+
showStats?: boolean;
|
|
46
|
+
showDataTable?: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function RangeDialog({ initial, minDate, maxDate, onDone }: {
|
|
50
|
+
initial: { start: string; end: string };
|
|
51
|
+
minDate?: number; maxDate?: number;
|
|
52
|
+
onDone: (range: { dt_start: number; dt_end: number } | null) => void;
|
|
53
|
+
}) {
|
|
54
|
+
const [range, setRange] = useState(initial);
|
|
55
|
+
const min = minDate == null ? undefined : metricsDateLabel(minDate);
|
|
56
|
+
const max = maxDate == null ? undefined : metricsDateLabel(maxDate);
|
|
57
|
+
const epochs = ymdRangeToEpochSeconds(range.start, range.end);
|
|
58
|
+
const dt_start = epochs ? Math.max(epochs.dtStart, minDate ?? -Infinity) : NaN;
|
|
59
|
+
const dt_end = epochs ? Math.min(epochs.dtEnd, maxDate ?? Infinity) : NaN;
|
|
60
|
+
const complete = !!epochs && dt_start <= dt_end && (!min || range.start >= min) && (!max || range.end <= max);
|
|
61
|
+
// Picker presets do not enforce min/max. Offer only supported calendar days,
|
|
62
|
+
// then clamp partial first/last days to exact epoch bounds on Apply.
|
|
63
|
+
const presets: PresetEntry[] = dayPresets().filter((preset) => {
|
|
64
|
+
if (preset.divider) return minDate == null && maxDate == null;
|
|
65
|
+
const r = preset.range();
|
|
66
|
+
const start = formatByPrecision(r.start, 'day');
|
|
67
|
+
const end = formatByPrecision(r.end, 'day');
|
|
68
|
+
return (!min || start >= min) && (!max || end <= max);
|
|
69
|
+
});
|
|
70
|
+
return <div className="modal-pad chart-range-dialog">
|
|
71
|
+
<h2 className="modal-title">Custom date range</h2>
|
|
72
|
+
<DateRangePicker inline months={1} presets={presets} min={min} max={max}
|
|
73
|
+
start={range.start} end={range.end} onChange={(e) => setRange({ start: e.start, end: e.end })} />
|
|
74
|
+
<div className="modal-actions">
|
|
75
|
+
<button className="btn" onClick={() => onDone(null)}>Cancel</button>
|
|
76
|
+
<button className="btn btn-primary" disabled={!complete} onClick={() => onDone({ dt_start, dt_end })}>Apply</button>
|
|
77
|
+
</div>
|
|
78
|
+
</div>;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function MetricsChartView({
|
|
82
|
+
title, className = '', content, window: selectedWindow, onWindowChange, onRefresh, onRetry = onRefresh,
|
|
83
|
+
loading = false, isFetching = false, error, disabled = false,
|
|
84
|
+
chartType: controlledType, defaultType = 'line', onTypeChange,
|
|
85
|
+
allowedRanges = QUICK_RANGES, allowedGranularities, minDate, maxDate, signal,
|
|
86
|
+
showGranularity = true, showDateRange = true, showTypeSwitch = true,
|
|
87
|
+
showRefresh = true, showStats = true, showDataTable = true,
|
|
88
|
+
height = 280, labels, datasets, valueFormatter, ...seriesProps
|
|
89
|
+
}: MetricsChartViewProps) {
|
|
90
|
+
const [localType, setLocalType] = useState(defaultType);
|
|
91
|
+
const chartType = controlledType ?? localType;
|
|
92
|
+
const setChartType = (next: ChartType) => { if (controlledType == null) setLocalType(next); onTypeChange?.(next); };
|
|
93
|
+
const aborted = useSyncExternalStore(
|
|
94
|
+
(changed) => { signal?.addEventListener('abort', changed); return () => signal?.removeEventListener('abort', changed); },
|
|
95
|
+
() => signal?.aborted ?? false,
|
|
96
|
+
() => signal?.aborted ?? false,
|
|
97
|
+
);
|
|
98
|
+
const owner = useRef<AbortController | null>(null);
|
|
99
|
+
// Compare content rather than array identity: inspection often re-renders
|
|
100
|
+
// parents with equivalent arrays. Expired/replaced data must close snapshots.
|
|
101
|
+
const dialogIdentity = JSON.stringify([title, selectedWindow, labels, datasets,
|
|
102
|
+
seriesProps.xValues, seriesProps.xDomain, loading, !!error, disabled, minDate, maxDate, allowedGranularities, allowedRanges]);
|
|
103
|
+
useLayoutEffect(() => {
|
|
104
|
+
const controller = new AbortController();
|
|
105
|
+
owner.current = controller;
|
|
106
|
+
const abort = () => controller.abort();
|
|
107
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
108
|
+
if (signal?.aborted) abort();
|
|
109
|
+
return () => { signal?.removeEventListener('abort', abort); controller.abort(); };
|
|
110
|
+
}, [signal, dialogIdentity]);
|
|
111
|
+
const available = metricsGranularities(selectedWindow, allowedGranularities);
|
|
112
|
+
const configured = GRANULARITIES.filter((g) => !allowedGranularities || allowedGranularities.includes(g.value));
|
|
113
|
+
const effective = selectedWindow.granularity;
|
|
114
|
+
const active = () => !disabled && !signal?.aborted && !!owner.current && !owner.current.signal.aborted;
|
|
115
|
+
const setWindow = (next: MetricsChartWindow) => {
|
|
116
|
+
const supported = metricsGranularities(next, allowedGranularities);
|
|
117
|
+
if (!active() || !supported.length) return;
|
|
118
|
+
onWindowChange({ ...next, granularity: supported.includes(next.granularity) ? next.granularity : supported.at(-1)! });
|
|
119
|
+
};
|
|
120
|
+
const quickWindow = (r: MetricsQuickRange) => metricsQuickWindow(r, effective, Math.min(Date.now(), (maxDate ?? Infinity) * 1000));
|
|
121
|
+
const ranges = allowedRanges.filter((r) => Number.isFinite(r.durationMs) && r.durationMs > 0 && quickWindow(r).dt_start >= (minDate ?? -Infinity));
|
|
122
|
+
const pickRange = (next: MetricsQuickRange) => setWindow(quickWindow(next));
|
|
123
|
+
const setGranularity = (granularity: string) => setWindow({ ...selectedWindow, granularity });
|
|
124
|
+
const openCustomRange = async () => {
|
|
125
|
+
if (!active()) return;
|
|
126
|
+
const dialogSignal = owner.current!.signal;
|
|
127
|
+
const initial = selectedWindow.range ? { start: '', end: '' } : { start: metricsDateLabel(selectedWindow.dt_start), end: metricsDateLabel(selectedWindow.dt_end) };
|
|
128
|
+
const picked = await modal.open<{ dt_start: number; dt_end: number } | null>((close) => (
|
|
129
|
+
<RangeDialog initial={initial} minDate={minDate} maxDate={maxDate} onDone={close} />
|
|
130
|
+
), { size: 'md', signal: dialogSignal });
|
|
131
|
+
if (!picked || dialogSignal.aborted) return;
|
|
132
|
+
setWindow({ ...picked, granularity: effective });
|
|
133
|
+
};
|
|
134
|
+
const refresh = () => { if (active()) onRefresh?.(); };
|
|
135
|
+
const openDialog = (stats: boolean) => {
|
|
136
|
+
if (!active() || loading || error) return;
|
|
137
|
+
const { xValues, xDomain } = seriesProps;
|
|
138
|
+
const bounded = xDomain && Number.isFinite(xDomain[0]) && Number.isFinite(xDomain[1]) && xDomain[1] > xDomain[0];
|
|
139
|
+
const indices = labels.flatMap((_, i) => !xValues || (Number.isFinite(xValues[i]) && (!bounded || (xValues[i]! >= xDomain[0] && xValues[i]! <= xDomain[1]))) ? [i] : []);
|
|
140
|
+
void (stats ? showSeriesStats : showSeriesData)({
|
|
141
|
+
title: stats ? `${title} — Stats` : title,
|
|
142
|
+
labels: indices.map((i) => labels[i]!),
|
|
143
|
+
datasets: datasets.map((d) => ({ ...d, data: indices.map((i) => d.data[i]) })),
|
|
144
|
+
granularity: showGranularity ? effective : undefined, formatter: valueFormatter, signal: owner.current!.signal,
|
|
145
|
+
});
|
|
146
|
+
};
|
|
147
|
+
const openStats = () => openDialog(true);
|
|
148
|
+
const openData = () => openDialog(false);
|
|
149
|
+
const customActive = !selectedWindow.range;
|
|
150
|
+
const range = { start: metricsDateLabel(selectedWindow.dt_start), end: metricsDateLabel(selectedWindow.dt_end) };
|
|
151
|
+
const customTitle = customActive ? `Custom range: ${range.start} – ${range.end}` : 'Custom range…';
|
|
152
|
+
if (aborted) return null;
|
|
153
|
+
|
|
154
|
+
return (
|
|
155
|
+
<div className={`panel panel-pad chart-panel ${className}`.trim()}>
|
|
156
|
+
<div className="chart-head">
|
|
157
|
+
<div>
|
|
158
|
+
<div className="eyebrow">Metrics</div>
|
|
159
|
+
<h3 className="panel-subtitle">
|
|
160
|
+
{title}
|
|
161
|
+
{customActive && <span className="chart-range-chip">{range.start} – {range.end}</span>}
|
|
162
|
+
</h3>
|
|
163
|
+
</div>
|
|
164
|
+
<div className="chart-controls">
|
|
165
|
+
{showGranularity && (
|
|
166
|
+
<>
|
|
167
|
+
<div className="seg seg-xs gran-toggle" role="group" aria-label="Granularity">
|
|
168
|
+
{configured.map((g) => {
|
|
169
|
+
const enabled = available.includes(g.value);
|
|
170
|
+
return (
|
|
171
|
+
<button
|
|
172
|
+
key={g.value}
|
|
173
|
+
className={`seg-btn${effective === g.value ? ' seg-active' : ''}`}
|
|
174
|
+
disabled={disabled || !enabled}
|
|
175
|
+
title={enabled ? g.label : `${g.label} — not available for this range`}
|
|
176
|
+
onClick={() => setGranularity(g.value)}
|
|
177
|
+
>
|
|
178
|
+
{g.short}
|
|
179
|
+
</button>
|
|
180
|
+
);
|
|
181
|
+
})}
|
|
182
|
+
</div>
|
|
183
|
+
<select
|
|
184
|
+
className="input input-compact gran-select"
|
|
185
|
+
value={effective}
|
|
186
|
+
disabled={disabled || available.length === 0}
|
|
187
|
+
onChange={(e) => setGranularity(e.target.value)}
|
|
188
|
+
aria-label="Granularity"
|
|
189
|
+
>
|
|
190
|
+
{GRANULARITIES.filter((g) => available.includes(g.value)).map((g) => (
|
|
191
|
+
<option key={g.value} value={g.value}>{g.label}</option>
|
|
192
|
+
))}
|
|
193
|
+
</select>
|
|
194
|
+
</>
|
|
195
|
+
)}
|
|
196
|
+
|
|
197
|
+
{showDateRange && (
|
|
198
|
+
<div className="seg seg-xs" role="group" aria-label="Date range">
|
|
199
|
+
{ranges.map((r) => (
|
|
200
|
+
<button
|
|
201
|
+
key={r.value}
|
|
202
|
+
className={`seg-btn${selectedWindow.range === r.value ? ' seg-active' : ''}`}
|
|
203
|
+
disabled={disabled}
|
|
204
|
+
onClick={() => pickRange(r)}
|
|
205
|
+
>
|
|
206
|
+
{r.label}
|
|
207
|
+
</button>
|
|
208
|
+
))}
|
|
209
|
+
<button
|
|
210
|
+
className={`seg-btn${customActive ? ' seg-active' : ''}`}
|
|
211
|
+
title={customTitle}
|
|
212
|
+
aria-label="Custom date range"
|
|
213
|
+
disabled={disabled || available.length === 0}
|
|
214
|
+
onClick={() => void openCustomRange()}
|
|
215
|
+
>
|
|
216
|
+
<i className="bi bi-calendar-range" />
|
|
217
|
+
</button>
|
|
218
|
+
</div>
|
|
219
|
+
)}
|
|
220
|
+
|
|
221
|
+
{showTypeSwitch && (
|
|
222
|
+
<div className="seg seg-xs" role="group" aria-label="Chart type">
|
|
223
|
+
{TYPES.map((t) => (
|
|
224
|
+
<button
|
|
225
|
+
key={t.value}
|
|
226
|
+
className={`seg-btn${chartType === t.value ? ' seg-active' : ''}`}
|
|
227
|
+
title={t.title}
|
|
228
|
+
aria-label={t.title}
|
|
229
|
+
disabled={disabled}
|
|
230
|
+
onClick={() => setChartType(t.value)}
|
|
231
|
+
>
|
|
232
|
+
<i className={`bi ${t.icon}`} />
|
|
233
|
+
</button>
|
|
234
|
+
))}
|
|
235
|
+
</div>
|
|
236
|
+
)}
|
|
237
|
+
|
|
238
|
+
{showStats && (
|
|
239
|
+
<button className="btn-icon" title="Stats" aria-label="Stats" disabled={disabled || loading || !!error} onClick={openStats}>
|
|
240
|
+
<i className="bi bi-info-circle" />
|
|
241
|
+
</button>
|
|
242
|
+
)}
|
|
243
|
+
{showDataTable && (
|
|
244
|
+
<button className="btn-icon" title="View data" aria-label="View data" disabled={disabled || loading || !!error} onClick={openData}>
|
|
245
|
+
<i className="bi bi-table" />
|
|
246
|
+
</button>
|
|
247
|
+
)}
|
|
248
|
+
{showRefresh && (
|
|
249
|
+
<button className="btn-icon" title="Refresh" aria-label="Refresh" disabled={disabled || !onRefresh || isFetching} onClick={refresh}>
|
|
250
|
+
<i className={`bi bi-arrow-repeat${isFetching ? ' spin' : ''}`} />
|
|
251
|
+
</button>
|
|
252
|
+
)}
|
|
253
|
+
</div>
|
|
254
|
+
</div>
|
|
255
|
+
|
|
256
|
+
<div className="chart-body" style={{ minHeight: height }}>
|
|
257
|
+
{loading ? (
|
|
258
|
+
<div className="chart-overlay"><span className="skel skel-block" /></div>
|
|
259
|
+
) : !!error ? (
|
|
260
|
+
<div className="chart-overlay">
|
|
261
|
+
<div className="chart-error">
|
|
262
|
+
<i className="bi bi-exclamation-triangle" />
|
|
263
|
+
<span>{error instanceof Error ? error.message : typeof error === 'string' ? error : 'Failed to load metrics'}</span>
|
|
264
|
+
<button className="btn btn-compact" disabled={disabled || !onRetry} onClick={() => { if (active()) onRetry?.(); }}>Retry</button>
|
|
265
|
+
</div>
|
|
266
|
+
</div>
|
|
267
|
+
) : content !== undefined ? content : (
|
|
268
|
+
<SeriesChart
|
|
269
|
+
{...seriesProps}
|
|
270
|
+
labels={labels}
|
|
271
|
+
datasets={datasets}
|
|
272
|
+
chartType={chartType}
|
|
273
|
+
height={height}
|
|
274
|
+
valueFormatter={valueFormatter}
|
|
275
|
+
/>
|
|
276
|
+
)}
|
|
277
|
+
</div>
|
|
278
|
+
</div>
|
|
279
|
+
);
|
|
280
|
+
}
|