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.
- package/package.json +1 -1
- package/src/app/index.tsx +6 -0
- package/src/app/pages/OverviewPage/index.jsx +11 -0
- package/src/app/pages/SystemMetricsExplorerPage/Loadable.jsx +16 -0
- package/src/app/pages/SystemMetricsExplorerPage/__tests__/index.test.jsx +699 -0
- package/src/app/pages/SystemMetricsExplorerPage/index.jsx +1058 -0
- package/src/app/pages/SystemMetricsExplorerPage/styles.css +477 -0
- package/src/app/services/DJService.js +31 -29
- package/src/app/services/__tests__/DJService.test.jsx +88 -283
|
@@ -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
|
+
};
|