UNPKG

datajunction-ui

Version:
1,059 lines (1,006 loc) 34.7 kB
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, };