From be28c76f207f2dd9926d6f48b6a3d78c92139e18 Mon Sep 17 00:00:00 2001 From: Andrew Theurer Date: Wed, 22 Apr 2026 12:40:30 -0400 Subject: [PATCH 1/2] Add deep dive series legend with rowSpan grouping and sticky labels Deep dive legend: - Live value tracking as pointer moves across chart, persists on exit - Click-to-pin/unpin with cross-chart sync via shared elapsed time - Reference line on all charts at the active time position - rowSpan grouping for breakout segments (hostname spans CPU rows) - Common prefix/suffix stripping from segment values - Sticky text in tall rowSpan cells so group label stays visible while scrolling - Color swatches positioned next to value column - Disable SVG focus outline on click Compare view: - Apply same sticky label treatment to breakout sidebar rowSpan cells Co-Authored-By: Claude Opus 4.6 (1M context) --- .../web-ui/src/components/CompareView.jsx | 5 +- .../web-ui/src/components/DeepDiveView.jsx | 88 +++++++++++++++++-- queries/cdmq/web-ui/src/index.css | 29 ++++++ 3 files changed, 115 insertions(+), 7 deletions(-) diff --git a/queries/cdmq/web-ui/src/components/CompareView.jsx b/queries/cdmq/web-ui/src/components/CompareView.jsx index 88862af2..c3765f71 100644 --- a/queries/cdmq/web-ui/src/components/CompareView.jsx +++ b/queries/cdmq/web-ui/src/components/CompareView.jsx @@ -450,7 +450,10 @@ function renderGroupedBreakouts(items, depth, breakoutNames) { {row.segVals.map(function (sv, ci) { if (rowSpans[ri][ci] === 0) return null; - return {sv}; + var span = rowSpans[ri][ci]; + return + {span > 1 ?
{sv}
: sv} + ; })} {row.value} diff --git a/queries/cdmq/web-ui/src/components/DeepDiveView.jsx b/queries/cdmq/web-ui/src/components/DeepDiveView.jsx index c3c37f6b..9ea41956 100644 --- a/queries/cdmq/web-ui/src/components/DeepDiveView.jsx +++ b/queries/cdmq/web-ui/src/components/DeepDiveView.jsx @@ -370,7 +370,7 @@ export default function DeepDiveView({ selected, deepDiveMetrics, metricConfigs: } }} onMouseLeave={function () { - if (pinnedElapsed == null) setHoverElapsed(null); + // Keep last hovered values visible when pointer leaves }} onClick={function (e) { if (e && e.activeTooltipIndex != null) { @@ -439,29 +439,105 @@ export default function DeepDiveView({ selected, deepDiveMetrics, metricConfigs:
{Object.keys(legendByIter).map(function (itId) { var group = legendByIter[itId]; + var items = group.items; + var numCols = items.length > 0 && items[0].segments.length > 0 ? items[0].segments.length : 0; + + // Strip common prefix/suffix from each segment column + var colStripped = []; + for (var col = 0; col < numCols; col++) { + var vals = items.map(function (it) { return it.segments[col] || ''; }); + var unique = Array.from(new Set(vals)); + var stripped = vals; + if (unique.length > 1) { + // Find common suffix (split at delimiter boundaries) + var suffix = ''; + var first = unique[0]; + for (var si2 = first.length - 1; si2 > 0; si2--) { + var ch = first[si2]; + if (ch === '.' || ch === '-' || ch === '_') { + var candidate = first.substring(si2); + if (unique.every(function (v) { return v.endsWith(candidate); })) { + suffix = candidate; + } + } + } + if (suffix) { + stripped = vals.map(function (v) { return v.substring(0, v.length - suffix.length); }); + } else { + // Try common prefix + var prefix = ''; + for (var pi = 0; pi < first.length - 1; pi++) { + var pch = first[pi]; + if (pch === '.' || pch === '-' || pch === '_') { + var pcandidate = first.substring(0, pi + 1); + if (unique.every(function (v) { return v.startsWith(pcandidate); })) { + prefix = pcandidate; + } + } + } + if (prefix) { + stripped = vals.map(function (v) { return v.substring(prefix.length); }); + } + } + } + colStripped.push(stripped); + } + + // Compute rowSpans for hierarchical grouping + var rowSpans = items.map(function () { return new Array(numCols).fill(1); }); + for (var col2 = 0; col2 < numCols; col2++) { + var spanStart = 0; + for (var row = 1; row <= items.length; row++) { + var same = row < items.length; + if (same) { + for (var c = 0; c <= col2; c++) { + if ((colStripped[c] ? colStripped[c][row] : '') !== (colStripped[c] ? colStripped[c][spanStart] : '')) { same = false; break; } + } + } + if (!same) { + rowSpans[spanStart][col2] = row - spanStart; + for (var r = spanStart + 1; r < row; r++) rowSpans[r][col2] = 0; + spanStart = row; + } + } + } + return (
{group.iterLabel}
- {breakoutNames.map(function (name, ni) { return ; })} {breakoutNames.length === 0 && } + - {group.items.map(function (item) { + {items.map(function (item, ri) { var value = activeEntry ? activeEntry[item.key] : null; return ( + {numCols > 0 ? (function () { + var cells = []; + for (var ci = 0; ci < numCols; ci++) { + if (rowSpans[ri][ci] > 0) { + var span = rowSpans[ri][ci]; + cells.push( + + ); + } + } + return cells; + })() : } - {item.segments.length > 0 ? item.segments.map(function (seg, si) { - return ; - }) : } ); diff --git a/queries/cdmq/web-ui/src/index.css b/queries/cdmq/web-ui/src/index.css index 096fc089..60886e2e 100644 --- a/queries/cdmq/web-ui/src/index.css +++ b/queries/cdmq/web-ui/src/index.css @@ -1721,6 +1721,12 @@ a.run-id:hover { font-family: 'SF Mono', ui-monospace, Consolas, monospace; } +.compare-sidebar-seg-sticky { + position: sticky; + top: 4px; + bottom: 4px; +} + .compare-sidebar-table-val { padding: 2px 4px; text-align: right; @@ -2056,6 +2062,14 @@ a.run-id:hover { border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; + user-select: none; +} + +.deepdive-chart-panel svg, +.deepdive-chart-panel svg *, +.deepdive-chart-panel .recharts-surface, +.deepdive-chart-panel .recharts-wrapper { + outline: none !important; } .deepdive-chart-title { @@ -2184,6 +2198,21 @@ a.run-id:hover { .deepdive-legend-seg { font-family: 'SF Mono', ui-monospace, Consolas, monospace; color: var(--text-secondary); + border-right: 1px solid var(--border); + vertical-align: middle; +} + +.deepdive-legend-seg[rowspan] { + font-weight: 600; + color: var(--text); + border-bottom: 1px solid var(--border); + position: relative; +} + +.deepdive-legend-seg-sticky { + position: sticky; + top: 4px; + bottom: 4px; } .deepdive-legend-val { From 1af3ef410485e299ac0e327831d6486c243530ae Mon Sep 17 00:00:00 2001 From: Andrew Theurer Date: Thu, 23 Apr 2026 12:17:31 -0400 Subject: [PATCH 2/2] Deep dive UI refinements, iteration selection, and unified styling Deep dive improvements: - Unified legend with per-iteration color columns, rowSpan grouping - Per-metric Combined/Split/Stacked chart mode controls - Brush-to-zoom with composable re-query - Sample-index-based data alignment across iterations - Two-pass filter for resolution > 1 (filter at res=1, re-query survivors) - Context bar with collapsible Common section and iteration cards - Auto-pin at 50% elapsed on initial data load - connectNulls for combined mode charts Compare view: - Deep dive iteration selection via clickable boxes on bar chart - Selected iterations shown as themed cards with remove buttons - Iteration cards ordered to match bar chart sort order - Common items styled with color-coded chips (matching search/deep dive) Unified iteration labels: - Shared buildIterItems/iterItemsToString in utils/iterLabel.js - Value consolidation (bs,rw,size=4k) across all views - Box-in-box iteration cards in search, compare, and deep dive - Single-column vertical layout for inner chips - Horizontal scroll for iteration card rows Search view: - Collapsible Common section with details/summary - Selection bar with box-in-box iteration cards and horizontal scroll - Header row with count and Clear All Server: - Cleaned up verbose debug logging (commented out OS-REQ/RESP/BATCH) - Kept PERF summary and METRIC-DESC timing Documentation: - Updated DESIGN.md with deep dive workflow, query performance, zoom - Updated ARCHITECTURE.md Phase 3 status Co-Authored-By: Claude Opus 4.6 (1M context) --- queries/cdmq/cdm.js | 32 +- queries/cdmq/server.js | 2 +- queries/cdmq/web-ui/ARCHITECTURE.md | 14 +- queries/cdmq/web-ui/DESIGN.md | 89 +- queries/cdmq/web-ui/src/App.jsx | 10 +- .../web-ui/src/components/CompareView.jsx | 94 ++- .../web-ui/src/components/DeepDiveView.jsx | 781 +++++++++++------- .../web-ui/src/components/IterationTable.jsx | 30 +- .../web-ui/src/components/SelectionBar.jsx | 42 +- queries/cdmq/web-ui/src/index.css | 429 +++++++++- queries/cdmq/web-ui/src/utils/iterLabel.js | 60 ++ 11 files changed, 1212 insertions(+), 371 deletions(-) create mode 100644 queries/cdmq/web-ui/src/utils/iterLabel.js diff --git a/queries/cdmq/cdm.js b/queries/cdmq/cdm.js index 4a562781..9a624b95 100644 --- a/queries/cdmq/cdm.js +++ b/queries/cdmq/cdm.js @@ -677,7 +677,7 @@ async function fetchBatchedData(instance, reqs, batchSize = 16) { try { var osReqStart = Date.now(); var bodyLen = req.body ? req.body.length : 0; - console.log('[' + new Date().toISOString() + '] [OS-REQ] POST ' + req.url + ' (' + bodyLen + ' bytes)'); + //console.log POST ' + req.url + ' (' + bodyLen + ' bytes)'); if (process.env.CDM_LOG_OS_CURL) { var curlBody = req.body.replace(/'/g, "'\\''"); console.log('[' + new Date().toISOString() + '] [OS-CURL] curl -s -X POST "' + req.url + '" -H "Content-Type: application/json" -d $\'' + curlBody + '\''); @@ -689,7 +689,7 @@ async function fetchBatchedData(instance, reqs, batchSize = 16) { headers: { 'Content-Type': 'application/json' }, }); var osElapsed = Date.now() - osReqStart; - console.log('[' + new Date().toISOString() + '] [OS-RESP] POST ' + req.url + ' status=' + response.status + ' in ' + osElapsed + 'ms'); + //console.log POST ' + req.url + ' status=' + response.status + ' in ' + osElapsed + 'ms'); if (response.ok) { try { return await response.json(); @@ -819,7 +819,7 @@ esJsonArrRequest = async function (instance, docType, action, jsonArr, yearDotMo } else { batchSize = 16; } - console.log('[' + new Date().toISOString() + '] [OS-BATCH] ' + reqs.length + ' _msearch request(s), ' + totalSubQueries + ' sub-queries, batchSize=' + batchSize); + //console.log ' + reqs.length + ' _msearch request(s), ' + totalSubQueries + ' sub-queries, batchSize=' + batchSize); var responses = await fetchBatchedData(instance, reqs, batchSize); reqs = []; @@ -2921,7 +2921,7 @@ getMetricGroupsFromBreakouts = async function (instance, sets, yearDotMonth) { jsonArr.push(JSON.stringify(q)); // Log the metric_desc query as curl for debugging var indexName = getIndexName('metric_desc', instance, yearDotMonth); - console.log('[' + new Date().toISOString() + '] [OS-METRIC-DESC] curl -s -X POST "http://' + instance['host'] + '/' + indexName + '/_search" -H "Content-Type: application/json" -d \'' + JSON.stringify(q) + '\''); + //console.log -s -X POST "http://' + instance['host'] + '/' + indexName + '/_search" -H "Content-Type: application/json" -d \'' + JSON.stringify(q) + '\''); }); var mdStart = Date.now(); var responses = await esJsonArrRequest(instance, 'metric_desc', '/_msearch', jsonArr, yearDotMonth); @@ -3105,9 +3105,9 @@ sendMetricReq = async function ( // Trim the pre-allocated array to actual size before sending var actualLen = jsonArr._writeIdx; var sendArr = jsonArr.slice(0, actualLen); - console.log('[' + new Date().toISOString() + '] [PERF] sendMetricReq: submitting ' + actualLen + ' jsonArr entries (' + (jsonArrEstimatedBytes/1024/1024).toFixed(1) + 'MB) to esJsonArrRequest'); + //console.log: submitting ' + actualLen + ' jsonArr entries (' + (jsonArrEstimatedBytes/1024/1024).toFixed(1) + 'MB) to esJsonArrRequest'); const theseResponses = await esJsonArrRequest(instance, 'metric_data', '/_msearch', sendArr, yearDotMonth); - console.log('[' + new Date().toISOString() + '] [PERF] sendMetricReq: esJsonArrRequest returned ' + theseResponses.length + ' responses in ' + (Date.now()-esStart) + 'ms'); + //console.log: esJsonArrRequest returned ' + theseResponses.length + ' responses in ' + (Date.now()-esStart) + 'ms'); responses.push(...theseResponses); jsonArr._writeIdx = 0; jsonArrTracker._writeIdx = 0; @@ -3145,7 +3145,7 @@ sendMetricReq = async function ( valueSets[setIdx][trackerLabel] ); } - console.log('[' + new Date().toISOString() + '] [PERF] sendMetricReq: calcAvg processed responses in ' + (Date.now()-calcStart) + 'ms'); + //console.log: calcAvg processed responses in ' + (Date.now()-calcStart) + 'ms'); } if (thisBegin > thisEnd) { @@ -3326,7 +3326,7 @@ getMetricDataFromIdsSets = async function (instance, sets, metricGroupIdsByLabel if (thisEnd > end) thisEnd = end; if (thisBegin > thisEnd) break; } - console.log('[' + new Date().toISOString() + '] [PERF] Built ' + timeRangeTemplates.length + ' time-range templates for set ' + idx); + //console.log templates for set ' + idx); const sortedKeys = Object.keys(metricGroupIdsByLabelSets[idx]).sort(); var jsonArr = []; @@ -3364,14 +3364,14 @@ getMetricDataFromIdsSets = async function (instance, sets, metricGroupIdsByLabel if (shouldFlush && jsonArr.length > 0) { var esStart = Date.now(); - console.log('[' + new Date().toISOString() + '] [PERF] Flushing ' + jsonArr.length + ' entries (' + (k+1) + '/' + sortedKeys.length + ' labels) to OpenSearch'); + //console.log ' + jsonArr.length + ' entries (' + (k+1) + '/' + sortedKeys.length + ' labels) to OpenSearch'); var theseResponses = await esJsonArrRequest(instance, 'metric_data', '/_msearch', jsonArr, yearDotMonth); - console.log('[' + new Date().toISOString() + '] [PERF] OpenSearch returned ' + theseResponses.length + ' responses in ' + (Date.now()-esStart) + 'ms'); + //console.log ' + theseResponses.length + ' responses in ' + (Date.now()-esStart) + 'ms'); responses.push(...theseResponses); // Process responses var calcStart = Date.now(); - console.log('[' + new Date().toISOString() + '] [DEBUG] Before calcAvg loop: jsonArrIdx=' + jsonArrIdx + ', responses.length=' + responses.length + ', jsonArrTracker.length=' + jsonArrTracker.length); + //console.log Before calcAvg loop: jsonArrIdx=' + jsonArrIdx + ', responses.length=' + responses.length + ', jsonArrTracker.length=' + jsonArrTracker.length); while (jsonArrIdx < responses.length * 2) { var trackerIdx = jsonArrIdx / 2; var tracker = jsonArrTracker[trackerIdx]; @@ -3384,7 +3384,7 @@ getMetricDataFromIdsSets = async function (instance, sets, metricGroupIdsByLabel jsonArrIdx = calcAvg(tracker.begin, tracker.end, responses, jsonArrIdx, jsonArrTracker, tracker.numMetricIds, valueSets[setIdx][trackerLabel]); console.log('[' + new Date().toISOString() + '] [DEBUG] calcAvg: label="' + trackerLabel + '", set=' + setIdx + ', jsonArrIdx ' + prevIdx + '->' + jsonArrIdx + ', values=' + valueSets[setIdx][trackerLabel].length); } - console.log('[' + new Date().toISOString() + '] [PERF] calcAvg in ' + (Date.now()-calcStart) + 'ms'); + //console.log in ' + (Date.now()-calcStart) + 'ms'); jsonArr = []; jsonArrTracker = []; @@ -3392,12 +3392,12 @@ getMetricDataFromIdsSets = async function (instance, sets, metricGroupIdsByLabel jsonArrIdx = 0; } - if (k === 0 || lastPass || (Date.now() - labelStart > 500)) { - console.log('[' + new Date().toISOString() + '] [PERF] label ' + (k+1) + '/' + sortedKeys.length + ' "' + label + '" took ' + (Date.now() - labelStart) + 'ms'); - } + //if (k === 0 || lastPass || (Date.now() - labelStart > 500)) { + // console.log('[' + new Date().toISOString() + '] [PERF] label ' + (k+1) + '/' + sortedKeys.length + ' "' + label + '" took ' + (Date.now() - labelStart) + 'ms'); + //} } } - console.log('[' + new Date().toISOString() + '] [PERF] getMetricDataFromIdsSets total: ' + (Date.now()-funcStart) + 'ms, valueSets.length=' + valueSets.length + ', keys=' + valueSets.map(function(vs, i) { return i + ':' + (vs ? Object.keys(vs).join(',') : 'null'); }).join(' | ')); + console.log('[' + new Date().toISOString() + '] [PERF] getMetricDataFromIdsSets total: ' + (Date.now()-funcStart) + 'ms'); return valueSets; }; diff --git a/queries/cdmq/server.js b/queries/cdmq/server.js index d5510a2e..81dae62f 100755 --- a/queries/cdmq/server.js +++ b/queries/cdmq/server.js @@ -1561,7 +1561,7 @@ app.post('/api/v1/metric-data', async (req, res) => { var reqStart = Date.now(); var breakoutStr = Array.isArray(breakout) ? breakout.join(',') : (breakout || 'none'); serverLog('POST /api/v1/metric-data: ' + source + '::' + type + ' resolution=' + resolution + ' breakout=[' + breakoutStr + ']' + (filter ? ' filter=' + filter : '') + ' run=' + (run || 'none').toString().substring(0, 8) + '... period=' + (period || 'none').toString().substring(0, 8) + '...', req.reqId); - serverLog(' curl: curl -s -X POST http://localhost:3000/api/v1/metric-data -H "Content-Type: application/json" -d \'' + JSON.stringify({ run: run, period: period, begin: begin, end: end, source: source, type: type, resolution: resolution, breakout: breakout, filter: filter }) + '\'', req.reqId); + //serverLog(' curl: curl -s -X POST http://localhost:3000/api/v1/metric-data -H "Content-Type: application/json" -d \'' + JSON.stringify({ run: run, period: period, begin: begin, end: end, source: source, type: type, resolution: resolution, breakout: breakout, filter: filter }) + '\'', req.reqId); // Use instances from request if provided, otherwise use server's configured instances var instancesToUse = reqInstances && reqInstances.length > 0 ? reqInstances : instances; diff --git a/queries/cdmq/web-ui/ARCHITECTURE.md b/queries/cdmq/web-ui/ARCHITECTURE.md index 5324c853..b0002fd3 100644 --- a/queries/cdmq/web-ui/ARCHITECTURE.md +++ b/queries/cdmq/web-ui/ARCHITECTURE.md @@ -1,8 +1,8 @@ # Crucible Web UI — Architecture & Design -> **Note:** This document covers the initial Phase 1 architecture. For comprehensive -> documentation covering all phases (search, compare, supplemental metrics, breakouts, -> URL sharing, etc.), see **[DESIGN.md](DESIGN.md)**. +> **Note:** This document covers the initial architecture overview. For comprehensive +> documentation covering all phases (search, compare, deep dive, supplemental metrics, +> breakouts, URL sharing, performance optimizations, etc.), see **[DESIGN.md](DESIGN.md)**. ## Overview @@ -51,6 +51,7 @@ queries/cdmq/ │ │ ├── IterationTable.jsx # Results table with sorting/filtering │ │ ├── SelectionBar.jsx # Persistent selection display │ │ ├── CompareView.jsx # Bar charts with grouping, metrics, breakouts +│ │ ├── DeepDiveView.jsx # Time-series line charts with zoom and legends │ │ ├── AutocompleteInput.jsx # Reusable dropdown (single/multi-select) │ │ └── DebugConsole.jsx # Timing/debug console panel │ └── dist/ # Build output (served by Express in production) @@ -347,12 +348,9 @@ See sections above and [DESIGN.md](DESIGN.md) for full details. ### Phase 2: Compare — Implemented Bar chart comparison with hierarchical group-by headers, supplemental metrics (overlay + panel modes), breakouts with filter/sample selection, click-to-pin with reference lines, URL state sharing. See [DESIGN.md](DESIGN.md) for full details. -### Phase 3: Deep Dive (Time-Series Line Charts) — Planned +### Phase 3: Deep Dive (Time-Series Line Charts) — Implemented -Interactive time-series exploration: -- Line charts with zoom/pan -- Breakout exploration -- Iteration overlay with relative time alignment +Time-series line charts with per-iteration color themes, combined/split/stacked modes, brush-to-zoom with re-query, unified series legend with rowSpan grouping, live value tracking across all charts, and two-pass filter support. See [DESIGN.md](DESIGN.md) for full details. ## Design Decisions & Rationale diff --git a/queries/cdmq/web-ui/DESIGN.md b/queries/cdmq/web-ui/DESIGN.md index 04bd16c7..1700ce5b 100644 --- a/queries/cdmq/web-ui/DESIGN.md +++ b/queries/cdmq/web-ui/DESIGN.md @@ -702,7 +702,88 @@ Primary metric values are NOT loaded with iteration details (would add ~7 second ### Start-Server Restart Loop -`start-server.sh` runs server.js in a `while true` loop. Killing the Node process (`pkill -f 'node ./server.js'`) triggers an automatic restart with updated code after a 1-second delay. This avoids the need to stop/restart the container during development. +`start-server.sh` runs server.js in a `while true` loop with `npm ci` and web UI build gated by stamp files (only re-run when `package-lock.json` changes). Killing the Node process (`pkill -f 'node ./server.js'`) triggers an automatic restart. Full restart via `sudo crucible stop opensearch && sudo crucible start opensearch`. + +### Metric Query Performance (20x improvement) + +The `getMetricDataFromIdsSets` function was rewritten to address a 100-second bottleneck when querying 130+ breakout labels at resolution=100: + +1. **Time-range templates**: The 4 queries per time window (weighted avg, total weight, 2 boundary doc fetches) share identical structure except timestamps. Templates are built once per set with `__IDS__` placeholder, then reused per label via `String.replace()`. + +2. **Periodic flushing**: Instead of accumulating all queries (104K+ array entries) before sending to OpenSearch, flush every 10 labels. Keeps array sizes small and lets OpenSearch process while building the next batch. + +3. **Native fetch**: Replaced `then-request` (which spawns child processes via `sync-rpc`) with Node.js native `fetch` for all async OpenSearch HTTP requests. + +4. **Debug function short-circuit**: `numMBytes()` and `memUsage()` now return immediately when `debugOut == 0`, avoiding `JSON.stringify` on large arrays. + +5. **Two-pass filter**: When `filter` is set with `resolution > 1`, first queries at resolution=1 to determine surviving labels, then re-queries at the requested resolution with only those labels' UUIDs. + +--- + +## Deep Dive Workflow + +### Overview + +The Deep Dive view provides time-series line charts for selected metrics at high resolution (default 100 data points), with multiple iterations overlaid. + +### Entry Flow + +1. In Compare view, check "Dive" on metric panels to select metrics for deep dive +2. "Deep Dive (N)" button becomes enabled in the nav bar +3. Clicking it snapshots the supplemental metric configs (breakouts, filters) and switches view +4. DeepDiveView fetches period info, then metric data sequentially per metric + +### Data Alignment + +CDM metric data is continuous — each sample covers a `[begin, end]` range in epoch-ms with no gaps. All series at the same resolution have exactly N samples. The chart uses **sample index** as the X coordinate (not raw elapsed midpoints) to ensure all series from different iterations align perfectly on the same grid. The X-axis displays elapsed time based on the longest period's duration. + +### Chart Modes + +Each metric chart has independent controls: +- **Combined**: All iterations overlaid on one chart (300px) +- **Split**: One chart per iteration stacked vertically (200px each), with consistent Y-axis scale across iterations +- **Lines / Stacked**: In split mode, toggle between individual lines and stacked area charts (useful for CPU utilization breakdown) + +### Zoom + +- **Click + drag** on any chart to select a time range (blue highlight) +- All charts re-query with the zoomed time range at the same resolution (more detail) +- Zoom is composable — zoom again within a zoomed view +- "Reset Zoom" button shows current zoom percentage +- Zoom is percentage-based: each iteration's begin/end adjusted proportionally + +### Series Legend + +Below each chart, a unified legend table shows all breakout labels once (not duplicated per iteration): + +- **Segment columns**: Breakout dimension values with rowSpan grouping and sticky text for tall cells +- **Per-iteration columns**: Color swatch + value pair for each iteration, with iteration chip header matching the context bar style +- **Live tracking**: Values update as pointer moves across any chart, synchronized across all charts via shared elapsed time +- **Click-to-pin**: Click locks all charts; click again to resume live tracking +- **Common prefix/suffix stripping**: Hostnames like `f35-h17-000-r640.rdu2.scalelab.redhat.com` shown as `f35-h17-000-r640` +- **Empty series**: No color swatch shown when an iteration lacks data for a label + +### Per-Iteration Color Themes + +Each iteration gets a color family (blues, reds, greens, purples, teals, ambers). Within each family, shade varies per breakout label. This makes it easy to identify which iteration a line belongs to. + +### Context Bar + +Above the charts, a context section shows: +- **Common**: Params/tags/benchmark shared across all iterations (chip-styled, respects hidden fields) +- **Chip legend**: bench/tag/param color reference +- **Iterations**: Labeled chips with iteration-specific varying params, colored with the iteration's theme + +### Server Endpoints + +| Method | Path | Purpose | +|--------|------|---------| +| POST | `/api/v1/iterations/period-info` | Period IDs and time ranges per iteration | +| POST | `/api/v1/metric-data` | Time-series metric values with resolution and breakouts | + +### Progressive Loading + +Metrics are fetched sequentially (one metric at a time). Within each metric, iterations run concurrently. Charts render progressively as data arrives. --- @@ -710,13 +791,15 @@ Primary metric values are NOT loaded with iteration details (would add ~7 second ### Current Limitations -- **Phase 3 (Deep Dive):** Time-series line charts are not yet implemented - **Large result sets:** Searching across many months with hundreds of runs can be slow due to sequential OpenSearch queries - **Bundle size:** Recharts adds ~400KB to the bundle. Code splitting could help. - **Breakout label parsing:** CDM may omit breakout dimensions with single values from labels, making label-to-dimension mapping imperfect. The sidebar uses segment-based grouping to work around this. +- **Deep dive color differentiation:** With many breakout labels, shades within an iteration's color theme can be hard to distinguish ### Planned Features -- **Deep Dive view:** Time-series line charts with zoom/pan and interactive breakout exploration +- **Deep dive series filtering:** Click-to-hide individual series or groups in the legend +- **Deep dive breakout controls:** Add/remove breakouts directly in deep dive view +- **"Other" aggregate series:** For filtered-out labels, show a single aggregated line - **Save/load workflows:** Server-side or localStorage persistence of named workflows - **Drag-to-reorder:** Group-by chips currently use arrow buttons; drag-and-drop would be more intuitive diff --git a/queries/cdmq/web-ui/src/App.jsx b/queries/cdmq/web-ui/src/App.jsx index 374bc934..013267b8 100644 --- a/queries/cdmq/web-ui/src/App.jsx +++ b/queries/cdmq/web-ui/src/App.jsx @@ -69,6 +69,7 @@ export default function App() { const restoredState = useRef(null); const [restoredMetrics, setRestoredMetrics] = useState(null); const [deepDiveMetrics, setDeepDiveMetrics] = useState(new Set()); // Set of "source::type" strings + const [deepDiveIterations, setDeepDiveIterations] = useState(new Set()); // Set of iterationId strings (max 6) const [deepDiveConfigs, setDeepDiveConfigs] = useState([]); // snapshot of supplemental metrics for deep dive // On mount, check for state in URL hash @@ -211,15 +212,14 @@ export default function App() { @@ -255,11 +255,11 @@ export default function App() { )} {view === 'compare' && ( - + )} {view === 'deepdive' && ( - + { var m = new Map(); selected.forEach(function (it, id) { if (deepDiveIterations.has(id)) m.set(id, it); }); return m; })()} deepDiveMetrics={deepDiveMetrics} metricConfigs={deepDiveConfigs} hiddenFields={hiddenFields} /> )} diff --git a/queries/cdmq/web-ui/src/components/CompareView.jsx b/queries/cdmq/web-ui/src/components/CompareView.jsx index c3765f71..5a705984 100644 --- a/queries/cdmq/web-ui/src/components/CompareView.jsx +++ b/queries/cdmq/web-ui/src/components/CompareView.jsx @@ -1,4 +1,5 @@ import React, { useState, useEffect, useMemo, useCallback, useRef, useImperativeHandle, forwardRef } from 'react'; +import { buildIterItems } from '../utils/iterLabel'; import { ComposedChart, Bar, Line, XAxis, YAxis, CartesianGrid, Tooltip, ErrorBar, ResponsiveContainer, Legend, Cell, ReferenceLine, LabelList } from 'recharts'; import * as api from '../api/cdm'; import { timeWork } from '../debugLog'; @@ -10,6 +11,8 @@ const COLORS = [ ]; const SUPP_COLORS = ['#f97316', '#e879f9', '#14b8a6', '#ef4444', '#8b5cf6', '#06b6d4']; +const ITER_THEME_BASES = ['#5b8def', '#ef5b5b', '#5bef8d', '#b85bef', '#5bcdef', '#efb85b']; +const MAX_DEEP_DIVE_ITERS = 6; // Smart Y-axis tick formatter: adjusts decimal precision based on value magnitude function formatYTick(value) { @@ -108,21 +111,21 @@ function computeCommonVarying(iters, hiddenSet) { // Benchmark if (benchmarks.size === 1) { - common.push({ key: 'benchmark', val: Array.from(benchmarks)[0] }); + common.push({ key: 'benchmark', val: Array.from(benchmarks)[0], type: 'benchmark' }); } else if (benchmarks.size > 1) { varyingKeys.add('benchmark'); } Object.keys(paramValues).sort().forEach(function (arg) { if (paramValues[arg].size === 1) { - common.push({ key: arg, val: Array.from(paramValues[arg])[0] }); + common.push({ key: arg, val: Array.from(paramValues[arg])[0], type: 'param' }); } else { varyingKeys.add('param:' + arg); } }); Object.keys(tagValues).sort().forEach(function (name) { if (tagValues[name].size === 1) { - common.push({ key: name, val: Array.from(tagValues[name])[0] }); + common.push({ key: name, val: Array.from(tagValues[name])[0], type: 'tag' }); } else { varyingKeys.add('tag:' + name); } @@ -495,7 +498,7 @@ function buildDimOptions(iterations) { return opts; } -const CompareView = forwardRef(function CompareView({ selected, groupByList, setGroupByList, hiddenFields, setHiddenFields, restoredMetrics, deepDiveMetrics, setDeepDiveMetrics }, ref) { +const CompareView = forwardRef(function CompareView({ selected, groupByList, setGroupByList, hiddenFields, setHiddenFields, restoredMetrics, deepDiveMetrics, setDeepDiveMetrics, deepDiveIterations, setDeepDiveIterations }, ref) { var [metricValues, setMetricValues] = useState({}); var [loading, setLoading] = useState(false); var [supplementalMetrics, setSupplementalMetrics] = useState([]); // [{ source, type, values: {iterId: {mean,...}} }] @@ -1121,7 +1124,7 @@ const CompareView = forwardRef(function CompareView({ selected, groupByList, set } } - result.push({ metricName: metricName, data: chartData, commonItems: commonItems, groupInfo: groupInfo, varyingKeys: varyingKeys }); + result.push({ metricName: metricName, data: chartData, commonItems: commonItems, groupInfo: groupInfo, varyingKeys: varyingKeys, sortedIterations: sorted }); }); return result; @@ -1394,9 +1397,54 @@ const CompareView = forwardRef(function CompareView({ selected, groupByList, set return (

{chart.metricName}

+ {/* Deep dive selected iterations — display only, selection happens on bar chart */} + {deepDiveIterations && ci === 0 && deepDiveIterations.size > 0 && ( +
+
+ Deep Dive Iterations ({deepDiveIterations.size}): + {deepDiveIterations.size >= MAX_DEEP_DIVE_ITERS && ( + Max {MAX_DEEP_DIVE_ITERS} reached + )} +
+
+ {Array.from(deepDiveIterations).map(function (itId, ii) { + var it = iterations.find(function (iter) { return iter.iterationId === itId; }); + if (!it) return null; + var themeColor = ITER_THEME_BASES[ii % ITER_THEME_BASES.length]; + var iterItems = buildIterItems(it, iterations, hiddenFields); + return ( +
+ {iterItems.length > 0 ? iterItems.map(function (item, pi) { + var label = item.type === 'benchmark' ? item.val : item.names.join(',') + '=' + item.val; + return ( + + {item.type === 'tag' && {item.names.join(',')}} + {item.type === 'tag' ? '=' + item.val : label} + + ); + }) : {it.iterationId.substring(0, 8)}} + +
+ ); + })} +
+
+ )} + {chart.commonItems.length > 0 && (
- {chart.commonItems.map(function (c) { return c.key + '=' + c.val; }).join(', ')} + Common: + {chart.commonItems.map(function (c, ci) { + return ( + + {c.type === 'tag' && {c.key}} + {c.type === 'tag' ? '=' + c.val : c.type === 'benchmark' ? c.val : c.key + '=' + c.val} + + ); + })}
)} @@ -1698,6 +1746,40 @@ const CompareView = forwardRef(function CompareView({ selected, groupByList, set ); }); })()} + {/* Deep dive iteration selection row — aligned with bars */} + {deepDiveIterations && (function () { + var nonGaps = chart.data.filter(function (d) { return !d.isGap; }); + var atLimit = deepDiveIterations.size >= MAX_DEEP_DIVE_ITERS; + return ( +
+
+
+ {chart.data.map(function (d, di) { + if (d.isGap) return
; + var isSelected = deepDiveIterations.has(d.iterationId); + var selArr = Array.from(deepDiveIterations); + var themeIdx = isSelected ? selArr.indexOf(d.iterationId) : -1; + var themeColor = themeIdx >= 0 ? ITER_THEME_BASES[themeIdx % ITER_THEME_BASES.length] : null; + return ( +
+ ); + })} +
+
← select up to 6 for deep dive
+
+ ); + })()} diff --git a/queries/cdmq/web-ui/src/components/DeepDiveView.jsx b/queries/cdmq/web-ui/src/components/DeepDiveView.jsx index 9ea41956..194c9c1a 100644 --- a/queries/cdmq/web-ui/src/components/DeepDiveView.jsx +++ b/queries/cdmq/web-ui/src/components/DeepDiveView.jsx @@ -1,14 +1,31 @@ import { useState, useEffect, useMemo, useRef, useCallback } from 'react'; -import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ReferenceLine } from 'recharts'; +import { ComposedChart, Line, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ReferenceLine, ReferenceArea } from 'recharts'; import * as api from '../api/cdm'; import { timeWork } from '../debugLog'; - -var COLORS = [ - '#5b8def', '#ef5b5b', '#5bef8d', '#efb85b', '#b85bef', - '#5bcdef', '#ef5bcd', '#8def5b', '#cd5bef', '#ef8d5b', - '#5b5bef', '#5bef5b', '#ef5b8d', '#8d5bef', '#5befcd', +import { buildIterItems, iterItemsToString } from '../utils/iterLabel'; + +// Per-iteration color themes: each iteration gets a hue family +// Within each family, brightness/saturation varies for different labels +var ITER_THEMES = [ + // Blues + { base: '#5b8def', shades: ['#3a6fd4', '#5b8def', '#7ca4f5', '#9dbcfa', '#bdd4ff', '#4a7de0', '#6991e8', '#89a8f0', '#a9bff8', '#c9d7ff'] }, + // Reds/oranges + { base: '#ef5b5b', shades: ['#d43a3a', '#ef5b5b', '#f57c7c', '#fa9d9d', '#ffbdbd', '#e04a4a', '#e86969', '#f08989', '#f8a9a9', '#ffc9c9'] }, + // Greens + { base: '#5bef8d', shades: ['#3ad46a', '#5bef8d', '#7cf5a4', '#9dfabc', '#bdffd4', '#4ae07a', '#69e891', '#89f0a8', '#a9f8bf', '#c9ffd7'] }, + // Purples + { base: '#b85bef', shades: ['#9a3ad4', '#b85bef', '#cc7cf5', '#dd9dfa', '#eebdff', '#a84ae0', '#bc69e8', '#cf89f0', '#e0a9f8', '#f0c9ff'] }, + // Teals + { base: '#5bcdef', shades: ['#3ab4d4', '#5bcdef', '#7cd8f5', '#9de4fa', '#bdf0ff', '#4ac2e0', '#69cce8', '#89d6f0', '#a9e0f8', '#c9ebff'] }, + // Ambers + { base: '#efb85b', shades: ['#d49a3a', '#efb85b', '#f5cc7c', '#fadd9d', '#ffeebf', '#e0a84a', '#e8bc69', '#f0cf89', '#f8e0a9', '#fff0c9'] }, ]; +function getIterColor(iterIdx, labelIdx) { + var theme = ITER_THEMES[iterIdx % ITER_THEMES.length]; + return theme.shades[labelIdx % theme.shades.length]; +} + function formatValue(v) { if (v == null) return ''; v = Number(v); @@ -26,34 +43,19 @@ function formatElapsed(ms) { return (sec / 3600).toFixed(1) + 'h'; } -// Build a short label for an iteration from its varying params -function buildIterShortLabel(it, allIterations) { - if (!it) return ''; - // Compute varying params across all iterations - var paramValues = {}; - var tagValues = {}; - allIterations.forEach(function (iter) { - (iter.params || []).forEach(function (p) { - if (!paramValues[p.arg]) paramValues[p.arg] = new Set(); - paramValues[p.arg].add(String(p.val)); - }); - (iter.tags || []).forEach(function (t) { - if (!tagValues[t.name]) tagValues[t.name] = new Set(); - tagValues[t.name].add(t.val); - }); - }); - var parts = []; - (it.params || []).forEach(function (p) { - if (paramValues[p.arg] && paramValues[p.arg].size > 1) { - parts.push(p.arg + '=' + p.val); - } - }); - (it.tags || []).forEach(function (t) { - if (tagValues[t.name] && tagValues[t.name].size > 1) { - parts.push(t.name + '=' + t.val); - } +// Render iteration items as box-in-box chips +function renderIterChips(it, allIterations, hiddenFields) { + var items = buildIterItems(it, allIterations, hiddenFields); + if (items.length === 0) return it.iterationId.substring(0, 8); + return items.map(function (item, i) { + var label = item.type === 'benchmark' ? item.val : item.names.join(',') + '=' + item.val; + return ( + + {item.type === 'tag' && {item.names.join(',')}} + {item.type === 'tag' ? '=' + item.val : label} + + ); }); - return parts.join(', ') || it.iterationId.substring(0, 8); } // Parse breakout label like "-<0>" into segments ["host1", "0"] @@ -74,14 +76,39 @@ function naturalCompare(a, b) { return 0; } -export default function DeepDiveView({ selected, deepDiveMetrics, metricConfigs: metricConfigsProp }) { +export default function DeepDiveView({ selected, deepDiveMetrics, metricConfigs: metricConfigsProp, hiddenFields }) { var [resolution, setResolution] = useState(100); var [periodInfo, setPeriodInfo] = useState(null); var [metricData, setMetricData] = useState({}); var [loadingPeriods, setLoadingPeriods] = useState(false); var [loadingMetrics, setLoadingMetrics] = useState(new Set()); - var [pinnedElapsed, setPinnedElapsed] = useState(null); // elapsed ms value for pinned (locked) time - var [hoverElapsed, setHoverElapsed] = useState(null); // elapsed ms value for live hover time + var [pinnedElapsed, setPinnedElapsed] = useState(null); + var [hoverElapsed, setHoverElapsed] = useState(null); + var initialPinSet = useRef(false); + + // Auto-set hover position to 50% when first data arrives + useEffect(function () { + if (initialPinSet.current) return; + if (Object.keys(metricData).length === 0) return; + // Find the first metric with data to compute the midpoint + for (var key in metricData) { + var metricResults = metricData[key]; + for (var itId in metricResults) { + var result = metricResults[itId]; + if (result && result.periodBegin && result.periodEnd) { + var duration = Number(result.periodEnd) - Number(result.periodBegin); + setHoverElapsed(Math.round(duration / 2)); + initialPinSet.current = true; + return; + } + } + } + }, [metricData]); + var [perMetricOpts, setPerMetricOpts] = useState({}); + // Zoom: percentage of total elapsed time range (0.0 to 1.0) + var [zoomRange, setZoomRange] = useState(null); // null = full range, { startPct, endPct } + var [brushStart, setBrushStart] = useState(null); // elapsed time of brush start (during drag) + var [brushEnd, setBrushEnd] = useState(null); // elapsed time of brush end (during drag) var abortRef = useRef(false); var iterations = useMemo(function () { @@ -110,6 +137,7 @@ export default function DeepDiveView({ selected, deepDiveMetrics, metricConfigs: useEffect(function () { if (iterations.length === 0 || metricList.length === 0) return; abortRef.current = false; + initialPinSet.current = false; setLoadingPeriods(true); setMetricData({}); @@ -158,8 +186,19 @@ export default function DeepDiveView({ selected, deepDiveMetrics, metricConfigs: // Fetch all iterations for this metric concurrently, then wait for all to complete var promises = iterations.map(function (it) { var pi = periods[it.iterationId]; - if (!pi) return Promise.resolve(); var loadKey = metricKey + '::' + it.iterationId; + if (!pi) { + setLoadingMetrics(function (prev) { var next = new Set(prev); next.delete(loadKey); return next; }); + return Promise.resolve(); + } + + var queryBegin = Number(pi.begin); + var queryEnd = Number(pi.end); + var periodDuration = queryEnd - queryBegin; + if (zoomRange) { + queryBegin = Number(pi.begin) + Math.round(periodDuration * zoomRange.startPct); + queryEnd = Number(pi.begin) + Math.round(periodDuration * zoomRange.endPct); + } return timeWork('Deep dive ' + source + '::' + type + ' ' + it.iterationId.substring(0, 8), function () { return api.getMetricData({ @@ -167,8 +206,8 @@ export default function DeepDiveView({ selected, deepDiveMetrics, metricConfigs: period: pi.periodId, source: source, type: type, - begin: pi.begin, - end: pi.end, + begin: String(queryBegin), + end: String(queryEnd), resolution: resolution, breakout: breakouts, filter: config.filter || null, @@ -180,8 +219,8 @@ export default function DeepDiveView({ selected, deepDiveMetrics, metricConfigs: if (!next[metricKey]) next[metricKey] = {}; next[metricKey][it.iterationId] = { values: data.values || {}, - periodBegin: pi.begin, - periodEnd: pi.end, + periodBegin: String(queryBegin), + periodEnd: String(queryEnd), }; return next; }); @@ -202,7 +241,7 @@ export default function DeepDiveView({ selected, deepDiveMetrics, metricConfigs: }); return function () { abortRef.current = true; }; - }, [iterations.length, metricList.join(','), resolution]); + }, [iterations.length, metricList.join(','), resolution, zoomRange]); if (loadingPeriods) { return ( @@ -231,6 +270,12 @@ export default function DeepDiveView({ selected, deepDiveMetrics, metricConfigs: /> data points + {zoomRange && ( + + )} + {zoomRange ? 'Click + drag to zoom further' : 'Click + drag on chart to zoom'} {loadingMetrics.size > 0 && ( @@ -239,91 +284,176 @@ export default function DeepDiveView({ selected, deepDiveMetrics, metricConfigs: )}
+ {/* Iteration context: common properties and per-iteration identity */} + {(function () { + var hiddenSet = hiddenFields ? new Set(hiddenFields) : new Set(); + // Compute common vs varying + var paramValues = {}; + var tagValues = {}; + var benchmarks = new Set(); + iterations.forEach(function (it) { + if (it.benchmark) benchmarks.add(it.benchmark); + (it.params || []).forEach(function (p) { + if (!paramValues[p.arg]) paramValues[p.arg] = new Set(); + paramValues[p.arg].add(String(p.val)); + }); + (it.tags || []).forEach(function (t) { + if (!tagValues[t.name]) tagValues[t.name] = new Set(); + tagValues[t.name].add(t.val); + }); + }); + var commonItems = []; + if (benchmarks.size === 1) commonItems.push({ key: 'benchmark', val: Array.from(benchmarks)[0], type: 'benchmark' }); + Object.keys(paramValues).sort().forEach(function (arg) { + if (paramValues[arg].size === 1 && !hiddenSet.has('param:' + arg)) { + commonItems.push({ key: arg, val: Array.from(paramValues[arg])[0], type: 'param' }); + } + }); + Object.keys(tagValues).sort().forEach(function (name) { + if (tagValues[name].size === 1 && !hiddenSet.has('tag:' + name)) { + commonItems.push({ key: name, val: Array.from(tagValues[name])[0], type: 'tag' }); + } + }); + + return ( +
+ {commonItems.length > 0 && ( +
+ Common ({commonItems.length}) +
+ {commonItems.map(function (p, i) { + return ( + + {p.type === 'tag' && {p.key}} + {p.type === 'tag' ? '=' + p.val : p.type === 'benchmark' ? p.val : p.key + '=' + p.val} + + ); + })} + + bench + tag + param + +
+
+ )} +
+
+ Iterations: + {iterations.map(function (it, ii) { + var theme = ITER_THEMES[ii % ITER_THEMES.length]; + return ( +
+ {renderIterChips(it, iterations, hiddenFields)} +
+ ); + })} +
+
+ ); + })()} + {metricList.map(function (metricKey, mi) { var parts = metricKey.split('::'); var source = parts[0]; var type = parts[1]; var metricResults = metricData[metricKey] || {}; - // Build chart data: merge all iterations into a unified elapsed-time dataset - // With breakouts, each breakout label becomes a separate line per iteration - var allPoints = []; // { elapsed, lineKey, value } - var lineKeys = []; // { key, label (display), iterationId } - var lineColors = {}; - var colorIdx = 0; - - iterations.forEach(function (it) { + // Build per-iteration info + var iterInfo = []; + iterations.forEach(function (it, ii) { var result = metricResults[it.iterationId]; if (!result || !result.values) return; - var iterLabel = buildIterShortLabel(it, iterations); - - var periodBegin = Number(result.periodBegin); - var labelKeys = Object.keys(result.values); - - labelKeys.forEach(function (lk) { - var entries = result.values[lk]; - if (!Array.isArray(entries)) return; - // Build a unique line key: "iterLabel" or "iterLabel " if breakouts - var lineKey = labelKeys.length > 1 ? iterLabel + ' ' + lk : iterLabel; - var displayLabel = lineKey; - if (!lineKeys.find(function (l) { return l.key === lineKey; })) { - lineKeys.push({ key: lineKey, label: displayLabel, iterationId: it.iterationId }); - lineColors[lineKey] = COLORS[colorIdx % COLORS.length]; - colorIdx++; - } - entries.forEach(function (entry) { - var elapsed = (Number(entry.begin) + Number(entry.end)) / 2 - periodBegin; - allPoints.push({ elapsed: elapsed, lineKey: lineKey, value: entry.value }); - }); + iterInfo.push({ + iterIdx: ii, + iterationId: it.iterationId, + iterLabel: iterItemsToString(buildIterItems(it, iterations, hiddenFields)) || it.iterationId.substring(0, 8), + periodBegin: Number(result.periodBegin), + periodEnd: Number(result.periodEnd), + labelKeys: Object.keys(result.values), + values: result.values, }); }); - // Build unified time axis - var timeSet = new Set(); - allPoints.forEach(function (p) { timeSet.add(p.elapsed); }); - var times = Array.from(timeSet).sort(function (a, b) { return a - b; }); - - // Build chart data array - var chartData = times.map(function (t) { - return { elapsed: t }; + // Build unified breakout label set (union across all iterations) + var allBreakoutLabels = new Set(); + iterInfo.forEach(function (info) { + info.labelKeys.forEach(function (lk) { allBreakoutLabels.add(lk); }); + }); + var sortedLabels = Array.from(allBreakoutLabels).sort(function (a, b) { + var sa = parseSegments(a); + var sb = parseSegments(b); + for (var i = 0; i < Math.max(sa.length, sb.length); i++) { + var cmp = naturalCompare(sa[i] || '', sb[i] || ''); + if (cmp !== 0) return cmp; + } + return 0; }); - // Index for fast lookup - var timeIndex = {}; - times.forEach(function (t, i) { timeIndex[t] = i; }); + // Build chart data and line keys with per-iteration color themes. + // Use sample index as X coordinate so all iterations align perfectly. + // The metric data is continuous — each series has exactly N samples + // covering the full period with no gaps. Using the sample index (0..N-1) + // ensures every series has a value at every X position. + var lineKeys = []; + var lineColors = {}; - // Fill in values per line - allPoints.forEach(function (p) { - var idx = timeIndex[p.elapsed]; - if (idx != null) { - chartData[idx][p.lineKey] = p.value; - } + // Find the longest period duration (for X-axis labels in elapsed time) + var maxDuration = 0; + var maxSamples = 0; + iterInfo.forEach(function (info) { + var dur = info.periodEnd - info.periodBegin; + if (dur > maxDuration) maxDuration = dur; }); - var hasData = lineKeys.length > 0 && chartData.length > 0; + // First pass: determine max sample count and build line keys + iterInfo.forEach(function (info) { + sortedLabels.forEach(function (lk, labelIdx) { + var entries = info.values[lk]; + if (!entries || !Array.isArray(entries)) return; + if (entries.length > maxSamples) maxSamples = entries.length; + var lineKey = 'iter' + info.iterIdx + ':' + lk; + var color = getIterColor(info.iterIdx, labelIdx); + lineKeys.push({ key: lineKey, iterIdx: info.iterIdx, iterationId: info.iterationId, breakoutLabel: lk }); + lineColors[lineKey] = color; + }); + }); - // Build legend data: group by iteration, then by breakout segments - var legendByIter = {}; - lineKeys.forEach(function (lk) { - var itId = lk.iterationId; - if (!legendByIter[itId]) legendByIter[itId] = { iterLabel: '', items: [] }; - // Extract breakout label from key (key = "iterLabel" or "iterLabel ") - var iterLabel = buildIterShortLabel(iterations.find(function (it) { return it.iterationId === itId; }), iterations); - legendByIter[itId].iterLabel = iterLabel; - var breakoutPart = lk.key.substring(iterLabel.length).trim(); - var segments = breakoutPart ? parseSegments(breakoutPart) : []; - legendByIter[itId].items.push({ key: lk.key, segments: segments, color: lineColors[lk.key] }); + // Build chart data array: one entry per sample index + var chartData = []; + for (var si = 0; si < maxSamples; si++) { + // Convert sample index to elapsed time using the longest period + var elapsed = maxDuration > 0 && maxSamples > 0 ? Math.round(maxDuration * (si + 0.5) / maxSamples) : si; + chartData.push({ elapsed: elapsed, _sampleIdx: si }); + } + + // Fill in values: each iteration's entries are indexed by sample position + iterInfo.forEach(function (info) { + var periodDuration = info.periodEnd - info.periodBegin; + sortedLabels.forEach(function (lk) { + var entries = info.values[lk]; + if (!entries || !Array.isArray(entries)) return; + var lineKey = 'iter' + info.iterIdx + ':' + lk; + // Sort entries by begin time to ensure correct order + var sorted = entries.slice().sort(function (a, b) { return Number(a.begin) - Number(b.begin); }); + sorted.forEach(function (entry, ei) { + if (ei < chartData.length) { + chartData[ei][lineKey] = entry.value; + } + }); + }); }); - // Sort items within each iteration by segments - Object.values(legendByIter).forEach(function (group) { - group.items.sort(function (a, b) { - for (var i = 0; i < Math.max(a.segments.length, b.segments.length); i++) { - var cmp = naturalCompare(a.segments[i] || '', b.segments[i] || ''); - if (cmp !== 0) return cmp; - } - return 0; + var hasData = lineKeys.length > 0 && chartData.length > 0; + + // Build unified legend rows: one row per breakout label, columns per iteration + var legendRows = sortedLabels.map(function (lk, labelIdx) { + var segments = lk ? parseSegments(lk) : []; + var iterCells = iterInfo.map(function (info) { + var lineKey = 'iter' + info.iterIdx + ':' + lk; + return { lineKey: lineKey, color: getIterColor(info.iterIdx, labelIdx), hasData: !!info.values[lk] }; }); + return { breakoutLabel: lk, segments: segments, iterCells: iterCells }; }); // Get breakout dimension names from config @@ -333,6 +463,57 @@ export default function DeepDiveView({ selected, deepDiveMetrics, metricConfigs: return eqIdx >= 0 ? b.substring(0, eqIdx) : b; }); + // Compute segment column stripping and rowSpans + var numCols = legendRows.length > 0 && legendRows[0].segments.length > 0 ? legendRows[0].segments.length : 0; + var colStripped = []; + for (var col = 0; col < numCols; col++) { + var vals = legendRows.map(function (r) { return r.segments[col] || ''; }); + var unique = Array.from(new Set(vals)); + var stripped = vals; + if (unique.length > 1) { + var suffix = ''; + var first = unique[0]; + for (var si2 = first.length - 1; si2 > 0; si2--) { + var ch = first[si2]; + if (ch === '.' || ch === '-' || ch === '_') { + var candidate = first.substring(si2); + if (unique.every(function (v) { return v.endsWith(candidate); })) suffix = candidate; + } + } + if (suffix) { + stripped = vals.map(function (v) { return v.substring(0, v.length - suffix.length); }); + } else { + var prefix = ''; + for (var pi = 0; pi < first.length - 1; pi++) { + if (first[pi] === '.' || first[pi] === '-' || first[pi] === '_') { + var pcandidate = first.substring(0, pi + 1); + if (unique.every(function (v) { return v.startsWith(pcandidate); })) prefix = pcandidate; + } + } + if (prefix) stripped = vals.map(function (v) { return v.substring(prefix.length); }); + } + } + colStripped.push(stripped); + } + + var rowSpans = legendRows.map(function () { return new Array(numCols).fill(1); }); + for (var col2 = 0; col2 < numCols; col2++) { + var spanStart = 0; + for (var row = 1; row <= legendRows.length; row++) { + var same = row < legendRows.length; + if (same) { + for (var c = 0; c <= col2; c++) { + if ((colStripped[c] ? colStripped[c][row] : '') !== (colStripped[c] ? colStripped[c][spanStart] : '')) { same = false; break; } + } + } + if (!same) { + rowSpans[spanStart][col2] = row - spanStart; + for (var r = spanStart + 1; r < row; r++) rowSpans[r][col2] = 0; + spanStart = row; + } + } + } + // Get active entry: find nearest data point to the shared elapsed time var activeElapsed = pinnedElapsed != null ? pinnedElapsed : hoverElapsed; var isPinned = pinnedElapsed != null; @@ -349,9 +530,36 @@ export default function DeepDiveView({ selected, deepDiveMetrics, metricConfigs: activeEntry = chartData[bestIdx]; } + var opts = perMetricOpts[metricKey] || {}; + var splitByIter = !!opts.split; + var chartType = opts.chartType || 'line'; + return (
-

{source}::{type}

+
+

{source}::{type}

+ {hasData && iterInfo.length > 1 && ( +
+ + + {splitByIter && ( + <> + | + + + + )} +
+ )} +
{!hasData && (
{loadingMetrics.size > 0 ? ( @@ -361,70 +569,154 @@ export default function DeepDiveView({ selected, deepDiveMetrics, metricConfigs: )} {hasData && ( <> - - 0) { + var bi = 0; + var bd = Math.abs(data[0].elapsed - activeElapsed); + for (var j = 1; j < data.length; j++) { + var d = Math.abs(data[j].elapsed - activeElapsed); + if (d < bd) { bd = d; bi = j; } + if (data[j].elapsed > activeElapsed) break; } - }} - onMouseLeave={function () { - // Keep last hovered values visible when pointer leaves - }} - onClick={function (e) { - if (e && e.activeTooltipIndex != null) { - var entry = chartData[e.activeTooltipIndex]; - if (entry) { - var clickedElapsed = entry.elapsed; - setPinnedElapsed(function (prev) { - if (prev != null) { - setHoverElapsed(clickedElapsed); - return null; - } - return clickedElapsed; + thisActiveEntry = data[bi]; + } + return ( +
+ {label &&
{label}
} + + 0) { + var left = Math.min(brushStart, brushEnd); + var right = Math.max(brushStart, brushEnd); + // If already zoomed, compose with existing zoom + var basePct = zoomRange || { startPct: 0, endPct: 1 }; + var baseRange = basePct.endPct - basePct.startPct; + var newStartPct = basePct.startPct + baseRange * ((left - minElapsed) / totalRange); + var newEndPct = basePct.startPct + baseRange * ((right - minElapsed) / totalRange); + setZoomRange({ startPct: newStartPct, endPct: newEndPct }); + setPinnedElapsed(null); + setHoverElapsed(null); + } + } else if (brushStart != null && (brushEnd == null || brushStart === brushEnd)) { + // Click without drag — pin/unpin + var ce = brushStart; + setPinnedElapsed(function (prev) { + if (prev != null) { setHoverElapsed(ce); return null; } + return ce; + }); + } + setBrushStart(null); + setBrushEnd(null); + }} + onMouseLeave={function () { + setBrushStart(null); + setBrushEnd(null); + }} + > + + + + ; }} + cursor={{ stroke: 'var(--text-muted)', strokeWidth: 1, strokeDasharray: '3 3' }} /> + {thisActiveEntry && !brushStart && ( + + )} + {brushStart != null && brushEnd != null && ( + + )} + {lines.map(function (lk) { + if (useStacked) { + return ( + + ); + } + return ( + + ); + })} + + +
+ ); + } + + if (!splitByIter) { + return
{renderOneChart(chartData, lineKeys, null, 300, false, null)}
; + } else { + // Compute global Y-axis domain across all iterations for consistent scale + var globalMax = 0; + chartData.forEach(function (entry) { + lineKeys.forEach(function (lk) { + if (entry[lk.key] != null && entry[lk.key] > globalMax) globalMax = entry[lk.key]; + }); + // For stacked mode, compute sum per time point per iteration + if (chartType === 'stacked') { + iterInfo.forEach(function (info) { + var sum = 0; + lineKeys.forEach(function (lk) { + if (lk.iterIdx === info.iterIdx && entry[lk.key] != null) sum += entry[lk.key]; }); - } + if (sum > globalMax) globalMax = sum; + }); } - }} - > - - - - ; }} - cursor={{ stroke: 'var(--text-muted)', strokeWidth: 1, strokeDasharray: '3 3' }} - /> - {activeEntry && ( - - )} - {lineKeys.map(function (lk) { - return ( - - ); - })} -
-
- - {/* Series legend table */} + }); + var yDomain = [0, globalMax * 1.05]; + + return iterInfo.map(function (info) { + var iterLines = lineKeys.filter(function (lk) { return lk.iterIdx === info.iterIdx; }); + if (iterLines.length === 0) return null; + var iterData = chartData.map(function (entry) { + var d = { elapsed: entry.elapsed }; + iterLines.forEach(function (lk) { + if (entry[lk.key] != null) d[lk.key] = entry[lk.key]; + }); + return d; + }).filter(function (d) { + return iterLines.some(function (lk) { return d[lk.key] != null; }); + }); + return renderOneChart(iterData, iterLines, info.iterLabel, 200, chartType === 'stacked', yDomain); + }); + } + })()} + + {/* Unified series legend table — one row per breakout label, columns per iteration */}
{activeEntry ? ( @@ -437,116 +729,51 @@ export default function DeepDiveView({ selected, deepDiveMetrics, metricConfigs: )}
- {Object.keys(legendByIter).map(function (itId) { - var group = legendByIter[itId]; - var items = group.items; - var numCols = items.length > 0 && items[0].segments.length > 0 ? items[0].segments.length : 0; - - // Strip common prefix/suffix from each segment column - var colStripped = []; - for (var col = 0; col < numCols; col++) { - var vals = items.map(function (it) { return it.segments[col] || ''; }); - var unique = Array.from(new Set(vals)); - var stripped = vals; - if (unique.length > 1) { - // Find common suffix (split at delimiter boundaries) - var suffix = ''; - var first = unique[0]; - for (var si2 = first.length - 1; si2 > 0; si2--) { - var ch = first[si2]; - if (ch === '.' || ch === '-' || ch === '_') { - var candidate = first.substring(si2); - if (unique.every(function (v) { return v.endsWith(candidate); })) { - suffix = candidate; - } - } - } - if (suffix) { - stripped = vals.map(function (v) { return v.substring(0, v.length - suffix.length); }); - } else { - // Try common prefix - var prefix = ''; - for (var pi = 0; pi < first.length - 1; pi++) { - var pch = first[pi]; - if (pch === '.' || pch === '-' || pch === '_') { - var pcandidate = first.substring(0, pi + 1); - if (unique.every(function (v) { return v.startsWith(pcandidate); })) { - prefix = pcandidate; +
{name}Series Value
1 ? span : undefined}> + {span > 1 ? ( +
{colStripped[ci] ? colStripped[ci][ri] : item.segments[ci]}
+ ) : (colStripped[ci] ? colStripped[ci][ri] : item.segments[ci])} +
-{seg}-{value != null ? formatValue(value) : '-'}
+ {breakoutNames.length > 0 && ( + + + {breakoutNames.map(function (name, ni) { + return ; + })} + {iterInfo.map(function (info) { + return ; + })} + + + )} + + {legendRows.map(function (row, ri) { + return ( + + {numCols > 0 ? (function () { + var cells = []; + for (var ci = 0; ci < numCols; ci++) { + if (rowSpans[ri][ci] > 0) { + var span = rowSpans[ri][ci]; + cells.push( + + ); + } } - } - } - if (prefix) { - stripped = vals.map(function (v) { return v.substring(prefix.length); }); - } - } - } - colStripped.push(stripped); - } - - // Compute rowSpans for hierarchical grouping - var rowSpans = items.map(function () { return new Array(numCols).fill(1); }); - for (var col2 = 0; col2 < numCols; col2++) { - var spanStart = 0; - for (var row = 1; row <= items.length; row++) { - var same = row < items.length; - if (same) { - for (var c = 0; c <= col2; c++) { - if ((colStripped[c] ? colStripped[c][row] : '') !== (colStripped[c] ? colStripped[c][spanStart] : '')) { same = false; break; } - } - } - if (!same) { - rowSpans[spanStart][col2] = row - spanStart; - for (var r = spanStart + 1; r < row; r++) rowSpans[r][col2] = 0; - spanStart = row; - } - } - } - - return ( -
-
{group.iterLabel}
-
{name}
1 ? span : undefined}> + {span > 1 ? ( +
{colStripped[ci] ? colStripped[ci][ri] : row.segments[ci]}
+ ) : (colStripped[ci] ? colStripped[ci][ri] : row.segments[ci])} +
- - - {breakoutNames.map(function (name, ni) { - return ; - })} - {breakoutNames.length === 0 && } - - - - - - {items.map(function (item, ri) { - var value = activeEntry ? activeEntry[item.key] : null; - return ( - - {numCols > 0 ? (function () { - var cells = []; - for (var ci = 0; ci < numCols; ci++) { - if (rowSpans[ri][ci] > 0) { - var span = rowSpans[ri][ci]; - cells.push( - - ); - } - } - return cells; - })() : } - - - - ); + return cells; + })() : } + {row.iterCells.map(function (cell) { + var value = activeEntry ? activeEntry[cell.lineKey] : null; + return [ + , + + ]; })} - -
{name}SeriesValue
1 ? span : undefined}> - {span > 1 ? ( -
{colStripped[ci] ? colStripped[ci][ri] : item.segments[ci]}
- ) : (colStripped[ci] ? colStripped[ci][ri] : item.segments[ci])} -
-{value != null ? formatValue(value) : '-'}
-{cell.hasData ? (value != null ? formatValue(value) : '-') : ''}{cell.hasData && }
-
- ); - })} + + ); + })} + +
diff --git a/queries/cdmq/web-ui/src/components/IterationTable.jsx b/queries/cdmq/web-ui/src/components/IterationTable.jsx index fb293531..3ab15aeb 100644 --- a/queries/cdmq/web-ui/src/components/IterationTable.jsx +++ b/queries/cdmq/web-ui/src/components/IterationTable.jsx @@ -589,21 +589,23 @@ export default function IterationTable({ iterations, selected, onToggleSelect, o {(commonList.length > 0 || hiddenDims.length > 0) && (
{commonList.length > 0 && ( - <> - Common: - {commonList.map(function (p, i) { - return ( - - {p.type === 'tag' && {p.key}} - {p.type === 'tag' ? '=' + p.val : p.type === 'benchmark' ? p.val : p.key + '=' + p.val} - - ); - })} - +
+ Common ({commonList.length}) +
+ {commonList.map(function (p, i) { + return ( + + {p.type === 'tag' && {p.key}} + {p.type === 'tag' ? '=' + p.val : p.type === 'benchmark' ? p.val : p.key + '=' + p.val} + + ); + })} +
+
)} {hiddenDims.length > 0 && ( - <> - 0 ? { marginLeft: 16 } : undefined}>Hidden: +
+ Hidden: {hiddenDims.map(function (dim) { return ( @@ -611,7 +613,7 @@ export default function IterationTable({ iterations, selected, onToggleSelect, o ); })} - +
)}
)} diff --git a/queries/cdmq/web-ui/src/components/SelectionBar.jsx b/queries/cdmq/web-ui/src/components/SelectionBar.jsx index 4b3d1f54..25c13c2d 100644 --- a/queries/cdmq/web-ui/src/components/SelectionBar.jsx +++ b/queries/cdmq/web-ui/src/components/SelectionBar.jsx @@ -1,26 +1,36 @@ +import { buildIterItems } from '../utils/iterLabel'; + export default function SelectionBar({ selected, onRemove, onClear }) { const items = Array.from(selected.values()); return (
- {selected.size} selected +
+ {selected.size} selected + +
- {items.slice(0, 10).map((it) => ( - - {it.benchmark} - {it.uniqueParams.length > 0 && ( - <> ({it.uniqueParams.map((p) => `${p.arg}=${p.val}`).join(', ')}) - )} - - - ))} - {items.length > 10 && +{items.length - 10} more} + {items.slice(0, 10).map(function (it) { + var iterItems = buildIterItems(it, items, null); + return ( +
+ + {iterItems.length > 0 ? iterItems.map(function (item, pi) { + var label = item.type === 'benchmark' ? item.val : item.names.join(',') + '=' + item.val; + return ( + + {item.type === 'tag' && {item.names.join(',')}} + {item.type === 'tag' ? '=' + item.val : label} + + ); + }) : {it.iterationId.substring(0, 8)}} +
+ ); + })} + {items.length > 10 && +{items.length - 10} more}
-
); } diff --git a/queries/cdmq/web-ui/src/index.css b/queries/cdmq/web-ui/src/index.css index 60886e2e..3a39e66a 100644 --- a/queries/cdmq/web-ui/src/index.css +++ b/queries/cdmq/web-ui/src/index.css @@ -350,10 +350,40 @@ body { .results-common { padding: 8px 16px; border-bottom: 1px solid var(--border); +} + +.results-common-details { + margin-bottom: 4px; +} + +.results-common-summary { + font-size: 12px; + font-weight: 600; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; + cursor: pointer; + user-select: none; +} + +.results-common-summary:hover { + color: var(--text); +} + +.results-common-chips { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px; + margin-top: 6px; +} + +.results-common-hidden { display: flex; flex-wrap: wrap; align-items: center; gap: 4px; + margin-top: 4px; } .results-common-label { @@ -578,8 +608,8 @@ body { /* Selection bar */ .selection-bar { display: flex; - align-items: center; - justify-content: space-between; + flex-direction: column; + gap: 8px; padding: 10px 16px; background: var(--accent-light); border: 1px solid var(--accent); @@ -587,6 +617,12 @@ body { margin-bottom: 16px; } +.selection-bar-header { + display: flex; + align-items: center; + justify-content: space-between; +} + .selection-bar .count { font-weight: 600; color: var(--accent); @@ -595,32 +631,57 @@ body { .selection-chips { display: flex; - flex-wrap: wrap; - gap: 4px; - flex: 1; - margin: 0 12px; + gap: 6px; + overflow-x: auto; + padding-bottom: 4px; + scrollbar-width: thin; } -.selection-chip { - display: inline-flex; - align-items: center; - gap: 4px; - padding: 3px 8px; +.selection-chip-card { + display: flex; + flex-direction: column; + gap: 2px; + padding: 4px 6px; background: var(--surface); border: 1px solid var(--accent); - border-radius: 12px; - font-size: 11px; - color: var(--accent); + border-radius: var(--radius-sm); + flex-shrink: 0; } -.selection-chip button { +.selection-chip-param.param, +.selection-chip-param.tag, +.selection-chip-param.benchmark-badge { + white-space: normal; + overflow-wrap: break-word; + word-break: break-word; + max-width: 200px; +} + +.selection-chip-remove { background: none; border: none; cursor: pointer; - font-size: 14px; - color: var(--accent); + font-size: 12px; + color: var(--text-muted); line-height: 1; - padding: 0; + padding: 0 2px; + align-self: flex-end; +} + +.selection-chip-remove:hover { + color: var(--danger); +} + +.selection-chip-id { + font-size: 11px; + font-family: 'SF Mono', ui-monospace, Consolas, monospace; + color: var(--text-muted); +} + +.selection-chip-more { + font-size: 11px; + color: var(--text-muted); + align-self: center; } /* Loading & errors */ @@ -843,15 +904,183 @@ a.run-id:hover { letter-spacing: -0.3px; } -.compare-subtitle { - font-size: 12px; - color: var(--text-muted); +/* Iteration selector for deep dive */ +.compare-iter-selector { margin-bottom: 10px; - font-family: 'SF Mono', ui-monospace, Consolas, monospace; - padding: 4px 8px; + padding: 8px 12px; background: var(--surface-alt); + border: 1px solid var(--border); border-radius: var(--radius-sm); - display: inline-block; +} + +.compare-iter-selector-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 6px; +} + +.compare-iter-selector-label { + font-size: 11px; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.compare-iter-limit-warning { + font-size: 10px; + color: var(--warning); + font-weight: 600; +} + +.compare-iter-cards { + display: flex; + gap: 6px; + overflow-x: auto; + padding-bottom: 4px; + scrollbar-width: thin; +} + +.compare-iter-card { + display: flex; + flex-direction: column; + gap: 2px; + padding: 4px 6px; + border: 2px solid var(--border); + border-radius: var(--radius-sm); + cursor: pointer; + font-size: 11px; + transition: all 0.15s; + flex-shrink: 0; +} + +.compare-iter-card:hover { + border-color: var(--accent); +} + +.compare-iter-card-selected { + font-weight: 600; +} + +.compare-iter-card-disabled { + opacity: 0.35; + cursor: not-allowed; +} + +.compare-iter-card-disabled:hover { + border-color: var(--border); +} + +.compare-iter-card input[type="checkbox"] { + cursor: pointer; + flex-shrink: 0; + margin-top: 1px; +} + +.compare-iter-card-label { + overflow-wrap: break-word; + word-break: break-word; +} + +.compare-iter-card-remove { + background: none; + border: none; + cursor: pointer; + font-size: 12px; + color: var(--text-muted); + align-self: flex-end; + padding: 0 2px; + line-height: 1; +} + +.compare-iter-card-remove:hover { + color: var(--danger); +} + +.compare-iter-card-param.param, +.compare-iter-card-param.tag, +.compare-iter-card-param.benchmark-badge { + white-space: normal; + overflow-wrap: break-word; + word-break: break-word; + max-width: 200px; +} + +/* Deep dive selection row on bar chart */ +.compare-dd-select-row { + display: flex; + align-items: center; + margin-left: 60px; + margin-right: 30px; + margin-bottom: 2px; + position: relative; +} + +.compare-dd-select-label { + position: absolute; + right: -30px; + top: 50%; + transform: translateY(-50%); + font-size: 11px; + font-weight: 500; + color: var(--text-muted); + white-space: nowrap; +} + +.compare-dd-select-boxes { + flex: 1; + display: flex; + align-items: center; +} + +.compare-dd-select-box { + flex: 1; + height: 12px; + margin: 0 1px; + border: 2px solid var(--border); + border-radius: 2px; + cursor: pointer; + transition: all 0.15s; + background: transparent; +} + +.compare-dd-select-box:hover { + border-color: var(--accent); +} + +.compare-dd-selected { + border-color: var(--accent); +} + +.compare-dd-disabled { + opacity: 0.2; + cursor: not-allowed; +} + +.compare-dd-disabled:hover { + border-color: var(--border); +} + +.compare-dd-select-gap { + flex: 1; +} + +.compare-subtitle { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px; + margin-bottom: 10px; +} + +.compare-subtitle-label { + font-size: 11px; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-right: 4px; } .compare-controls { @@ -2072,11 +2301,135 @@ a.run-id:hover { outline: none !important; } +/* Deep dive context bar */ +.deepdive-context { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 10px 14px; +} + +.deepdive-common-details { + margin-bottom: 4px; +} + +.deepdive-common-summary { + font-size: 11px; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.5px; + cursor: pointer; + user-select: none; +} + +.deepdive-common-summary:hover { + color: var(--text-secondary); +} + +.deepdive-common { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px; + margin-top: 6px; +} + +.deepdive-iterations-divider { + border-top: 1px solid var(--border); + margin: 6px 0; +} + +.deepdive-iterations { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; +} + +.deepdive-iterations-label { + font-size: 11px; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.5px; + width: 100%; + margin-bottom: 2px; +} + +.deepdive-chip-legend { + display: inline-flex; + gap: 4px; + margin-right: 4px; +} + +.deepdive-iter-card { + display: flex; + flex-direction: column; + gap: 2px; + padding: 4px 6px; + border: 2px solid; + border-radius: var(--radius-sm); +} + +.deepdive-iter-param.param, +.deepdive-iter-param.tag, +.deepdive-iter-param.benchmark-badge, +.deepdive-iter-param { + white-space: normal; + overflow-wrap: break-word; + word-break: break-word; + max-width: 200px; +} + +.deepdive-legend-iter-tag { + display: inline-block; + padding: 2px 6px; + border: 1px solid; + border-radius: var(--radius-sm); + font-size: 10px; + font-weight: 600; + max-width: 180px; + overflow-wrap: break-word; + word-break: break-word; +} + +.deepdive-chart-wrap { + margin-bottom: 4px; +} + +.deepdive-chart-sublabel { + font-size: 11px; + font-weight: 600; + color: var(--text-secondary); + padding-left: 60px; + margin-bottom: 2px; + font-family: 'SF Mono', ui-monospace, Consolas, monospace; +} + +.deepdive-chart-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; +} + +.deepdive-chart-controls { + display: flex; + align-items: center; + gap: 4px; +} + +.deepdive-chart-controls-sep { + color: var(--border-strong); + margin: 0 2px; +} + .deepdive-chart-title { font-size: 14px; font-weight: 600; color: var(--text); - margin-bottom: 8px; + margin: 0; } .deepdive-chart-loading { @@ -2195,6 +2548,15 @@ a.run-id:hover { border-radius: 2px; } +.deepdive-legend-swatch-cell { + width: 16px; + padding-right: 4px; +} + +.deepdive-legend-iter-first { + border-left: 2px solid var(--border-strong); +} + .deepdive-legend-seg { font-family: 'SF Mono', ui-monospace, Consolas, monospace; color: var(--text-secondary); @@ -2225,3 +2587,20 @@ a.run-id:hover { .deepdive-legend-value-col { text-align: right; } + +.deepdive-legend-table th.deepdive-legend-iter-col { + font-family: 'SF Mono', ui-monospace, Consolas, monospace; + font-size: 10px; + white-space: nowrap; + border-left: 2px solid var(--border-strong); + text-align: right; +} + +.deepdive-legend-iter-dot { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + margin-right: 4px; + vertical-align: middle; +} diff --git a/queries/cdmq/web-ui/src/utils/iterLabel.js b/queries/cdmq/web-ui/src/utils/iterLabel.js new file mode 100644 index 00000000..a9d28768 --- /dev/null +++ b/queries/cdmq/web-ui/src/utils/iterLabel.js @@ -0,0 +1,60 @@ +// Build consolidated iteration items: groups params/tags with same value +// Returns array of { names: ['bs','rw'], val: '4k', type: 'param' } objects +// Only includes varying dimensions (>1 distinct value), excludes hidden fields +export function buildIterItems(it, allIterations, hiddenFields) { + if (!it) return []; + var hiddenSet = hiddenFields ? new Set(hiddenFields) : new Set(); + var paramValues = {}; + var tagValues = {}; + var benchmarks = new Set(); + allIterations.forEach(function (iter) { + if (iter.benchmark) benchmarks.add(iter.benchmark); + (iter.params || []).forEach(function (p) { + if (!paramValues[p.arg]) paramValues[p.arg] = new Set(); + paramValues[p.arg].add(String(p.val)); + }); + (iter.tags || []).forEach(function (t) { + if (!tagValues[t.name]) tagValues[t.name] = new Set(); + tagValues[t.name].add(t.val); + }); + }); + + // Collect varying items + var items = []; + if (benchmarks.size > 1 && !hiddenSet.has('benchmark')) { + items.push({ name: 'benchmark', val: it.benchmark || '', type: 'benchmark' }); + } + (it.params || []).forEach(function (p) { + if (paramValues[p.arg] && paramValues[p.arg].size > 1 && !hiddenSet.has('param:' + p.arg)) { + items.push({ name: p.arg, val: String(p.val), type: 'param' }); + } + }); + (it.tags || []).forEach(function (t) { + if (tagValues[t.name] && tagValues[t.name].size > 1 && !hiddenSet.has('tag:' + t.name)) { + items.push({ name: t.name, val: t.val, type: 'tag' }); + } + }); + + // Consolidate: group items with the same value + var byVal = {}; + var valOrder = []; + items.forEach(function (item) { + var key = item.type + ':' + item.val; + if (!byVal[key]) { + byVal[key] = { names: [], val: item.val, type: item.type }; + valOrder.push(key); + } + byVal[key].names.push(item.name); + }); + + return valOrder.map(function (key) { return byVal[key]; }); +} + +// Render items as a flat string (for bar labels, legend headers) +export function iterItemsToString(items) { + if (items.length === 0) return ''; + return items.map(function (item) { + if (item.type === 'benchmark') return item.val; + return item.names.join(',') + '=' + item.val; + }).join(', '); +}