datajunction-ui
Version:
DataJunction UI
1,059 lines (1,006 loc) • 34.7 kB
JSX
import { memo, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import Select from 'react-select';
import {
ResponsiveContainer,
LineChart,
Line,
AreaChart,
Area,
BarChart,
Bar,
CartesianGrid,
XAxis,
YAxis,
Tooltip,
Legend,
} from 'recharts';
import DJClientContext from '../../providers/djclient';
import { DataJunctionAPI } from '../../services/DJService';
import './styles.css';
const PALETTE = [
'#3b51d6',
'#00C49F',
'#FFBB28',
'#FF91A3',
'#AA46BE',
'#0088FE',
'#FF8042',
'#82ca9d',
];
// Brighter cousins of the canonical DJ node-type colors
// (the canonical ones in src/styles/index.css are tuned for badges on a
// white background — they're too desaturated for chart series).
const NODE_TYPE_COLORS = {
source: '#22c55e', // bright emerald
transform: '#3b82f6', // bright blue
metric: '#f43f5e', // bright rose
dimension: '#f59e0b', // bright amber
cube: '#a855f7', // bright purple
tag: '#8b5cf6', // bright violet
};
const colorForSeries = (label, fallback) => {
if (!label) return { color: fallback, opacity: 0.85 };
const key = String(label).toLowerCase();
if (NODE_TYPE_COLORS[key]) {
return { color: NODE_TYPE_COLORS[key], opacity: 0.85 };
}
return { color: fallback, opacity: 0.85 };
};
const OPERATORS = [
{ value: '=', label: '=' },
{ value: '!=', label: '≠' },
{ value: 'in', label: 'is one of' },
{ value: '>', label: '>' },
{ value: '<', label: '<' },
{ value: '>=', label: '≥' },
{ value: '<=', label: '≤' },
];
const isTemporal = name =>
/(_date|_week|_month|_quarter|_year|_day|dateint|created_at)/i.test(name);
export const typeIcon = type => {
if (!type) return '#';
const t = String(type).toLowerCase();
if (/(bool)/.test(t)) return '✓';
if (/(date|time)/.test(t)) return '📅';
if (/(int|long|double|float|decimal|numeric|number)/.test(t)) return '123';
if (/(string|varchar|char|text)/.test(t)) return 'Aa';
if (/(list|array)/.test(t)) return '[ ]';
return '#';
};
// Shared react-select styles so options inside groups are visibly indented
// under their group header, and the header has a tight, distinct look.
const groupedSelectStyles = {
groupHeading: base => ({
...base,
fontSize: 10.5,
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.04em',
color: '#64748b',
padding: '6px 12px 2px',
}),
option: (base, state) => ({
...base,
paddingLeft: state.data && state.data.group ? 28 : base.paddingLeft,
}),
group: base => ({
...base,
paddingTop: 4,
paddingBottom: 4,
}),
};
// Recharts tooltip that drops zero-valued series — when stacked breakdowns
// have lots of empty cells (zero-filled for stacking), the default tooltip
// fills the screen with "X: 0" rows that aren't useful.
export function NonZeroTooltip({ active, payload, label }) {
if (!active || !payload || !payload.length) return null;
const nonZero = payload.filter(p => p && p.value !== 0 && p.value != null);
if (!nonZero.length) return null;
return (
<div
style={{
background: '#fff',
border: '1px solid #e2e8f0',
borderRadius: 6,
padding: '8px 10px',
fontSize: 12,
boxShadow: '0 2px 8px rgba(0,0,0,0.08)',
}}
>
<div style={{ fontWeight: 600, marginBottom: 4, color: '#1e293b' }}>
{label}
</div>
{nonZero.map(p => (
<div
key={p.dataKey}
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
color: '#334155',
}}
>
<span
style={{
display: 'inline-block',
width: 9,
height: 9,
background: p.color,
borderRadius: 2,
}}
/>
<span>{p.name}</span>
<span
style={{ marginLeft: 'auto', fontVariantNumeric: 'tabular-nums' }}
>
{typeof p.value === 'number' ? p.value.toLocaleString() : p.value}
</span>
</div>
))}
</div>
);
}
const isNumericLike = v =>
typeof v === 'number' || (typeof v === 'string' && /^-?\d+(\.\d+)?$/.test(v));
// Recharts subtree, memoed so unrelated parent re-renders (e.g. URL syncs,
// filter-input keystrokes) don't re-mount the chart's SVG paths.
const ChartView = memo(function ChartView({
chartData,
resolvedChartType,
startAtZero,
}) {
const stacked = chartData.series.length > 1;
const isLine = resolvedChartType === 'line';
const isArea = resolvedChartType === 'area';
const ChartCmp = isArea ? AreaChart : isLine ? LineChart : BarChart;
return (
<div className="sme-chart-body">
<ResponsiveContainer width="100%" height="100%">
<ChartCmp
data={chartData.data}
margin={{ top: 10, right: 30, left: 10, bottom: 20 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="#eee" />
<XAxis dataKey={chartData.xKey} tick={{ fontSize: 12 }} />
<YAxis
tick={{ fontSize: 12 }}
domain={startAtZero ? [0, 'auto'] : ['auto', 'auto']}
/>
<Tooltip
content={<NonZeroTooltip />}
isAnimationActive={false}
cursor={{ fill: 'rgba(15, 23, 42, 0.04)' }}
/>
{stacked ? <Legend /> : null}
{chartData.series.map((s, i) => {
const name = chartData.labels[s] || s;
const { color, opacity } = colorForSeries(
name,
PALETTE[i % PALETTE.length],
);
if (isArea) {
return (
<Area
key={s}
type="monotone"
dataKey={s}
name={name}
stackId={stacked ? '1' : undefined}
stroke={color}
fill={color}
fillOpacity={opacity}
isAnimationActive={false}
activeDot={false}
/>
);
}
if (isLine) {
return (
<Line
key={s}
type="monotone"
dataKey={s}
name={name}
stroke={color}
strokeWidth={2}
dot={false}
isAnimationActive={false}
activeDot={false}
/>
);
}
return (
<Bar
key={s}
dataKey={s}
name={name}
fill={color}
fillOpacity={opacity}
stackId={stacked ? '1' : undefined}
isAnimationActive={false}
activeBar={false}
/>
);
})}
</ChartCmp>
</ResponsiveContainer>
</div>
);
});
// Parse URL search params into the explorer's initial state.
function parseSearch(search) {
const p = new URLSearchParams(search);
const filters = p
.getAll('filter')
.map(raw => {
const [dim = '', op = '=', ...rest] = raw.split('|');
return { dim, op, value: rest.join('|') };
})
.filter(f => f.dim);
return {
metric: p.get('metric') || null,
xAxis: p.get('x') || null,
compareBy: (p.get('by') || '').split(',').filter(Boolean),
filters,
view: p.get('view') === 'table' ? 'table' : 'chart',
chartType: ['line', 'area', 'bar', 'auto'].includes(p.get('chart'))
? p.get('chart')
: 'auto',
startAtZero: p.get('zero') === '1',
};
}
export function SystemMetricsExplorerPage() {
const djClient = useContext(DJClientContext).DataJunctionAPI;
const location = useLocation();
const navigate = useNavigate();
// eslint-disable-next-line react-hooks/exhaustive-deps
const initial = useMemo(() => parseSearch(location.search), []);
const [metricsList, setMetricsList] = useState([]);
const [metricSearch, setMetricSearch] = useState('');
const [dimSearch, setDimSearch] = useState('');
const [selectedMetric, setSelectedMetric] = useState(initial.metric);
const [availableDims, setAvailableDims] = useState([]); // [{name,type,path,label}]
const [xAxisDim, setXAxisDim] = useState(null);
const [compareBy, setCompareBy] = useState([]);
const [filters, setFilters] = useState(initial.filters);
// Pending selections from the URL that need to be resolved against
// availableDims once they load.
const pendingXAxis = useRef(initial.xAxis);
const pendingCompareBy = useRef(initial.compareBy);
const [rows, setRows] = useState(null);
const [columns, setColumns] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [view, setView] = useState(initial.view);
const [chartType, setChartType] = useState(initial.chartType);
const [startAtZero, setStartAtZero] = useState(initial.startAtZero);
// Load system metrics
useEffect(() => {
let cancelled = false;
(async () => {
try {
const list = await djClient.system.list();
if (cancelled) return;
if (!Array.isArray(list)) {
setError(
`Unexpected response from /system/metrics: ${JSON.stringify(
list,
).slice(0, 200)}`,
);
setMetricsList([]);
return;
}
// Endpoint may return either ["name", ...] (legacy) or
// [{name, display_name, description}, ...] (new). Normalize.
const normalized = list.map(item => {
if (typeof item === 'string') {
return {
name: item,
display_name: item,
description: '',
group: 'Other',
subgroup: 'Other',
suggestedCompareBy: [],
};
}
const meta = item.custom_metadata || {};
return {
name: item.name,
display_name: item.display_name || item.name,
description: item.description || '',
group: meta.group || 'Other',
subgroup: meta.subgroup || 'Other',
suggestedCompareBy: Array.isArray(meta.suggested_compare_by)
? meta.suggested_compare_by
: [],
};
});
setMetricsList(normalized);
if (normalized.length && !selectedMetric)
setSelectedMetric(normalized[0].name);
} catch (e) {
if (!cancelled) setError(String(e));
}
})();
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [djClient]);
// Load dimensions for selected metric
useEffect(() => {
if (!selectedMetric) {
setAvailableDims([]);
return;
}
let cancelled = false;
setXAxisDim(null);
setCompareBy([]);
setRows(null);
setColumns([]);
setError(null);
(async () => {
try {
const dims = await djClient.commonDimensions([selectedMetric]);
if (cancelled) return;
const opts = (Array.isArray(dims) ? dims : []).map(d => {
// d.name is e.g. "system.dj.date.dateint[created_at]". Pull the
// role suffix out for the group header.
const roleMatch = d.name.match(/\[([^\]]+)\]$/);
const role = roleMatch ? roleMatch[1] : null;
const baseName = role
? d.name.slice(0, -roleMatch[0].length)
: d.name;
const segments = baseName.split('.');
const attr = segments[segments.length - 1];
const nodeDisplay =
d.node_display_name || segments.slice(0, -1).join('.');
const colDisplay = d.column_display_name || attr;
const group = role ? `${nodeDisplay} [${role}]` : nodeDisplay;
return {
value: d.name,
label: colDisplay,
// Combined label used in search and in some narrow contexts.
combinedLabel: `${group} · ${colDisplay}`,
attr,
nodeDisplay,
colDisplay,
role,
group,
path: d.path.join(' ▶ '),
type: d.type,
};
});
setAvailableDims(opts);
// Apply pending URL state first if present; otherwise default to a
// temporal X-axis. Pending state is one-shot — clear after applying.
if (pendingXAxis.current) {
const match = opts.find(o => o.value === pendingXAxis.current);
if (match) setXAxisDim(match);
pendingXAxis.current = null;
} else {
// Prefer a "week" temporal column; fall back to any temporal one.
const weekly = opts.find(o => /_week\b/i.test(o.value));
const firstTemporal = weekly || opts.find(o => isTemporal(o.value));
if (firstTemporal) setXAxisDim(firstTemporal);
}
if (pendingCompareBy.current && pendingCompareBy.current.length) {
const matches = pendingCompareBy.current
.map(v => opts.find(o => o.value === v))
.filter(Boolean);
if (matches.length) setCompareBy(matches);
pendingCompareBy.current = null;
} else {
// Apply the metric's suggested compare-by defaults (from
// custom_metadata.suggested_compare_by). Only kicks in when the
// user has no pending compare-by from the URL.
const metric = metricsList.find(m => m.name === selectedMetric);
const suggested = (metric && metric.suggestedCompareBy) || [];
if (suggested.length) {
const matches = suggested
.map(v => opts.find(o => o.value === v))
.filter(Boolean);
if (matches.length) setCompareBy(matches);
}
}
} catch (e) {
if (!cancelled) setError(String(e));
}
})();
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedMetric, djClient]);
// Sync state back to URL so links are shareable.
useEffect(() => {
const p = new URLSearchParams();
if (selectedMetric) p.set('metric', selectedMetric);
if (xAxisDim) p.set('x', xAxisDim.value);
if (compareBy.length) p.set('by', compareBy.map(d => d.value).join(','));
filters.forEach(f => {
if (f.dim && f.op) {
p.append('filter', `${f.dim}|${f.op}|${f.value ?? ''}`);
}
});
if (view !== 'chart') p.set('view', view);
if (chartType !== 'auto') p.set('chart', chartType);
if (startAtZero) p.set('zero', '1');
const next = p.toString();
const current = window.location.search.replace(/^\?/, '');
if (next !== current) {
navigate(
{ pathname: window.location.pathname, search: next ? `?${next}` : '' },
{ replace: true },
);
}
}, [
selectedMetric,
xAxisDim,
compareBy,
filters,
view,
chartType,
startAtZero,
navigate,
]);
// Run query whenever the user changes inputs
useEffect(() => {
if (!selectedMetric) return;
let cancelled = false;
(async () => {
setLoading(true);
setError(null);
try {
const dimensions = [
...(xAxisDim ? [xAxisDim.value] : []),
...compareBy.map(d => d.value),
];
const filterClauses = filters
.filter(f => f.dim && f.op && f.value !== '')
.map(f => {
if (f.op === 'in') {
const vals = f.value
.split(',')
.map(v => v.trim())
.filter(Boolean)
.map(v => (isNumericLike(v) ? v : `'${v.replace(/'/g, "''")}'`))
.join(', ');
return `${f.dim} IN (${vals})`;
}
const lhs = f.dim;
const rhs = isNumericLike(f.value)
? f.value
: `'${String(f.value).replace(/'/g, "''")}'`;
return `${lhs} ${f.op} ${rhs}`;
});
const orderby = xAxisDim ? [xAxisDim.value] : [];
const result = await djClient.querySystemMetric({
metric: selectedMetric,
dimensions,
filters: filterClauses,
orderby,
});
if (cancelled) return;
// New compact shape: { columns: [...], rows: [[...], ...] }.
// Rows are already aligned to columns, no per-cell envelope.
setColumns(result.columns || []);
setRows(result.rows || []);
} catch (e) {
if (!cancelled) setError(String(e));
setRows(null);
setColumns([]);
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [selectedMetric, xAxisDim, compareBy, filters, djClient]);
const metricDisplay = useMemo(() => {
const map = {};
for (const m of metricsList) map[m.name] = m.display_name || m.name;
return map;
}, [metricsList]);
// Shape data for the chart.
// Recharts uses lodash-style get(obj, dataKey), so keys containing dots
// (like "system.dj.number_of_nodes") would be interpreted as nested
// paths and produce undefined. Flatten to safe keys (__x, s0, s1, ...)
// and keep a label map for tooltip/legend display.
const chartData = useMemo(() => {
const empty = { data: [], series: [], xKey: null, labels: {} };
if (!rows || !selectedMetric) return empty;
const xKey = xAxisDim?.value;
const breakdownKey = compareBy[0]?.value || null;
const metricCol = selectedMetric;
const metricIdx = columns.indexOf(metricCol);
if (metricIdx === -1) return empty;
const num = v => {
if (v === null || v === undefined) return 0;
const n = typeof v === 'number' ? v : parseFloat(v);
return Number.isFinite(n) ? n : 0;
};
const X = '__x';
// Strip rows (X buckets) where every series is 0/null and series that
// are 0/null at every X. Both flavors of "empty" are noise: an X bucket
// with no data adds an unused tick; a series with no data adds a legend
// entry and a phantom stack member.
const pruneEmpty = ({ data, series, labels, xKey: xk }) => {
const seriesWithValue = series.filter(s =>
data.some(r => r[s] !== 0 && r[s] != null),
);
const keptLabels = {};
for (const s of seriesWithValue) keptLabels[s] = labels[s];
const keptData = data
.map(r => {
const out = { [xk]: r[xk] };
for (const s of seriesWithValue) out[s] = r[s];
return out;
})
.filter(r => seriesWithValue.some(s => r[s] !== 0 && r[s] != null));
return {
data: keptData,
series: seriesWithValue,
labels: keptLabels,
xKey: xk,
};
};
if (!xKey) {
const bdIdx = breakdownKey ? columns.indexOf(breakdownKey) : -1;
const sKey = 's0';
const baseLabel =
metricDisplay[metricCol] || metricCol.replace(/^system\.dj\./, '');
if (bdIdx === -1) {
return {
data: [{ [X]: metricCol, [sKey]: num(rows[0]?.[metricIdx]) }],
series: [sKey],
xKey: X,
labels: { [sKey]: baseLabel },
};
}
return pruneEmpty({
data: rows.map(r => ({
[X]: String(r[bdIdx]),
[sKey]: num(r[metricIdx]),
})),
series: [sKey],
xKey: X,
labels: { [sKey]: baseLabel },
});
}
const xIdx = columns.indexOf(xKey);
if (xIdx === -1) return empty;
const baseLabel =
metricDisplay[metricCol] || metricCol.replace(/^system\.dj\./, '');
if (!breakdownKey) {
const sKey = 's0';
return pruneEmpty({
data: rows.map(r => ({
[X]: String(r[xIdx]),
[sKey]: num(r[metricIdx]),
})),
series: [sKey],
xKey: X,
labels: { [sKey]: baseLabel },
});
}
const bdIdx = columns.indexOf(breakdownKey);
const byX = new Map();
const seriesLabels = new Map(); // safeKey -> displayLabel
const seriesOrder = [];
for (const r of rows) {
const xVal = String(r[xIdx]);
const bdVal = String(r[bdIdx]);
if (!seriesLabels.has(bdVal)) {
const safe = `s${seriesLabels.size}`;
seriesLabels.set(bdVal, safe);
seriesOrder.push(safe);
}
const safe = seriesLabels.get(bdVal);
if (!byX.has(xVal)) byX.set(xVal, { [X]: xVal });
byX.get(xVal)[safe] = num(r[metricIdx]);
}
const labels = {};
seriesLabels.forEach((safe, orig) => {
labels[safe] = orig;
});
const filled = [];
byX.forEach(row => {
const out = { ...row };
for (const safe of seriesOrder) {
if (out[safe] === undefined || out[safe] === null) out[safe] = 0;
}
filled.push(out);
});
return pruneEmpty({
data: filled,
series: seriesOrder,
xKey: X,
labels,
});
}, [rows, columns, xAxisDim, compareBy, selectedMetric, metricDisplay]);
const filteredMetrics = useMemo(() => {
const q = metricSearch.toLowerCase();
return metricsList.filter(
m =>
m.name.toLowerCase().includes(q) ||
(m.display_name && m.display_name.toLowerCase().includes(q)),
);
}, [metricsList, metricSearch]);
// Two-level grouping: group > subgroup > metric[]. Preserves insertion
// order so the seed's intended ordering is honored.
// NOTE: avoid `[...map.entries()]` here — this bundle has a polyfill that
// breaks Map's iterator and silently produces empty arrays. forEach works.
const groupedMetrics = useMemo(() => {
const groupOrder = [];
const groupMap = {};
for (const m of filteredMetrics) {
const g = m.group || 'Other';
const sg = m.subgroup || 'Other';
if (!groupMap[g]) {
groupMap[g] = { subgroupOrder: [], subgroups: {} };
groupOrder.push(g);
}
if (!groupMap[g].subgroups[sg]) {
groupMap[g].subgroups[sg] = [];
groupMap[g].subgroupOrder.push(sg);
}
groupMap[g].subgroups[sg].push(m);
}
return groupOrder.map(group => ({
group,
subgroups: groupMap[group].subgroupOrder.map(subgroup => ({
subgroup,
metrics: groupMap[group].subgroups[subgroup],
})),
}));
}, [filteredMetrics]);
const selectedMetricDisplay = selectedMetric
? metricDisplay[selectedMetric] ||
selectedMetric.replace(/^system\.dj\./, '')
: null;
const selectedMetricDescription = selectedMetric
? (metricsList.find(m => m.name === selectedMetric) || {}).description || ''
: '';
const filteredDims = useMemo(() => {
const q = dimSearch.toLowerCase();
return availableDims.filter(
d =>
d.value.toLowerCase().includes(q) ||
d.combinedLabel.toLowerCase().includes(q),
);
}, [availableDims, dimSearch]);
// Group filtered dims by their parent dim node (+ role) for use in
// react-select option groups and in the rail's sectioned list.
const groupedDims = useMemo(() => {
const order = [];
const byGroup = new Map();
for (const d of filteredDims) {
if (!byGroup.has(d.group)) {
byGroup.set(d.group, []);
order.push(d.group);
}
byGroup.get(d.group).push(d);
}
return order.map(group => ({ label: group, options: byGroup.get(group) }));
}, [filteredDims]);
const resolvedChartType = useMemo(() => {
if (chartType !== 'auto') return chartType;
const temporal = xAxisDim && isTemporal(xAxisDim.value);
if (!temporal) return 'bar';
return compareBy.length > 0 ? 'area' : 'line';
}, [chartType, xAxisDim, compareBy]);
const addFilter = () =>
setFilters([...filters, { dim: '', op: '=', value: '' }]);
const updateFilter = (i, patch) =>
setFilters(filters.map((f, idx) => (idx === i ? { ...f, ...patch } : f)));
const removeFilter = i => setFilters(filters.filter((_, idx) => idx !== i));
const renderChart = () => {
if (loading)
return (
<div className="sme-empty">
<span className="sme-spinner" />
Loading…
</div>
);
if (!rows || rows.length === 0)
return (
<div className="sme-empty">
<div className="sme-empty-icon">📈</div>
No data.
</div>
);
return (
<ChartView
chartData={chartData}
resolvedChartType={resolvedChartType}
startAtZero={startAtZero}
/>
);
};
const renderTable = () => {
if (loading)
return (
<div className="sme-empty">
<span className="sme-spinner" />
Loading…
</div>
);
if (!rows || rows.length === 0)
return <div className="sme-empty">No data.</div>;
return (
<div className="sme-table-wrap">
<table className="sme-table">
<thead>
<tr>
{columns.map(c => (
<th key={c}>{c}</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, i) => (
<tr key={`row-${i}`}>
{row.map((v, j) => (
<td key={`${i}-${j}`}>
{v === null || v === undefined ? '' : String(v)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
};
return (
<div className="sme-page">
{/* Left rail */}
<aside className="sme-rail">
<div className="sme-rail-section grow">
<div className="sme-rail-header">
<span>System Metrics</span>
<span className="sme-rail-count">{filteredMetrics.length}</span>
</div>
<input
className="sme-rail-search"
placeholder="Search metrics"
value={metricSearch}
onChange={e => setMetricSearch(e.target.value)}
/>
<div className="sme-rail-list">
{groupedMetrics.map(g => (
<div key={g.group} className="sme-rail-group">
<div className="sme-rail-group-header">{g.group}</div>
{g.subgroups.map(sg => (
<div key={sg.subgroup} className="sme-rail-subgroup">
<div className="sme-rail-subgroup-header">
{sg.subgroup}
</div>
{sg.metrics.map(m => (
<div
key={m.name}
className={`sme-rail-item indent${
selectedMetric === m.name ? ' active' : ''
}`}
onClick={() => setSelectedMetric(m.name)}
title={
m.description
? `${m.name}\n\n${m.description}`
: m.name
}
>
<span className="sme-rail-icon">Σ</span>
{m.display_name || m.name.replace(/^system\.dj\./, '')}
</div>
))}
</div>
))}
</div>
))}
</div>
</div>
<div className="sme-rail-section grow">
<div className="sme-rail-header">
<span>Dimensions</span>
<span className="sme-rail-count">{filteredDims.length}</span>
</div>
<input
className="sme-rail-search"
placeholder="Search dimensions"
value={dimSearch}
onChange={e => setDimSearch(e.target.value)}
disabled={!selectedMetric}
/>
<div className="sme-rail-list">
{groupedDims.map(group => (
<div key={group.label} className="sme-rail-group">
<div className="sme-rail-group-header">{group.label}</div>
{group.options.map(d => {
const selected =
compareBy.some(c => c.value === d.value) ||
xAxisDim?.value === d.value;
return (
<div
key={d.value}
className={`sme-rail-item indent${
selected ? ' active' : ''
}`}
title={`${d.value}\n${d.path}\nClick to toggle in Compare-by`}
onClick={() => {
if (xAxisDim?.value === d.value) return;
setCompareBy(prev =>
prev.some(c => c.value === d.value)
? prev.filter(c => c.value !== d.value)
: [...prev, d],
);
}}
>
<span className="sme-rail-icon">{typeIcon(d.type)}</span>
{d.label}
</div>
);
})}
</div>
))}
</div>
</div>
</aside>
{/* Main pane */}
<div className="sme-main">
<div className="sme-toolbar">
<div className="sme-row">
<span className="sme-label">X-axis</span>
<span className="sme-select-wide">
<Select
isClearable
placeholder="Choose an X-axis dimension"
options={groupedDims}
value={xAxisDim}
onChange={setXAxisDim}
isDisabled={!selectedMetric}
styles={groupedSelectStyles}
/>
</span>
</div>
<div className="sme-row">
<span className="sme-label">Compare by</span>
<span className="sme-select-wide">
<Select
isMulti
isClearable
placeholder="Break down by dimensions"
options={groupedDims
.map(g => ({
...g,
options: g.options.filter(d => d.value !== xAxisDim?.value),
}))
.filter(g => g.options.length > 0)}
value={compareBy}
onChange={vals => setCompareBy(vals || [])}
isDisabled={!selectedMetric}
styles={groupedSelectStyles}
/>
</span>
</div>
{filters.map((f, i) => (
<div className="sme-filter-row" key={`f-${i}`}>
<span className="sme-label">{i === 0 ? 'Filter' : 'and'}</span>
<span className="sme-select">
<Select
placeholder="Dimension"
options={groupedDims}
value={availableDims.find(d => d.value === f.dim) || null}
onChange={o => updateFilter(i, { dim: o?.value || '' })}
styles={groupedSelectStyles}
/>
</span>
<span style={{ minWidth: 140 }}>
<Select
options={OPERATORS}
value={OPERATORS.find(o => o.value === f.op)}
onChange={o => updateFilter(i, { op: o.value })}
/>
</span>
<input
className="sme-text-input"
placeholder={f.op === 'in' ? 'comma-separated values' : 'value'}
value={f.value}
onChange={e => updateFilter(i, { value: e.target.value })}
/>
<button
className="sme-remove-filter"
onClick={() => removeFilter(i)}
aria-label="Remove filter"
>
×
</button>
</div>
))}
<div className="sme-row">
<button
className="sme-add-filter"
onClick={addFilter}
disabled={!selectedMetric}
>
+ Add filter
</button>
</div>
</div>
<div className="sme-content">
<div className="sme-chart-controls">
<div className="sme-view-toggle">
<button
className={`sme-view-btn${view === 'chart' ? ' active' : ''}`}
onClick={() => setView('chart')}
>
📈 Chart
</button>
<button
className={`sme-view-btn${view === 'table' ? ' active' : ''}`}
onClick={() => setView('table')}
>
▦ Table
</button>
</div>
{view === 'chart' ? (
<div className="sme-view-toggle">
{[
{ key: 'auto', label: 'Auto' },
{ key: 'line', label: 'Line' },
{ key: 'area', label: 'Area' },
{ key: 'bar', label: 'Bar' },
].map(t => (
<button
key={t.key}
className={`sme-view-btn${
chartType === t.key ? ' active' : ''
}`}
onClick={() => setChartType(t.key)}
>
{t.label}
</button>
))}
</div>
) : null}
<div className="sme-options">
<label>
<input
type="checkbox"
checked={startAtZero}
onChange={e => setStartAtZero(e.target.checked)}
/>
Start scale at zero
</label>
</div>
</div>
<h2 className="sme-chart-title">
{selectedMetric ? (
<a
href={`/nodes/${selectedMetric}`}
target="_blank"
rel="noreferrer"
title={`Open ${selectedMetric}`}
className="sme-chart-title-link"
>
{selectedMetricDisplay}
</a>
) : (
'Pick a system metric'
)}
</h2>
{selectedMetricDescription ? (
<p className="sme-chart-description">{selectedMetricDescription}</p>
) : null}
{error ? <div className="sme-error">{error}</div> : null}
{view === 'chart' ? renderChart() : renderTable()}
</div>
</div>
</div>
);
}
SystemMetricsExplorerPage.defaultProps = {
djClient: DataJunctionAPI,
};