From 6486bd275daf73d1cb9c430ef15688448b0198a6 Mon Sep 17 00:00:00 2001 From: Shaun Patterson Date: Fri, 12 Jun 2026 10:27:50 -0400 Subject: [PATCH 1/5] feat: per-phase query latency breakdown in the frame header Dgraph returns a per-phase latency breakdown with every response (extensions.server_latency: parsing/processing/encoding/...), but Ratel discarded it - and the frame header latency bar was dead code: it read frame.serverLatencyNs while the timing lives on frameResults[id][tab], so it never rendered at all. - lib/latency.js: pure helpers turning server_latency into ordered, labelled bar segments (known phases in pipeline order, unknown *_ns fields included future-proof, total_ns excluded) plus tooltip text. 9 unit tests. - frames reducer keeps the raw server_latency on the frame result. - FrameHeader now receives tabResult and renders a multi-segment color-coded bar (parsing/processing/encoding/network) with a per-phase tooltip showing times and percentages. Co-Authored-By: Claude Fable 5 --- client/src/components/FrameItem.js | 1 + .../src/components/FrameLayout/FrameHeader.js | 76 +++++------- .../components/FrameLayout/FrameHeader.scss | 19 ++- client/src/lib/latency.js | 114 ++++++++++++++++++ client/src/lib/latency.test.js | 100 +++++++++++++++ client/src/reducers/frames.js | 6 +- 6 files changed, 263 insertions(+), 53 deletions(-) create mode 100644 client/src/lib/latency.js create mode 100644 client/src/lib/latency.test.js diff --git a/client/src/components/FrameItem.js b/client/src/components/FrameItem.js index a3731f45..1733750c 100644 --- a/client/src/components/FrameItem.js +++ b/client/src/components/FrameItem.js @@ -115,6 +115,7 @@ export default function FrameItem({ > sum + s.ns, 0) - const flexStyles = { - server: { flexGrow: 1000 * ratio }, - network: { flexGrow: 1000 * (1 - ratio) }, - } return ( -
+
-
-
+ {segments.map((s) => ( +
+ ))}
-
- {timeToText(serverNs)} -
-
- {timeToText(networkNs)} +
+ {serverNs > 0 ? timeToText(serverNs) : timeToText(totalNs)}
@@ -109,7 +89,7 @@ export default function FrameHeader({ /> ) : null} - {drawLatency(frame.serverLatencyNs, frame.networkLatencyNs)} + {drawLatency(tabResult)}
{collapsed ? null : ( diff --git a/client/src/components/FrameLayout/FrameHeader.scss b/client/src/components/FrameLayout/FrameHeader.scss index 37a6265c..a5936a06 100644 --- a/client/src/components/FrameLayout/FrameHeader.scss +++ b/client/src/components/FrameLayout/FrameHeader.scss @@ -54,15 +54,26 @@ display: flex; flex-direction: row; - .server-bar { + .latency-seg { flex-basis: 2px; height: 2px; background-color: $serverColor; } - .network-bar { - flex-basis: 2px; - height: 2px; + // Server phases, in pipeline order. + .latency-seg--parsing { + background-color: color.adjust(#5cb85c, $saturation: -30%); + } + .latency-seg--processing { + background-color: $serverColor; + } + .latency-seg--encoding { + background-color: color.adjust(#d9534f, $saturation: -30%); + } + .latency-seg--assign_timestamp { + background-color: color.adjust(#9b59b6, $saturation: -30%); + } + .latency-seg--network { background-color: $networkColor; } } diff --git a/client/src/lib/latency.js b/client/src/lib/latency.js new file mode 100644 index 00000000..0715be94 --- /dev/null +++ b/client/src/lib/latency.js @@ -0,0 +1,114 @@ +/* + * SPDX-FileCopyrightText: © 2017-2026 Istari Digital, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +// Helpers for rendering Dgraph's per-phase latency breakdown +// (response.extensions.server_latency). + +const KNOWN_PHASES = [ + ['parsing_ns', 'Parsing'], + ['processing_ns', 'Processing'], + ['encoding_ns', 'Encoding'], + ['assign_timestamp_ns', 'Assign timestamp'], +] + +export function timeToText(ns) { + if (ns === null || ns === undefined) { + return '' + } + if (ns < 1e4) { + return ns.toFixed(0) + 'ns' + } + const ms = ns / 1e6 + if (ms < 1000) { + return ms.toFixed(0) + 'ms' + } + const s = ms / 1000 + if (s <= 60) { + return s.toFixed(1) + 's' + } + const secondsOnly = Math.round(s) % 60 + + return `${Math.floor(s / 60)}m${secondsOnly.toLocaleString('en', { + minimumIntegerDigits: 2, + })}s` +} + +const labelFor = (key) => + key + .replace(/_ns$/, '') + .replace(/_/g, ' ') + .replace(/^./, (c) => c.toUpperCase()) + +/** + * Turns extensions.server_latency into ordered display segments. + * Known phases come first in pipeline order; any other *_ns fields the + * server adds in the future follow with a prettified label. total_ns is + * excluded (it duplicates the sum). + */ +export function serverLatencySegments(serverLatency) { + if (!serverLatency) { + return [] + } + + const segments = [] + const seen = new Set() + + KNOWN_PHASES.forEach(([key, label]) => { + seen.add(key) + const ns = serverLatency[key] + if (typeof ns === 'number' && ns > 0) { + segments.push({ key, label, ns }) + } + }) + + Object.keys(serverLatency) + .sort() + .forEach((key) => { + if (seen.has(key) || key === 'total_ns' || !key.endsWith('_ns')) { + return + } + const ns = serverLatency[key] + if (typeof ns === 'number' && ns > 0) { + segments.push({ key, label: labelFor(key), ns }) + } + }) + + return segments +} + +/** + * Full set of bar segments for a frame: server phases plus network time, + * each with its share of the total. Returns [] when there is nothing to + * show. + */ +export function latencyBarSegments(serverLatency, networkNs) { + const segments = serverLatencySegments(serverLatency) + if (typeof networkNs === 'number' && networkNs > 0) { + segments.push({ key: 'network', label: 'Network', ns: networkNs }) + } + + const totalNs = segments.reduce((sum, s) => sum + s.ns, 0) + if (totalNs <= 0) { + return [] + } + + return segments.map((s) => ({ + ...s, + ratio: s.ns / totalNs, + text: timeToText(s.ns), + })) +} + +export function latencyTooltip(segments) { + if (!segments.length) { + return '' + } + const totalNs = segments.reduce((sum, s) => sum + s.ns, 0) + const lines = segments.map( + (s) => `${s.label}: ${timeToText(s.ns)} (${(s.ratio * 100).toFixed(0)}%)`, + ) + lines.push(`Total: ${timeToText(totalNs)}`) + return lines.join('\n') +} diff --git a/client/src/lib/latency.test.js b/client/src/lib/latency.test.js new file mode 100644 index 00000000..c7bea058 --- /dev/null +++ b/client/src/lib/latency.test.js @@ -0,0 +1,100 @@ +/* + * SPDX-FileCopyrightText: © 2017-2026 Istari Digital, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + latencyBarSegments, + latencyTooltip, + serverLatencySegments, + timeToText, +} from './latency' + +describe('timeToText', () => { + it('formats across magnitudes', () => { + expect(timeToText(null)).toBe('') + expect(timeToText(undefined)).toBe('') + expect(timeToText(500)).toBe('500ns') + expect(timeToText(2.5e6)).toBe('3ms') + expect(timeToText(1.5e9)).toBe('1.5s') + expect(timeToText(90e9)).toBe('1m30s') + }) +}) + +describe('serverLatencySegments', () => { + it('returns empty for missing input', () => { + expect(serverLatencySegments(null)).toEqual([]) + expect(serverLatencySegments(undefined)).toEqual([]) + expect(serverLatencySegments({})).toEqual([]) + }) + + it('orders known phases by pipeline order and skips zeros', () => { + const segments = serverLatencySegments({ + encoding_ns: 100, + parsing_ns: 50, + processing_ns: 0, + total_ns: 150, + }) + expect(segments.map((s) => s.key)).toEqual(['parsing_ns', 'encoding_ns']) + expect(segments[0].label).toBe('Parsing') + }) + + it('includes unknown *_ns fields with prettified labels, excludes total', () => { + const segments = serverLatencySegments({ + parsing_ns: 10, + some_new_phase_ns: 20, + total_ns: 30, + not_a_latency: 99, + }) + expect(segments.map((s) => s.key)).toEqual([ + 'parsing_ns', + 'some_new_phase_ns', + ]) + expect(segments[1].label).toBe('Some new phase') + }) +}) + +describe('latencyBarSegments', () => { + it('appends network time and computes ratios', () => { + const segments = latencyBarSegments( + { parsing_ns: 25, processing_ns: 50 }, + 25, + ) + expect(segments.map((s) => s.key)).toEqual([ + 'parsing_ns', + 'processing_ns', + 'network', + ]) + expect(segments.map((s) => s.ratio)).toEqual([0.25, 0.5, 0.25]) + expect(segments[2].label).toBe('Network') + }) + + it('returns empty when there is nothing to show', () => { + expect(latencyBarSegments(null, 0)).toEqual([]) + expect(latencyBarSegments({}, undefined)).toEqual([]) + }) + + it('works with server latency only', () => { + const segments = latencyBarSegments({ processing_ns: 10 }, undefined) + expect(segments).toHaveLength(1) + expect(segments[0].ratio).toBe(1) + }) +}) + +describe('latencyTooltip', () => { + it('lists each phase with percentage and a total', () => { + const tooltip = latencyTooltip( + latencyBarSegments({ parsing_ns: 25, processing_ns: 50 }, 25), + ) + expect(tooltip).toContain('Parsing: ') + expect(tooltip).toContain('(25%)') + expect(tooltip).toContain('Processing: ') + expect(tooltip).toContain('(50%)') + expect(tooltip).toContain('Network: ') + expect(tooltip.split('\n').pop()).toMatch(/^Total: /) + }) + + it('is empty for no segments', () => { + expect(latencyTooltip([])).toBe('') + }) +}) diff --git a/client/src/reducers/frames.js b/client/src/reducers/frames.js index c2ef0451..ec583791 100644 --- a/client/src/reducers/frames.js +++ b/client/src/reducers/frames.js @@ -28,13 +28,17 @@ function getFrameTiming(executionStart, extensions) { return { serverLatencyNs: 0, networkLatencyNs: fullRequestTimeNs, + serverLatency: null, } } const { parsing_ns, processing_ns, encoding_ns } = extensions.server_latency - const serverLatencyNs = parsing_ns + processing_ns + (encoding_ns || 0) + const serverLatencyNs = + (parsing_ns || 0) + (processing_ns || 0) + (encoding_ns || 0) return { serverLatencyNs, networkLatencyNs: fullRequestTimeNs - serverLatencyNs, + // Keep the raw per-phase breakdown for the latency bar tooltip. + serverLatency: extensions.server_latency, } } From 52c4389dd1fb50bbbc07ab652ff557cee06d8d99 Mon Sep 17 00:00:00 2001 From: Shaun Patterson Date: Mon, 15 Jun 2026 21:10:55 -0400 Subject: [PATCH 2/5] feat: clickable query latency breakdown modal with node counts Address review feedback on the latency bar: - The collapsed headline now shows the grand total (server phases + network), not parsing+processing+encoding, matching what users expect from "total". - Clicking the latency bar opens a 'Query latency breakdown' modal (the hover tooltip is kept) with one labelled, colored bar per phase plus a total row. - A 'Num UIDs' section counts the values each predicate contributed to the response (scalars once, child lists by length), a proxy for how much data the query returned and thus its processing/encoding/network cost. countPredicates/numUidSegments are pure and unit-tested. Co-Authored-By: Claude Fable 5 --- .../src/components/FrameLayout/FrameHeader.js | 18 ++- .../components/FrameLayout/FrameHeader.scss | 2 +- .../components/FrameLayout/LatencyModal.js | 117 ++++++++++++++++++ .../components/FrameLayout/LatencyModal.scss | 93 ++++++++++++++ client/src/lib/latency.js | 61 +++++++++ client/src/lib/latency.test.js | 71 +++++++++++ 6 files changed, 355 insertions(+), 7 deletions(-) create mode 100644 client/src/components/FrameLayout/LatencyModal.js create mode 100644 client/src/components/FrameLayout/LatencyModal.scss diff --git a/client/src/components/FrameLayout/FrameHeader.js b/client/src/components/FrameLayout/FrameHeader.js index 92b564b7..0092788b 100644 --- a/client/src/components/FrameLayout/FrameHeader.js +++ b/client/src/components/FrameLayout/FrameHeader.js @@ -13,6 +13,7 @@ import { discardFrame, setActiveFrame } from 'actions/frames' import { updateQueryAndAction, updateQueryVars } from 'actions/query' import { latencyBarSegments, latencyTooltip, timeToText } from 'lib/latency' +import LatencyModal from './LatencyModal' import QueryPreview from './QueryPreview' import SharingSettings from './SharingSettings' import './FrameHeader.scss' @@ -26,6 +27,7 @@ export default function FrameHeader({ onToggleFullscreen, }) { const dispatch = useDispatch() + const [showLatency, setShowLatency] = React.useState(false) const selectFrame = () => { dispatch(updateQueryAndAction(frame.query, frame.action)) if (frame.action === 'query') { @@ -46,14 +48,16 @@ export default function FrameHeader({ return null } - const serverNs = result.serverLatencyNs || 0 const totalNs = segments.reduce((sum, s) => sum + s.ns, 0) return (
{ + e.stopPropagation() + setShowLatency(true) + }} >
{segments.map((s) => ( @@ -65,9 +69,7 @@ export default function FrameHeader({ ))}
-
- {serverNs > 0 ? timeToText(serverNs) : timeToText(totalNs)} -
+
{timeToText(totalNs)}
) @@ -123,6 +125,10 @@ export default function FrameHeader({ ) : null}
+ + {showLatency && tabResult && ( + setShowLatency(false)} /> + )}
) } diff --git a/client/src/components/FrameLayout/FrameHeader.scss b/client/src/components/FrameLayout/FrameHeader.scss index a5936a06..b2bf1a0e 100644 --- a/client/src/components/FrameLayout/FrameHeader.scss +++ b/client/src/components/FrameLayout/FrameHeader.scss @@ -108,7 +108,7 @@ *, & { - cursor: default; + cursor: pointer; } } diff --git a/client/src/components/FrameLayout/LatencyModal.js b/client/src/components/FrameLayout/LatencyModal.js new file mode 100644 index 00000000..4327aeed --- /dev/null +++ b/client/src/components/FrameLayout/LatencyModal.js @@ -0,0 +1,117 @@ +/* + * SPDX-FileCopyrightText: © 2017-2026 Istari Digital, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react' +import Modal from 'react-bootstrap/Modal' + +import { latencyBarSegments, numUidSegments, timeToText } from 'lib/latency' + +import './LatencyModal.scss' + +// Phase colors mirror the inline latency bar (.latency-seg--* in +// FrameHeader.scss). +const PHASE_COLOR = { + parsing: '#74b074', + processing: '#e0a960', + encoding: '#cc6b68', + assign_timestamp: '#a974c0', + network: '#5b8bb5', +} + +// Segment keys for server phases carry an "_ns" suffix (parsing_ns, ...); +// network does not. Normalize before looking up the color. +const colorFor = (key) => PHASE_COLOR[key.replace(/_ns$/, '')] || '#5b8bb5' + +export default function LatencyModal({ result, onHide }) { + const segments = latencyBarSegments( + result.serverLatency, + result.networkLatencyNs, + ) + const totalNs = segments.reduce((sum, s) => sum + s.ns, 0) + const { segments: uidSegments, total: uidTotal } = numUidSegments( + result.response && result.response.data, + ) + + return ( + + + Query latency breakdown + + + {segments.length === 0 ? ( +

+ No latency data for this query. +

+ ) : ( +
+
Latency
+ {segments.map((s) => ( +
+ {s.label} + + + + {s.text} + + {(s.ratio * 100).toFixed(0)}% + +
+ ))} +
+ Total + + + {timeToText(totalNs)} + + +
+
+ )} + + {uidSegments.length > 0 && ( +
+
+ Num UIDs + + total: {uidTotal.toLocaleString()} + +
+ {uidSegments.map((s) => ( +
+ + {s.key} + + + + + + {s.count.toLocaleString()} + +
+ ))} +
+ )} +
+
+ ) +} diff --git a/client/src/components/FrameLayout/LatencyModal.scss b/client/src/components/FrameLayout/LatencyModal.scss new file mode 100644 index 00000000..4a4ff03c --- /dev/null +++ b/client/src/components/FrameLayout/LatencyModal.scss @@ -0,0 +1,93 @@ +.latency-modal { + .latency-modal__section { + margin-bottom: 18px; + + &:last-child { + margin-bottom: 0; + } + } + + .latency-modal__section-title { + display: flex; + justify-content: space-between; + align-items: baseline; + text-transform: uppercase; + font-size: 11px; + letter-spacing: 0.05em; + color: #999; + margin-bottom: 10px; + } + + .latency-modal__section-total { + text-transform: none; + letter-spacing: 0; + font-size: 13px; + color: #666; + } + + .latency-modal__row { + display: flex; + align-items: center; + gap: 12px; + padding: 4px 0; + + &--total { + margin-top: 6px; + padding-top: 10px; + border-top: 1px solid #eee; + font-weight: 600; + } + } + + .latency-modal__label { + flex: none; + width: 120px; + text-align: right; + color: #444; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .latency-modal__track { + flex: 1; + height: 22px; + background: #f3f4f6; + border-radius: 3px; + display: flex; + align-items: center; + + &--rule { + background: none; + border-bottom: 1px dashed #ccc; + height: 0; + } + } + + .latency-modal__bar { + height: 22px; + border-radius: 3px; + min-width: 2px; + } + + .latency-modal__value { + flex: none; + width: 64px; + text-align: right; + font-size: 13px; + color: #444; + font-variant-numeric: tabular-nums; + } + + .latency-modal__pct { + flex: none; + width: 48px; + text-align: right; + color: #888; + font-variant-numeric: tabular-nums; + } + + .latency-modal__empty { + color: #888; + } +} diff --git a/client/src/lib/latency.js b/client/src/lib/latency.js index 0715be94..ab06491c 100644 --- a/client/src/lib/latency.js +++ b/client/src/lib/latency.js @@ -101,6 +101,67 @@ export function latencyBarSegments(serverLatency, networkNs) { })) } +/** + * Tallies how many values each predicate contributes across the whole + * response tree: scalars count once, child-node lists count their length + * (and recurse). Block aliases at the top level and facet maps (keys with a + * "|") are excluded. This is a rough proxy for how much data the query + * returned, which helps explain processing/encoding/network time. + */ +export function countPredicates(data) { + const counts = {} + const bump = (key, n) => { + counts[key] = (counts[key] || 0) + n + } + + const visit = (node) => { + if (Array.isArray(node)) { + node.forEach(visit) + return + } + if (!node || typeof node !== 'object') { + return + } + for (const [key, val] of Object.entries(node)) { + if (key.includes('|')) { + // Facet map (e.g. "friend|since"), not a predicate of its own. + continue + } + if (Array.isArray(val)) { + bump(key, val.length) + val.forEach(visit) + } else if (val && typeof val === 'object') { + bump(key, 1) + visit(val) + } else { + bump(key, 1) + } + } + } + + // Top-level keys are query block aliases, not predicates: descend past them. + Object.values(data || {}).forEach(visit) + return counts +} + +/** + * Per-predicate value counts as display segments, widest bar first. `ratio` + * is relative to the largest count so the busiest predicate fills the track. + */ +export function numUidSegments(data) { + const counts = countPredicates(data) + const entries = Object.entries(counts) + const total = entries.reduce((sum, [, n]) => sum + n, 0) + if (total <= 0) { + return { segments: [], total: 0 } + } + const max = Math.max(...entries.map(([, n]) => n)) + const segments = entries + .map(([key, count]) => ({ key, count, ratio: max ? count / max : 0 })) + .sort((a, b) => b.count - a.count || a.key.localeCompare(b.key)) + return { segments, total } +} + export function latencyTooltip(segments) { if (!segments.length) { return '' diff --git a/client/src/lib/latency.test.js b/client/src/lib/latency.test.js index c7bea058..0f303f08 100644 --- a/client/src/lib/latency.test.js +++ b/client/src/lib/latency.test.js @@ -4,8 +4,10 @@ */ import { + countPredicates, latencyBarSegments, latencyTooltip, + numUidSegments, serverLatencySegments, timeToText, } from './latency' @@ -98,3 +100,72 @@ describe('latencyTooltip', () => { expect(latencyTooltip([])).toBe('') }) }) + +describe('countPredicates', () => { + it('counts scalars per node and recurses into child lists', () => { + const data = { + q: [ + { + uid: '0x1', + name: 'Alice', + age: 30, + friend: [ + { uid: '0x2', name: 'Bob' }, + { uid: '0x3', name: 'Carol', age: 25 }, + ], + }, + ], + } + const counts = countPredicates(data) + expect(counts.uid).toBe(3) + expect(counts.name).toBe(3) + expect(counts.age).toBe(2) + expect(counts.friend).toBe(2) + }) + + it('does not count top-level block aliases as predicates', () => { + const counts = countPredicates({ q: [{ uid: '0x1' }] }) + expect(counts.q).toBeUndefined() + expect(counts.uid).toBe(1) + }) + + it('ignores facet maps', () => { + const counts = countPredicates({ + q: [{ uid: '0x1', 'friend|since': { 0: '2020' } }], + }) + expect(counts['friend|since']).toBeUndefined() + expect(counts.uid).toBe(1) + }) + + it('handles one-to-one object relationships', () => { + const counts = countPredicates({ + q: [{ uid: '0x1', boss: { uid: '0x9', name: 'Eve' } }], + }) + expect(counts.boss).toBe(1) + expect(counts.name).toBe(1) + expect(counts.uid).toBe(2) + }) + + it('returns empty for missing data', () => { + expect(countPredicates(null)).toEqual({}) + expect(countPredicates(undefined)).toEqual({}) + }) +}) + +describe('numUidSegments', () => { + it('returns sorted segments with the busiest predicate first', () => { + const data = { + q: [{ uid: '0x1', name: 'A', friend: [{ uid: '0x2', name: 'B' }] }], + } + const { segments, total } = numUidSegments(data) + // uid:2, name:2, friend:1 => total 5 + expect(total).toBe(5) + expect(segments[0].ratio).toBe(1) + expect(segments[segments.length - 1].key).toBe('friend') + expect(segments.find((s) => s.key === 'uid').count).toBe(2) + }) + + it('returns an empty result for no data', () => { + expect(numUidSegments(null)).toEqual({ segments: [], total: 0 }) + }) +}) From 519b5bd2a0addb26c235d8fe26188edc2b91b6d1 Mon Sep 17 00:00:00 2001 From: Shaun Patterson Date: Mon, 15 Jun 2026 21:52:53 -0400 Subject: [PATCH 3/5] feat: render latency phases as a timeline waterfall Per review, the Latency section now reads as a sequence over the total query duration: each phase bar starts where the previous phase ended (offset by cumulative elapsed time) instead of every bar starting at zero. Num UIDs bars stay left-aligned as plain counts. Co-Authored-By: Claude Fable 5 --- .../src/components/FrameLayout/LatencyModal.js | 16 ++++++++++++++-- .../src/components/FrameLayout/LatencyModal.scss | 7 +++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/client/src/components/FrameLayout/LatencyModal.js b/client/src/components/FrameLayout/LatencyModal.js index 4327aeed..686f1d76 100644 --- a/client/src/components/FrameLayout/LatencyModal.js +++ b/client/src/components/FrameLayout/LatencyModal.js @@ -30,6 +30,17 @@ export default function LatencyModal({ result, onHide }) { result.networkLatencyNs, ) const totalNs = segments.reduce((sum, s) => sum + s.ns, 0) + + // Lay the phases out as a timeline/waterfall: each bar starts where the + // previous phase ended, so the row reads as a sequence over the total + // duration rather than five independent bars from zero. + let elapsedNs = 0 + const timeline = segments.map((s) => { + const offset = totalNs > 0 ? elapsedNs / totalNs : 0 + elapsedNs += s.ns + return { ...s, offset } + }) + const { segments: uidSegments, total: uidTotal } = numUidSegments( result.response && result.response.data, ) @@ -53,13 +64,14 @@ export default function LatencyModal({ result, onHide }) { ) : (
Latency
- {segments.map((s) => ( + {timeline.map((s) => (
{s.label} Date: Mon, 15 Jun 2026 21:55:41 -0400 Subject: [PATCH 4/5] fix: dark-mode styling for the latency breakdown modal The modal chrome is themed centrally, but the latency/num-uids bar tracks rendered bright white and labels were dim in dark mode. Add [data-theme=dark] overrides (co-located with the component) so the empty tracks use the dark elevated surface, the total rule/border use the dark border, and labels/values stay legible. Co-Authored-By: Claude Fable 5 --- .../components/FrameLayout/LatencyModal.scss | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/client/src/components/FrameLayout/LatencyModal.scss b/client/src/components/FrameLayout/LatencyModal.scss index bd0a7c91..2b9a2f59 100644 --- a/client/src/components/FrameLayout/LatencyModal.scss +++ b/client/src/components/FrameLayout/LatencyModal.scss @@ -98,3 +98,34 @@ color: #888; } } + +// Dark mode: the modal chrome is themed centrally (theme-dark.scss), but the +// bar tracks and text inside default to light. Recolor them so the empty +// tracks read as dark surfaces and the labels stay legible. Variables are +// inherited from the [data-theme="dark"] root. +[data-theme="dark"] .latency-modal { + .latency-modal__track { + background: var(--bg-elevated); + } + + .latency-modal__track--rule { + background: none; + border-bottom-color: var(--border); + } + + .latency-modal__row--total { + border-top-color: var(--border); + } + + .latency-modal__label, + .latency-modal__value { + color: var(--text); + } + + .latency-modal__pct, + .latency-modal__section-title, + .latency-modal__section-total, + .latency-modal__empty { + color: var(--text-muted); + } +} From 6bb75253b2d0ab0e814fab319c7ab1c4f2bde721 Mon Sep 17 00:00:00 2001 From: Shaun Patterson Date: Mon, 15 Jun 2026 21:58:04 -0400 Subject: [PATCH 5/5] fix: widen latency modal label column so 'Assign timestamp' fits The 120px label column truncated 'Assign timestamp' to 'Assign timesta...'. Widen to 150px so the longest phase label fits without ellipsis. Co-Authored-By: Claude Fable 5 --- client/src/components/FrameLayout/LatencyModal.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/components/FrameLayout/LatencyModal.scss b/client/src/components/FrameLayout/LatencyModal.scss index 2b9a2f59..dbe329b7 100644 --- a/client/src/components/FrameLayout/LatencyModal.scss +++ b/client/src/components/FrameLayout/LatencyModal.scss @@ -41,7 +41,7 @@ .latency-modal__label { flex: none; - width: 120px; + width: 150px; text-align: right; color: #444; overflow: hidden;