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({ > { dispatch(updateQueryAndAction(frame.query, frame.action)) if (frame.action === 'query') { @@ -54,40 +36,40 @@ export default function FrameHeader({ dispatch(setActiveFrame(frame.id)) } - function drawLatency(serverNs, networkNs) { - if ( - serverNs === undefined || - networkNs === undefined || - serverNs === null || - networkNs === null - ) { + function drawLatency(result) { + if (!result) { + return null + } + const segments = latencyBarSegments( + result.serverLatency, + result.networkLatencyNs, + ) + if (!segments.length) { return null } - const ratio = serverNs / (serverNs + networkNs) - const title = `Alpha Latency: ${timeToText(serverNs)} (${( - ratio * 100 - ).toFixed(0)}%)\nNetwork Latency: ${timeToText(networkNs)} (${( - (1 - ratio) * 100 - ).toFixed(0)}%)\nTotal Latency: ${timeToText(serverNs + networkNs)}` + const totalNs = segments.reduce((sum, s) => sum + s.ns, 0) - const flexStyles = { - server: { flexGrow: 1000 * ratio }, - network: { flexGrow: 1000 * (1 - ratio) }, - } return ( -
+
{ + e.stopPropagation() + setShowLatency(true) + }} + >
-
-
+ {segments.map((s) => ( +
+ ))}
-
- {timeToText(serverNs)} -
-
- {timeToText(networkNs)} -
+
{timeToText(totalNs)}
) @@ -109,7 +91,7 @@ export default function FrameHeader({ /> ) : null} - {drawLatency(frame.serverLatencyNs, frame.networkLatencyNs)} + {drawLatency(tabResult)}
{collapsed ? null : ( @@ -143,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 37a6265c..b2bf1a0e 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; } } @@ -97,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..686f1d76 --- /dev/null +++ b/client/src/components/FrameLayout/LatencyModal.js @@ -0,0 +1,129 @@ +/* + * 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) + + // 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, + ) + + return ( + + + Query latency breakdown + + + {segments.length === 0 ? ( +

+ No latency data for this query. +

+ ) : ( +
+
Latency
+ {timeline.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..dbe329b7 --- /dev/null +++ b/client/src/components/FrameLayout/LatencyModal.scss @@ -0,0 +1,131 @@ +.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: 150px; + text-align: right; + color: #444; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .latency-modal__track { + position: relative; + 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; + } + + // Timeline/waterfall bar: positioned at its start offset within the track. + .latency-modal__bar--timeline { + position: absolute; + top: 0; + } + + .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; + } +} + +// 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); + } +} diff --git a/client/src/lib/latency.js b/client/src/lib/latency.js new file mode 100644 index 00000000..ab06491c --- /dev/null +++ b/client/src/lib/latency.js @@ -0,0 +1,175 @@ +/* + * 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), + })) +} + +/** + * 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 '' + } + 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..0f303f08 --- /dev/null +++ b/client/src/lib/latency.test.js @@ -0,0 +1,171 @@ +/* + * SPDX-FileCopyrightText: © 2017-2026 Istari Digital, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + countPredicates, + latencyBarSegments, + latencyTooltip, + numUidSegments, + 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('') + }) +}) + +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 }) + }) +}) 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, } }