datajunction-ui 0.0.154 → 0.0.156

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.
@@ -0,0 +1,1058 @@
1
+ import { memo, useContext, useEffect, useMemo, useRef, useState } from 'react';
2
+ import { useLocation, useNavigate } from 'react-router-dom';
3
+ import Select from 'react-select';
4
+ import {
5
+ ResponsiveContainer,
6
+ LineChart,
7
+ Line,
8
+ AreaChart,
9
+ Area,
10
+ BarChart,
11
+ Bar,
12
+ CartesianGrid,
13
+ XAxis,
14
+ YAxis,
15
+ Tooltip,
16
+ Legend,
17
+ } from 'recharts';
18
+ import DJClientContext from '../../providers/djclient';
19
+ import { DataJunctionAPI } from '../../services/DJService';
20
+ import './styles.css';
21
+
22
+ const PALETTE = [
23
+ '#3b51d6',
24
+ '#00C49F',
25
+ '#FFBB28',
26
+ '#FF91A3',
27
+ '#AA46BE',
28
+ '#0088FE',
29
+ '#FF8042',
30
+ '#82ca9d',
31
+ ];
32
+
33
+ // Brighter cousins of the canonical DJ node-type colors
34
+ // (the canonical ones in src/styles/index.css are tuned for badges on a
35
+ // white background — they're too desaturated for chart series).
36
+ const NODE_TYPE_COLORS = {
37
+ source: '#22c55e', // bright emerald
38
+ transform: '#3b82f6', // bright blue
39
+ metric: '#f43f5e', // bright rose
40
+ dimension: '#f59e0b', // bright amber
41
+ cube: '#a855f7', // bright purple
42
+ tag: '#8b5cf6', // bright violet
43
+ };
44
+
45
+ const colorForSeries = (label, fallback) => {
46
+ if (!label) return { color: fallback, opacity: 0.85 };
47
+ const key = String(label).toLowerCase();
48
+ if (NODE_TYPE_COLORS[key]) {
49
+ return { color: NODE_TYPE_COLORS[key], opacity: 0.85 };
50
+ }
51
+ return { color: fallback, opacity: 0.85 };
52
+ };
53
+
54
+ const OPERATORS = [
55
+ { value: '=', label: '=' },
56
+ { value: '!=', label: '≠' },
57
+ { value: 'in', label: 'is one of' },
58
+ { value: '>', label: '>' },
59
+ { value: '<', label: '<' },
60
+ { value: '>=', label: '≥' },
61
+ { value: '<=', label: '≤' },
62
+ ];
63
+
64
+ const isTemporal = name =>
65
+ /(_date|_week|_month|_quarter|_year|_day|dateint|created_at)/i.test(name);
66
+
67
+ export const typeIcon = type => {
68
+ if (!type) return '#';
69
+ const t = String(type).toLowerCase();
70
+ if (/(bool)/.test(t)) return '✓';
71
+ if (/(date|time)/.test(t)) return '📅';
72
+ if (/(int|long|double|float|decimal|numeric|number)/.test(t)) return '123';
73
+ if (/(string|varchar|char|text)/.test(t)) return 'Aa';
74
+ if (/(list|array)/.test(t)) return '[ ]';
75
+ return '#';
76
+ };
77
+
78
+ // Shared react-select styles so options inside groups are visibly indented
79
+ // under their group header, and the header has a tight, distinct look.
80
+ const groupedSelectStyles = {
81
+ groupHeading: base => ({
82
+ ...base,
83
+ fontSize: 10.5,
84
+ fontWeight: 600,
85
+ textTransform: 'uppercase',
86
+ letterSpacing: '0.04em',
87
+ color: '#64748b',
88
+ padding: '6px 12px 2px',
89
+ }),
90
+ option: (base, state) => ({
91
+ ...base,
92
+ paddingLeft: state.data && state.data.group ? 28 : base.paddingLeft,
93
+ }),
94
+ group: base => ({
95
+ ...base,
96
+ paddingTop: 4,
97
+ paddingBottom: 4,
98
+ }),
99
+ };
100
+
101
+ // Recharts tooltip that drops zero-valued series — when stacked breakdowns
102
+ // have lots of empty cells (zero-filled for stacking), the default tooltip
103
+ // fills the screen with "X: 0" rows that aren't useful.
104
+ export function NonZeroTooltip({ active, payload, label }) {
105
+ if (!active || !payload || !payload.length) return null;
106
+ const nonZero = payload.filter(p => p && p.value !== 0 && p.value != null);
107
+ if (!nonZero.length) return null;
108
+ return (
109
+ <div
110
+ style={{
111
+ background: '#fff',
112
+ border: '1px solid #e2e8f0',
113
+ borderRadius: 6,
114
+ padding: '8px 10px',
115
+ fontSize: 12,
116
+ boxShadow: '0 2px 8px rgba(0,0,0,0.08)',
117
+ }}
118
+ >
119
+ <div style={{ fontWeight: 600, marginBottom: 4, color: '#1e293b' }}>
120
+ {label}
121
+ </div>
122
+ {nonZero.map(p => (
123
+ <div
124
+ key={p.dataKey}
125
+ style={{
126
+ display: 'flex',
127
+ alignItems: 'center',
128
+ gap: 6,
129
+ color: '#334155',
130
+ }}
131
+ >
132
+ <span
133
+ style={{
134
+ display: 'inline-block',
135
+ width: 9,
136
+ height: 9,
137
+ background: p.color,
138
+ borderRadius: 2,
139
+ }}
140
+ />
141
+ <span>{p.name}</span>
142
+ <span
143
+ style={{ marginLeft: 'auto', fontVariantNumeric: 'tabular-nums' }}
144
+ >
145
+ {typeof p.value === 'number' ? p.value.toLocaleString() : p.value}
146
+ </span>
147
+ </div>
148
+ ))}
149
+ </div>
150
+ );
151
+ }
152
+
153
+ const isNumericLike = v =>
154
+ typeof v === 'number' || (typeof v === 'string' && /^-?\d+(\.\d+)?$/.test(v));
155
+
156
+ // Recharts subtree, memoed so unrelated parent re-renders (e.g. URL syncs,
157
+ // filter-input keystrokes) don't re-mount the chart's SVG paths.
158
+ const ChartView = memo(function ChartView({
159
+ chartData,
160
+ resolvedChartType,
161
+ startAtZero,
162
+ }) {
163
+ const stacked = chartData.series.length > 1;
164
+ const isLine = resolvedChartType === 'line';
165
+ const isArea = resolvedChartType === 'area';
166
+ const ChartCmp = isArea ? AreaChart : isLine ? LineChart : BarChart;
167
+ return (
168
+ <div className="sme-chart-body">
169
+ <ResponsiveContainer width="100%" height="100%">
170
+ <ChartCmp
171
+ data={chartData.data}
172
+ margin={{ top: 10, right: 30, left: 10, bottom: 20 }}
173
+ >
174
+ <CartesianGrid strokeDasharray="3 3" stroke="#eee" />
175
+ <XAxis dataKey={chartData.xKey} tick={{ fontSize: 12 }} />
176
+ <YAxis
177
+ tick={{ fontSize: 12 }}
178
+ domain={startAtZero ? [0, 'auto'] : ['auto', 'auto']}
179
+ />
180
+ <Tooltip
181
+ content={<NonZeroTooltip />}
182
+ isAnimationActive={false}
183
+ cursor={{ fill: 'rgba(15, 23, 42, 0.04)' }}
184
+ />
185
+ {stacked ? <Legend /> : null}
186
+ {chartData.series.map((s, i) => {
187
+ const name = chartData.labels[s] || s;
188
+ const { color, opacity } = colorForSeries(
189
+ name,
190
+ PALETTE[i % PALETTE.length],
191
+ );
192
+ if (isArea) {
193
+ return (
194
+ <Area
195
+ key={s}
196
+ type="monotone"
197
+ dataKey={s}
198
+ name={name}
199
+ stackId={stacked ? '1' : undefined}
200
+ stroke={color}
201
+ fill={color}
202
+ fillOpacity={opacity}
203
+ isAnimationActive={false}
204
+ activeDot={false}
205
+ />
206
+ );
207
+ }
208
+ if (isLine) {
209
+ return (
210
+ <Line
211
+ key={s}
212
+ type="monotone"
213
+ dataKey={s}
214
+ name={name}
215
+ stroke={color}
216
+ strokeWidth={2}
217
+ dot={false}
218
+ isAnimationActive={false}
219
+ activeDot={false}
220
+ />
221
+ );
222
+ }
223
+ return (
224
+ <Bar
225
+ key={s}
226
+ dataKey={s}
227
+ name={name}
228
+ fill={color}
229
+ fillOpacity={opacity}
230
+ stackId={stacked ? '1' : undefined}
231
+ isAnimationActive={false}
232
+ activeBar={false}
233
+ />
234
+ );
235
+ })}
236
+ </ChartCmp>
237
+ </ResponsiveContainer>
238
+ </div>
239
+ );
240
+ });
241
+
242
+ // Parse URL search params into the explorer's initial state.
243
+ function parseSearch(search) {
244
+ const p = new URLSearchParams(search);
245
+ const filters = p
246
+ .getAll('filter')
247
+ .map(raw => {
248
+ const [dim = '', op = '=', ...rest] = raw.split('|');
249
+ return { dim, op, value: rest.join('|') };
250
+ })
251
+ .filter(f => f.dim);
252
+ return {
253
+ metric: p.get('metric') || null,
254
+ xAxis: p.get('x') || null,
255
+ compareBy: (p.get('by') || '').split(',').filter(Boolean),
256
+ filters,
257
+ view: p.get('view') === 'table' ? 'table' : 'chart',
258
+ chartType: ['line', 'area', 'bar', 'auto'].includes(p.get('chart'))
259
+ ? p.get('chart')
260
+ : 'auto',
261
+ startAtZero: p.get('zero') === '1',
262
+ };
263
+ }
264
+
265
+ export function SystemMetricsExplorerPage() {
266
+ const djClient = useContext(DJClientContext).DataJunctionAPI;
267
+ const location = useLocation();
268
+ const navigate = useNavigate();
269
+ // eslint-disable-next-line react-hooks/exhaustive-deps
270
+ const initial = useMemo(() => parseSearch(location.search), []);
271
+
272
+ const [metricsList, setMetricsList] = useState([]);
273
+ const [metricSearch, setMetricSearch] = useState('');
274
+ const [dimSearch, setDimSearch] = useState('');
275
+
276
+ const [selectedMetric, setSelectedMetric] = useState(initial.metric);
277
+ const [availableDims, setAvailableDims] = useState([]); // [{name,type,path,label}]
278
+ const [xAxisDim, setXAxisDim] = useState(null);
279
+ const [compareBy, setCompareBy] = useState([]);
280
+ const [filters, setFilters] = useState(initial.filters);
281
+
282
+ // Pending selections from the URL that need to be resolved against
283
+ // availableDims once they load.
284
+ const pendingXAxis = useRef(initial.xAxis);
285
+ const pendingCompareBy = useRef(initial.compareBy);
286
+
287
+ const [rows, setRows] = useState(null);
288
+ const [columns, setColumns] = useState([]);
289
+ const [loading, setLoading] = useState(false);
290
+ const [error, setError] = useState(null);
291
+
292
+ const [view, setView] = useState(initial.view);
293
+ const [chartType, setChartType] = useState(initial.chartType);
294
+ const [startAtZero, setStartAtZero] = useState(initial.startAtZero);
295
+
296
+ // Load system metrics
297
+ useEffect(() => {
298
+ let cancelled = false;
299
+ (async () => {
300
+ try {
301
+ const list = await djClient.system.list();
302
+ if (cancelled) return;
303
+ if (!Array.isArray(list)) {
304
+ setError(
305
+ `Unexpected response from /system/metrics: ${JSON.stringify(
306
+ list,
307
+ ).slice(0, 200)}`,
308
+ );
309
+ setMetricsList([]);
310
+ return;
311
+ }
312
+ // Endpoint may return either ["name", ...] (legacy) or
313
+ // [{name, display_name, description}, ...] (new). Normalize.
314
+ const normalized = list.map(item => {
315
+ if (typeof item === 'string') {
316
+ return {
317
+ name: item,
318
+ display_name: item,
319
+ description: '',
320
+ group: 'Other',
321
+ subgroup: 'Other',
322
+ suggestedCompareBy: [],
323
+ };
324
+ }
325
+ const meta = item.custom_metadata || {};
326
+ return {
327
+ name: item.name,
328
+ display_name: item.display_name || item.name,
329
+ description: item.description || '',
330
+ group: meta.group || 'Other',
331
+ subgroup: meta.subgroup || 'Other',
332
+ suggestedCompareBy: Array.isArray(meta.suggested_compare_by)
333
+ ? meta.suggested_compare_by
334
+ : [],
335
+ };
336
+ });
337
+ setMetricsList(normalized);
338
+ if (normalized.length && !selectedMetric)
339
+ setSelectedMetric(normalized[0].name);
340
+ } catch (e) {
341
+ if (!cancelled) setError(String(e));
342
+ }
343
+ })();
344
+ return () => {
345
+ cancelled = true;
346
+ };
347
+ // eslint-disable-next-line react-hooks/exhaustive-deps
348
+ }, [djClient]);
349
+
350
+ // Load dimensions for selected metric
351
+ useEffect(() => {
352
+ if (!selectedMetric) {
353
+ setAvailableDims([]);
354
+ return;
355
+ }
356
+ let cancelled = false;
357
+ setXAxisDim(null);
358
+ setCompareBy([]);
359
+ setRows(null);
360
+ setColumns([]);
361
+ setError(null);
362
+ (async () => {
363
+ try {
364
+ const dims = await djClient.commonDimensions([selectedMetric]);
365
+ if (cancelled) return;
366
+ const opts = (Array.isArray(dims) ? dims : []).map(d => {
367
+ // d.name is e.g. "system.dj.date.dateint[created_at]". Pull the
368
+ // role suffix out for the group header.
369
+ const roleMatch = d.name.match(/\[([^\]]+)\]$/);
370
+ const role = roleMatch ? roleMatch[1] : null;
371
+ const baseName = role
372
+ ? d.name.slice(0, -roleMatch[0].length)
373
+ : d.name;
374
+ const segments = baseName.split('.');
375
+ const attr = segments[segments.length - 1];
376
+ const nodeDisplay =
377
+ d.node_display_name || segments.slice(0, -1).join('.');
378
+ const colDisplay = d.column_display_name || attr;
379
+ const group = role ? `${nodeDisplay} [${role}]` : nodeDisplay;
380
+ return {
381
+ value: d.name,
382
+ label: colDisplay,
383
+ // Combined label used in search and in some narrow contexts.
384
+ combinedLabel: `${group} · ${colDisplay}`,
385
+ attr,
386
+ nodeDisplay,
387
+ colDisplay,
388
+ role,
389
+ group,
390
+ path: d.path.join(' ▶ '),
391
+ type: d.type,
392
+ };
393
+ });
394
+ setAvailableDims(opts);
395
+
396
+ // Apply pending URL state first if present; otherwise default to a
397
+ // temporal X-axis. Pending state is one-shot — clear after applying.
398
+ if (pendingXAxis.current) {
399
+ const match = opts.find(o => o.value === pendingXAxis.current);
400
+ if (match) setXAxisDim(match);
401
+ pendingXAxis.current = null;
402
+ } else {
403
+ // Prefer a "week" temporal column; fall back to any temporal one.
404
+ const weekly = opts.find(o => /_week\b/i.test(o.value));
405
+ const firstTemporal = weekly || opts.find(o => isTemporal(o.value));
406
+ if (firstTemporal) setXAxisDim(firstTemporal);
407
+ }
408
+ if (pendingCompareBy.current && pendingCompareBy.current.length) {
409
+ const matches = pendingCompareBy.current
410
+ .map(v => opts.find(o => o.value === v))
411
+ .filter(Boolean);
412
+ if (matches.length) setCompareBy(matches);
413
+ pendingCompareBy.current = null;
414
+ } else {
415
+ // Apply the metric's suggested compare-by defaults (from
416
+ // custom_metadata.suggested_compare_by). Only kicks in when the
417
+ // user has no pending compare-by from the URL.
418
+ const metric = metricsList.find(m => m.name === selectedMetric);
419
+ const suggested = (metric && metric.suggestedCompareBy) || [];
420
+ if (suggested.length) {
421
+ const matches = suggested
422
+ .map(v => opts.find(o => o.value === v))
423
+ .filter(Boolean);
424
+ if (matches.length) setCompareBy(matches);
425
+ }
426
+ }
427
+ } catch (e) {
428
+ if (!cancelled) setError(String(e));
429
+ }
430
+ })();
431
+ return () => {
432
+ cancelled = true;
433
+ };
434
+ // eslint-disable-next-line react-hooks/exhaustive-deps
435
+ }, [selectedMetric, djClient]);
436
+
437
+ // Sync state back to URL so links are shareable.
438
+ useEffect(() => {
439
+ const p = new URLSearchParams();
440
+ if (selectedMetric) p.set('metric', selectedMetric);
441
+ if (xAxisDim) p.set('x', xAxisDim.value);
442
+ if (compareBy.length) p.set('by', compareBy.map(d => d.value).join(','));
443
+ filters.forEach(f => {
444
+ if (f.dim && f.op) {
445
+ p.append('filter', `${f.dim}|${f.op}|${f.value ?? ''}`);
446
+ }
447
+ });
448
+ if (view !== 'chart') p.set('view', view);
449
+ if (chartType !== 'auto') p.set('chart', chartType);
450
+ if (startAtZero) p.set('zero', '1');
451
+ const next = p.toString();
452
+ const current = window.location.search.replace(/^\?/, '');
453
+ if (next !== current) {
454
+ navigate(
455
+ { pathname: window.location.pathname, search: next ? `?${next}` : '' },
456
+ { replace: true },
457
+ );
458
+ }
459
+ }, [
460
+ selectedMetric,
461
+ xAxisDim,
462
+ compareBy,
463
+ filters,
464
+ view,
465
+ chartType,
466
+ startAtZero,
467
+ navigate,
468
+ ]);
469
+
470
+ // Run query whenever the user changes inputs
471
+ useEffect(() => {
472
+ if (!selectedMetric) return;
473
+ let cancelled = false;
474
+ (async () => {
475
+ setLoading(true);
476
+ setError(null);
477
+ try {
478
+ const dimensions = [
479
+ ...(xAxisDim ? [xAxisDim.value] : []),
480
+ ...compareBy.map(d => d.value),
481
+ ];
482
+ const filterClauses = filters
483
+ .filter(f => f.dim && f.op && f.value !== '')
484
+ .map(f => {
485
+ if (f.op === 'in') {
486
+ const vals = f.value
487
+ .split(',')
488
+ .map(v => v.trim())
489
+ .filter(Boolean)
490
+ .map(v => (isNumericLike(v) ? v : `'${v.replace(/'/g, "''")}'`))
491
+ .join(', ');
492
+ return `${f.dim} IN (${vals})`;
493
+ }
494
+ const lhs = f.dim;
495
+ const rhs = isNumericLike(f.value)
496
+ ? f.value
497
+ : `'${String(f.value).replace(/'/g, "''")}'`;
498
+ return `${lhs} ${f.op} ${rhs}`;
499
+ });
500
+
501
+ const orderby = xAxisDim ? [xAxisDim.value] : [];
502
+
503
+ const result = await djClient.querySystemMetric({
504
+ metric: selectedMetric,
505
+ dimensions,
506
+ filters: filterClauses,
507
+ orderby,
508
+ });
509
+ if (cancelled) return;
510
+ // New compact shape: { columns: [...], rows: [[...], ...] }.
511
+ // Rows are already aligned to columns, no per-cell envelope.
512
+ setColumns(result.columns || []);
513
+ setRows(result.rows || []);
514
+ } catch (e) {
515
+ if (!cancelled) setError(String(e));
516
+ setRows(null);
517
+ setColumns([]);
518
+ } finally {
519
+ if (!cancelled) setLoading(false);
520
+ }
521
+ })();
522
+ return () => {
523
+ cancelled = true;
524
+ };
525
+ }, [selectedMetric, xAxisDim, compareBy, filters, djClient]);
526
+
527
+ const metricDisplay = useMemo(() => {
528
+ const map = {};
529
+ for (const m of metricsList) map[m.name] = m.display_name || m.name;
530
+ return map;
531
+ }, [metricsList]);
532
+
533
+ // Shape data for the chart.
534
+ // Recharts uses lodash-style get(obj, dataKey), so keys containing dots
535
+ // (like "system.dj.number_of_nodes") would be interpreted as nested
536
+ // paths and produce undefined. Flatten to safe keys (__x, s0, s1, ...)
537
+ // and keep a label map for tooltip/legend display.
538
+ const chartData = useMemo(() => {
539
+ const empty = { data: [], series: [], xKey: null, labels: {} };
540
+ if (!rows || !selectedMetric) return empty;
541
+ const xKey = xAxisDim?.value;
542
+ const breakdownKey = compareBy[0]?.value || null;
543
+ const metricCol = selectedMetric;
544
+ const metricIdx = columns.indexOf(metricCol);
545
+ if (metricIdx === -1) return empty;
546
+ const num = v => {
547
+ if (v === null || v === undefined) return 0;
548
+ const n = typeof v === 'number' ? v : parseFloat(v);
549
+ return Number.isFinite(n) ? n : 0;
550
+ };
551
+ const X = '__x';
552
+
553
+ // Strip rows (X buckets) where every series is 0/null and series that
554
+ // are 0/null at every X. Both flavors of "empty" are noise: an X bucket
555
+ // with no data adds an unused tick; a series with no data adds a legend
556
+ // entry and a phantom stack member.
557
+ const pruneEmpty = ({ data, series, labels, xKey: xk }) => {
558
+ const seriesWithValue = series.filter(s =>
559
+ data.some(r => r[s] !== 0 && r[s] != null),
560
+ );
561
+ const keptLabels = {};
562
+ for (const s of seriesWithValue) keptLabels[s] = labels[s];
563
+ const keptData = data
564
+ .map(r => {
565
+ const out = { [xk]: r[xk] };
566
+ for (const s of seriesWithValue) out[s] = r[s];
567
+ return out;
568
+ })
569
+ .filter(r => seriesWithValue.some(s => r[s] !== 0 && r[s] != null));
570
+ return {
571
+ data: keptData,
572
+ series: seriesWithValue,
573
+ labels: keptLabels,
574
+ xKey: xk,
575
+ };
576
+ };
577
+
578
+ if (!xKey) {
579
+ const bdIdx = breakdownKey ? columns.indexOf(breakdownKey) : -1;
580
+ const sKey = 's0';
581
+ const baseLabel =
582
+ metricDisplay[metricCol] || metricCol.replace(/^system\.dj\./, '');
583
+ if (bdIdx === -1) {
584
+ return {
585
+ data: [{ [X]: metricCol, [sKey]: num(rows[0]?.[metricIdx]) }],
586
+ series: [sKey],
587
+ xKey: X,
588
+ labels: { [sKey]: baseLabel },
589
+ };
590
+ }
591
+ return pruneEmpty({
592
+ data: rows.map(r => ({
593
+ [X]: String(r[bdIdx]),
594
+ [sKey]: num(r[metricIdx]),
595
+ })),
596
+ series: [sKey],
597
+ xKey: X,
598
+ labels: { [sKey]: baseLabel },
599
+ });
600
+ }
601
+
602
+ const xIdx = columns.indexOf(xKey);
603
+ if (xIdx === -1) return empty;
604
+
605
+ const baseLabel =
606
+ metricDisplay[metricCol] || metricCol.replace(/^system\.dj\./, '');
607
+
608
+ if (!breakdownKey) {
609
+ const sKey = 's0';
610
+ return pruneEmpty({
611
+ data: rows.map(r => ({
612
+ [X]: String(r[xIdx]),
613
+ [sKey]: num(r[metricIdx]),
614
+ })),
615
+ series: [sKey],
616
+ xKey: X,
617
+ labels: { [sKey]: baseLabel },
618
+ });
619
+ }
620
+
621
+ const bdIdx = columns.indexOf(breakdownKey);
622
+ const byX = new Map();
623
+ const seriesLabels = new Map(); // safeKey -> displayLabel
624
+ const seriesOrder = [];
625
+ for (const r of rows) {
626
+ const xVal = String(r[xIdx]);
627
+ const bdVal = String(r[bdIdx]);
628
+ if (!seriesLabels.has(bdVal)) {
629
+ const safe = `s${seriesLabels.size}`;
630
+ seriesLabels.set(bdVal, safe);
631
+ seriesOrder.push(safe);
632
+ }
633
+ const safe = seriesLabels.get(bdVal);
634
+ if (!byX.has(xVal)) byX.set(xVal, { [X]: xVal });
635
+ byX.get(xVal)[safe] = num(r[metricIdx]);
636
+ }
637
+ const labels = {};
638
+ seriesLabels.forEach((safe, orig) => {
639
+ labels[safe] = orig;
640
+ });
641
+ const filled = [];
642
+ byX.forEach(row => {
643
+ const out = { ...row };
644
+ for (const safe of seriesOrder) {
645
+ if (out[safe] === undefined || out[safe] === null) out[safe] = 0;
646
+ }
647
+ filled.push(out);
648
+ });
649
+ return pruneEmpty({
650
+ data: filled,
651
+ series: seriesOrder,
652
+ xKey: X,
653
+ labels,
654
+ });
655
+ }, [rows, columns, xAxisDim, compareBy, selectedMetric, metricDisplay]);
656
+
657
+ const filteredMetrics = useMemo(() => {
658
+ const q = metricSearch.toLowerCase();
659
+ return metricsList.filter(
660
+ m =>
661
+ m.name.toLowerCase().includes(q) ||
662
+ (m.display_name && m.display_name.toLowerCase().includes(q)),
663
+ );
664
+ }, [metricsList, metricSearch]);
665
+
666
+ // Two-level grouping: group > subgroup > metric[]. Preserves insertion
667
+ // order so the seed's intended ordering is honored.
668
+ // NOTE: avoid `[...map.entries()]` here — this bundle has a polyfill that
669
+ // breaks Map's iterator and silently produces empty arrays. forEach works.
670
+ const groupedMetrics = useMemo(() => {
671
+ const groupOrder = [];
672
+ const groupMap = {};
673
+ for (const m of filteredMetrics) {
674
+ const g = m.group || 'Other';
675
+ const sg = m.subgroup || 'Other';
676
+ if (!groupMap[g]) {
677
+ groupMap[g] = { subgroupOrder: [], subgroups: {} };
678
+ groupOrder.push(g);
679
+ }
680
+ if (!groupMap[g].subgroups[sg]) {
681
+ groupMap[g].subgroups[sg] = [];
682
+ groupMap[g].subgroupOrder.push(sg);
683
+ }
684
+ groupMap[g].subgroups[sg].push(m);
685
+ }
686
+ return groupOrder.map(group => ({
687
+ group,
688
+ subgroups: groupMap[group].subgroupOrder.map(subgroup => ({
689
+ subgroup,
690
+ metrics: groupMap[group].subgroups[subgroup],
691
+ })),
692
+ }));
693
+ }, [filteredMetrics]);
694
+
695
+ const selectedMetricDisplay = selectedMetric
696
+ ? metricDisplay[selectedMetric] ||
697
+ selectedMetric.replace(/^system\.dj\./, '')
698
+ : null;
699
+
700
+ const selectedMetricDescription = selectedMetric
701
+ ? (metricsList.find(m => m.name === selectedMetric) || {}).description || ''
702
+ : '';
703
+
704
+ const filteredDims = useMemo(() => {
705
+ const q = dimSearch.toLowerCase();
706
+ return availableDims.filter(
707
+ d =>
708
+ d.value.toLowerCase().includes(q) ||
709
+ d.combinedLabel.toLowerCase().includes(q),
710
+ );
711
+ }, [availableDims, dimSearch]);
712
+
713
+ // Group filtered dims by their parent dim node (+ role) for use in
714
+ // react-select option groups and in the rail's sectioned list.
715
+ const groupedDims = useMemo(() => {
716
+ const order = [];
717
+ const byGroup = new Map();
718
+ for (const d of filteredDims) {
719
+ if (!byGroup.has(d.group)) {
720
+ byGroup.set(d.group, []);
721
+ order.push(d.group);
722
+ }
723
+ byGroup.get(d.group).push(d);
724
+ }
725
+ return order.map(group => ({ label: group, options: byGroup.get(group) }));
726
+ }, [filteredDims]);
727
+
728
+ const resolvedChartType = useMemo(() => {
729
+ if (chartType !== 'auto') return chartType;
730
+ const temporal = xAxisDim && isTemporal(xAxisDim.value);
731
+ if (!temporal) return 'bar';
732
+ return compareBy.length > 0 ? 'area' : 'line';
733
+ }, [chartType, xAxisDim, compareBy]);
734
+
735
+ const addFilter = () =>
736
+ setFilters([...filters, { dim: '', op: '=', value: '' }]);
737
+
738
+ const updateFilter = (i, patch) =>
739
+ setFilters(filters.map((f, idx) => (idx === i ? { ...f, ...patch } : f)));
740
+
741
+ const removeFilter = i => setFilters(filters.filter((_, idx) => idx !== i));
742
+
743
+ const renderChart = () => {
744
+ if (loading)
745
+ return (
746
+ <div className="sme-empty">
747
+ <span className="sme-spinner" />
748
+ Loading…
749
+ </div>
750
+ );
751
+ if (!rows || rows.length === 0)
752
+ return (
753
+ <div className="sme-empty">
754
+ <div className="sme-empty-icon">📈</div>
755
+ No data.
756
+ </div>
757
+ );
758
+ return (
759
+ <ChartView
760
+ chartData={chartData}
761
+ resolvedChartType={resolvedChartType}
762
+ startAtZero={startAtZero}
763
+ />
764
+ );
765
+ };
766
+
767
+ const renderTable = () => {
768
+ if (loading)
769
+ return (
770
+ <div className="sme-empty">
771
+ <span className="sme-spinner" />
772
+ Loading…
773
+ </div>
774
+ );
775
+ if (!rows || rows.length === 0)
776
+ return <div className="sme-empty">No data.</div>;
777
+ return (
778
+ <div className="sme-table-wrap">
779
+ <table className="sme-table">
780
+ <thead>
781
+ <tr>
782
+ {columns.map(c => (
783
+ <th key={c}>{c}</th>
784
+ ))}
785
+ </tr>
786
+ </thead>
787
+ <tbody>
788
+ {rows.map((row, i) => (
789
+ <tr key={`row-${i}`}>
790
+ {row.map((v, j) => (
791
+ <td key={`${i}-${j}`}>
792
+ {v === null || v === undefined ? '' : String(v)}
793
+ </td>
794
+ ))}
795
+ </tr>
796
+ ))}
797
+ </tbody>
798
+ </table>
799
+ </div>
800
+ );
801
+ };
802
+
803
+ return (
804
+ <div className="sme-page">
805
+ {/* Left rail */}
806
+ <aside className="sme-rail">
807
+ <div className="sme-rail-section grow">
808
+ <div className="sme-rail-header">
809
+ <span>System Metrics</span>
810
+ <span className="sme-rail-count">{filteredMetrics.length}</span>
811
+ </div>
812
+ <input
813
+ className="sme-rail-search"
814
+ placeholder="Search metrics"
815
+ value={metricSearch}
816
+ onChange={e => setMetricSearch(e.target.value)}
817
+ />
818
+ <div className="sme-rail-list">
819
+ {groupedMetrics.map(g => (
820
+ <div key={g.group} className="sme-rail-group">
821
+ <div className="sme-rail-group-header">{g.group}</div>
822
+ {g.subgroups.map(sg => (
823
+ <div key={sg.subgroup} className="sme-rail-subgroup">
824
+ <div className="sme-rail-subgroup-header">
825
+ {sg.subgroup}
826
+ </div>
827
+ {sg.metrics.map(m => (
828
+ <div
829
+ key={m.name}
830
+ className={`sme-rail-item indent${
831
+ selectedMetric === m.name ? ' active' : ''
832
+ }`}
833
+ onClick={() => setSelectedMetric(m.name)}
834
+ title={
835
+ m.description
836
+ ? `${m.name}\n\n${m.description}`
837
+ : m.name
838
+ }
839
+ >
840
+ <span className="sme-rail-icon">Σ</span>
841
+ {m.display_name || m.name.replace(/^system\.dj\./, '')}
842
+ </div>
843
+ ))}
844
+ </div>
845
+ ))}
846
+ </div>
847
+ ))}
848
+ </div>
849
+ </div>
850
+
851
+ <div className="sme-rail-section grow">
852
+ <div className="sme-rail-header">
853
+ <span>Dimensions</span>
854
+ <span className="sme-rail-count">{filteredDims.length}</span>
855
+ </div>
856
+ <input
857
+ className="sme-rail-search"
858
+ placeholder="Search dimensions"
859
+ value={dimSearch}
860
+ onChange={e => setDimSearch(e.target.value)}
861
+ disabled={!selectedMetric}
862
+ />
863
+ <div className="sme-rail-list">
864
+ {groupedDims.map(group => (
865
+ <div key={group.label} className="sme-rail-group">
866
+ <div className="sme-rail-group-header">{group.label}</div>
867
+ {group.options.map(d => {
868
+ const selected =
869
+ compareBy.some(c => c.value === d.value) ||
870
+ xAxisDim?.value === d.value;
871
+ return (
872
+ <div
873
+ key={d.value}
874
+ className={`sme-rail-item indent${
875
+ selected ? ' active' : ''
876
+ }`}
877
+ title={`${d.value}\n${d.path}\nClick to toggle in Compare-by`}
878
+ onClick={() => {
879
+ if (xAxisDim?.value === d.value) return;
880
+ setCompareBy(prev =>
881
+ prev.some(c => c.value === d.value)
882
+ ? prev.filter(c => c.value !== d.value)
883
+ : [...prev, d],
884
+ );
885
+ }}
886
+ >
887
+ <span className="sme-rail-icon">{typeIcon(d.type)}</span>
888
+ {d.label}
889
+ </div>
890
+ );
891
+ })}
892
+ </div>
893
+ ))}
894
+ </div>
895
+ </div>
896
+ </aside>
897
+
898
+ {/* Main pane */}
899
+ <div className="sme-main">
900
+ <div className="sme-toolbar">
901
+ <div className="sme-row">
902
+ <span className="sme-label">X-axis</span>
903
+ <span className="sme-select-wide">
904
+ <Select
905
+ isClearable
906
+ placeholder="Choose an X-axis dimension"
907
+ options={groupedDims}
908
+ value={xAxisDim}
909
+ onChange={setXAxisDim}
910
+ isDisabled={!selectedMetric}
911
+ styles={groupedSelectStyles}
912
+ />
913
+ </span>
914
+ </div>
915
+ <div className="sme-row">
916
+ <span className="sme-label">Compare by</span>
917
+ <span className="sme-select-wide">
918
+ <Select
919
+ isMulti
920
+ isClearable
921
+ placeholder="Break down by dimensions"
922
+ options={groupedDims
923
+ .map(g => ({
924
+ ...g,
925
+ options: g.options.filter(d => d.value !== xAxisDim?.value),
926
+ }))
927
+ .filter(g => g.options.length > 0)}
928
+ value={compareBy}
929
+ onChange={vals => setCompareBy(vals || [])}
930
+ isDisabled={!selectedMetric}
931
+ styles={groupedSelectStyles}
932
+ />
933
+ </span>
934
+ </div>
935
+ {filters.map((f, i) => (
936
+ <div className="sme-filter-row" key={`f-${i}`}>
937
+ <span className="sme-label">{i === 0 ? 'Filter' : 'and'}</span>
938
+ <span className="sme-select">
939
+ <Select
940
+ placeholder="Dimension"
941
+ options={groupedDims}
942
+ value={availableDims.find(d => d.value === f.dim) || null}
943
+ onChange={o => updateFilter(i, { dim: o?.value || '' })}
944
+ styles={groupedSelectStyles}
945
+ />
946
+ </span>
947
+ <span style={{ minWidth: 140 }}>
948
+ <Select
949
+ options={OPERATORS}
950
+ value={OPERATORS.find(o => o.value === f.op)}
951
+ onChange={o => updateFilter(i, { op: o.value })}
952
+ />
953
+ </span>
954
+ <input
955
+ className="sme-text-input"
956
+ placeholder={f.op === 'in' ? 'comma-separated values' : 'value'}
957
+ value={f.value}
958
+ onChange={e => updateFilter(i, { value: e.target.value })}
959
+ />
960
+ <button
961
+ className="sme-remove-filter"
962
+ onClick={() => removeFilter(i)}
963
+ aria-label="Remove filter"
964
+ >
965
+ ×
966
+ </button>
967
+ </div>
968
+ ))}
969
+ <div className="sme-row">
970
+ <button
971
+ className="sme-add-filter"
972
+ onClick={addFilter}
973
+ disabled={!selectedMetric}
974
+ >
975
+ + Add filter
976
+ </button>
977
+ </div>
978
+ </div>
979
+
980
+ <div className="sme-content">
981
+ <div className="sme-chart-controls">
982
+ <div className="sme-view-toggle">
983
+ <button
984
+ className={`sme-view-btn${view === 'chart' ? ' active' : ''}`}
985
+ onClick={() => setView('chart')}
986
+ >
987
+ 📈 Chart
988
+ </button>
989
+ <button
990
+ className={`sme-view-btn${view === 'table' ? ' active' : ''}`}
991
+ onClick={() => setView('table')}
992
+ >
993
+ ▦ Table
994
+ </button>
995
+ </div>
996
+ {view === 'chart' ? (
997
+ <div className="sme-view-toggle">
998
+ {[
999
+ { key: 'auto', label: 'Auto' },
1000
+ { key: 'line', label: 'Line' },
1001
+ { key: 'area', label: 'Area' },
1002
+ { key: 'bar', label: 'Bar' },
1003
+ ].map(t => (
1004
+ <button
1005
+ key={t.key}
1006
+ className={`sme-view-btn${
1007
+ chartType === t.key ? ' active' : ''
1008
+ }`}
1009
+ onClick={() => setChartType(t.key)}
1010
+ >
1011
+ {t.label}
1012
+ </button>
1013
+ ))}
1014
+ </div>
1015
+ ) : null}
1016
+ <div className="sme-options">
1017
+ <label>
1018
+ <input
1019
+ type="checkbox"
1020
+ checked={startAtZero}
1021
+ onChange={e => setStartAtZero(e.target.checked)}
1022
+ />
1023
+ Start scale at zero
1024
+ </label>
1025
+ </div>
1026
+ </div>
1027
+
1028
+ <h2 className="sme-chart-title">
1029
+ {selectedMetric ? (
1030
+ <a
1031
+ href={`/nodes/${selectedMetric}`}
1032
+ target="_blank"
1033
+ rel="noreferrer"
1034
+ title={`Open ${selectedMetric}`}
1035
+ className="sme-chart-title-link"
1036
+ >
1037
+ {selectedMetricDisplay}
1038
+ </a>
1039
+ ) : (
1040
+ 'Pick a system metric'
1041
+ )}
1042
+ </h2>
1043
+ {selectedMetricDescription ? (
1044
+ <p className="sme-chart-description">{selectedMetricDescription}</p>
1045
+ ) : null}
1046
+
1047
+ {error ? <div className="sme-error">{error}</div> : null}
1048
+
1049
+ {view === 'chart' ? renderChart() : renderTable()}
1050
+ </div>
1051
+ </div>
1052
+ </div>
1053
+ );
1054
+ }
1055
+
1056
+ SystemMetricsExplorerPage.defaultProps = {
1057
+ djClient: DataJunctionAPI,
1058
+ };