From 8017efbf76fc7099ce83e8162356f63536f84fc4 Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:18:43 +0100 Subject: [PATCH 01/17] refactor(log-viewer): one swatch for every colour key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four places drew a small colour key, in three shapes. --lana-radius-sm is var(--vscode-cornerRadius-small, 4px), so at 8px the frame tooltip's swatch rounded to a circle, the timeline legend asked for one outright, and only the stacked bar and the metric strip drew the squircle. Replace all four with , a custom element so the canvas tooltips — which build their panels imperatively and cannot adopt a Lit stylesheet — draw the same shape as the Lit components. Size and radius come from new --lana-swatch-* tokens, which also takes the last literal 8px/50% out of TimelineKey. --- log-viewer/src/components/ColorSwatch.ts | 66 +++++++++++++++++++ log-viewer/src/components/StackedTimeBar.ts | 13 ++-- .../components/__tests__/ColorSwatch.test.ts | 53 +++++++++++++++ .../timeline/__tests__/TimelineKey.test.ts | 7 +- .../timeline/__tests__/tooltip.test.ts | 8 +-- .../timeline/components/TimelineKey.ts | 10 +-- .../optimised/FrameTooltipRenderer.ts | 8 ++- .../MetricStripTooltipRenderer.ts | 7 +- .../features/timeline/styles/timeline.css.ts | 7 -- log-viewer/src/styles/tokens.css | 6 ++ 10 files changed, 148 insertions(+), 37 deletions(-) create mode 100644 log-viewer/src/components/ColorSwatch.ts create mode 100644 log-viewer/src/components/__tests__/ColorSwatch.test.ts diff --git a/log-viewer/src/components/ColorSwatch.ts b/log-viewer/src/components/ColorSwatch.ts new file mode 100644 index 00000000..6d8ddfa4 --- /dev/null +++ b/log-viewer/src/components/ColorSwatch.ts @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ +import { LitElement, css } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; + +import { tokenStyles } from '../styles/tokens.styles.js'; + +/** + * The colour key beside a label — a legend chip, a reveal row, a tooltip row. One + * shape for every one of them, so a hue means the same thing wherever it appears. + * + * A custom element rather than a shared stylesheet, because the canvas tooltips + * build their panels imperatively and cannot adopt a Lit one. + * + * The hue is decorative: whatever it stands for is named in text beside it, so the + * swatch is hidden from a screen reader and only ever carries a hover title. + */ +@customElement('color-swatch') +export class ColorSwatch extends LitElement { + /** Any CSS colour. Unset, the swatch takes `--row-hue` from the row around it. */ + @property() + color = ''; + + /** What the colour stands for, shown on hover. */ + @property() + label = ''; + + static styles = [ + tokenStyles, + css` + :host { + display: block; + /* Centred against a taller line, and never squeezed by a flex or grid parent. */ + align-self: center; + flex: 0 0 auto; + width: var(--lana-swatch-size); + height: var(--lana-swatch-size); + border-radius: var(--lana-swatch-radius); + background: var(--row-hue); + } + `, + ]; + + connectedCallback(): void { + super.connectedCallback(); + this.setAttribute('aria-hidden', 'true'); + } + + protected updated(): void { + // A colour is data, not a token, so it is written as a style rather than declared + // above. Cleared rather than set empty, so `--row-hue` can answer again. + this.style.setProperty('background', this.color || null); + if (this.label) { + this.title = this.label; + } else { + this.removeAttribute('title'); + } + } +} + +declare global { + interface HTMLElementTagNameMap { + 'color-swatch': ColorSwatch; + } +} diff --git a/log-viewer/src/components/StackedTimeBar.ts b/log-viewer/src/components/StackedTimeBar.ts index 0d74b4d6..150b2236 100644 --- a/log-viewer/src/components/StackedTimeBar.ts +++ b/log-viewer/src/components/StackedTimeBar.ts @@ -9,6 +9,9 @@ import { styleMap } from 'lit/directives/style-map.js'; import { formatDuration, formatInteger } from '../core/utility/Util.js'; import { globalStyles } from '../styles/global.styles.js'; +// web components +import './ColorSwatch.js'; + /** One coloured length of a {@link StackedTimeBar}, measured in nanoseconds. */ export interface StackedSegment { label: string; @@ -137,14 +140,6 @@ export class StackedTimeBar extends LitElement { color: var(--lana-fg); } - .legend__swatch { - width: 8px; - height: 8px; - border-radius: 2px; - align-self: center; - flex: none; - } - .legend__value { font-family: var(--lana-font-mono); font-variant-numeric: tabular-nums; @@ -267,7 +262,7 @@ export class StackedTimeBar extends LitElement { @pointerenter=${() => (this._hover = { label: segment.label, onBar: false })} @pointerleave=${() => (this._hover = null)} > - + ${segment.label} ${readout(segment.timeNs, denominator)} diff --git a/log-viewer/src/components/__tests__/ColorSwatch.test.ts b/log-viewer/src/components/__tests__/ColorSwatch.test.ts new file mode 100644 index 00000000..ebe48f82 --- /dev/null +++ b/log-viewer/src/components/__tests__/ColorSwatch.test.ts @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + * + * @jest-environment jsdom + */ +import { describe, expect, it } from '@jest/globals'; + +import type { ColorSwatch } from '../ColorSwatch.js'; +import '../ColorSwatch.js'; + +async function mount(props: Partial> = {}) { + const element = document.createElement('color-swatch'); + Object.assign(element, props); + document.body.appendChild(element); + await element.updateComplete; + return element; +} + +describe('ColorSwatch', () => { + it('paints itself in the colour it is given', async () => { + const element = await mount({ color: '#88ae58' }); + + expect(element.style.background).toBe('rgb(136, 174, 88)'); + }); + + it('leaves the row hue to answer when it has no colour of its own', async () => { + const element = await mount(); + + expect(element.style.background).toBe(''); + }); + + it('drops back to the row hue when the colour is taken away', async () => { + const element = await mount({ color: '#88ae58' }); + + element.color = ''; + await element.updateComplete; + + expect(element.style.background).toBe(''); + }); + + it('names what the colour stands for on hover', async () => { + const element = await mount({ color: '#88ae58', label: 'Apex' }); + + expect(element.title).toBe('Apex'); + }); + + // The hue repeats something the row already says in text. + it('stays hidden from a screen reader', async () => { + const element = await mount({ color: '#88ae58' }); + + expect(element.getAttribute('aria-hidden')).toBe('true'); + }); +}); diff --git a/log-viewer/src/features/timeline/__tests__/TimelineKey.test.ts b/log-viewer/src/features/timeline/__tests__/TimelineKey.test.ts index d20a97aa..db832f52 100644 --- a/log-viewer/src/features/timeline/__tests__/TimelineKey.test.ts +++ b/log-viewer/src/features/timeline/__tests__/TimelineKey.test.ts @@ -24,7 +24,7 @@ function chips(el: Timelinekey): HTMLElement[] { } describe('TimelineKey', () => { - it('renders one chip per entry, with dot color, label and data-category', async () => { + it('renders one chip per entry, with swatch color, label and data-category', async () => { const el = await mount([ { label: 'Apex', fillColor: 'rgb(43, 143, 129)', selfTimeNs: 12_100_000_000 }, { label: 'SOQL', fillColor: 'rgb(109, 76, 125)', selfTimeNs: 500_000 }, @@ -36,8 +36,7 @@ describe('TimelineKey', () => { const [apex] = rendered; expect(apex?.dataset['category']).toBe('Apex'); expect(apex?.textContent).toContain('Apex'); - const dot = apex?.querySelector('.chip__dot'); - expect(dot?.style.backgroundColor).toBe('rgb(43, 143, 129)'); + expect(apex?.querySelector('color-swatch')?.color).toBe('rgb(43, 143, 129)'); }); it('shows the compact self time when present', async () => { @@ -54,7 +53,7 @@ describe('TimelineKey', () => { expect(chips(el)[0]?.querySelector('.chip__time')).toBeNull(); }); - it('keeps the chip itself unfilled — only the dot carries the category color', async () => { + it('keeps the chip itself unfilled — only the swatch carries the category color', async () => { const el = await mount([{ label: 'DML', fillColor: 'rgb(176, 104, 104)' }]); expect(chips(el)[0]?.getAttribute('style')).toBeNull(); diff --git a/log-viewer/src/features/timeline/__tests__/tooltip.test.ts b/log-viewer/src/features/timeline/__tests__/tooltip.test.ts index d63b38a8..9cbea416 100644 --- a/log-viewer/src/features/timeline/__tests__/tooltip.test.ts +++ b/log-viewer/src/features/timeline/__tests__/tooltip.test.ts @@ -404,16 +404,16 @@ describe('FrameTooltipRenderer', () => { showSettled(createEvent(0, 100, 'Event', 'Apex'), cursorAnchor(100, 100)); - const swatch = container.querySelector('.tooltip-swatch') as HTMLElement; + const swatch = container.querySelector('color-swatch'); expect(swatch).not.toBeNull(); - expect(swatch.style.backgroundColor).toBe('rgb(136, 174, 88)'); - expect(swatch.parentElement?.textContent).toContain('Apex'); + expect(swatch?.color).toBe('#88ae58'); + expect(swatch?.parentElement?.textContent).toContain('Apex'); }); it('should not display a category row for an uncategorised event', () => { showSettled(createEvent(0, 100, 'Event', ''), cursorAnchor(100, 100)); - expect(container.querySelector('.tooltip-swatch')).toBeNull(); + expect(container.querySelector('color-swatch')).toBeNull(); }); it('should display wall-clock time row when apexLog has startTime', () => { diff --git a/log-viewer/src/features/timeline/components/TimelineKey.ts b/log-viewer/src/features/timeline/components/TimelineKey.ts index 3274d28c..de146478 100644 --- a/log-viewer/src/features/timeline/components/TimelineKey.ts +++ b/log-viewer/src/features/timeline/components/TimelineKey.ts @@ -8,6 +8,7 @@ import { repeat } from 'lit/directives/repeat.js'; import { formatDuration } from '../../../core/utility/Util.js'; // web components +import '../../../components/ColorSwatch.js'; import '../../../components/OverflowList.js'; // styles @@ -43,13 +44,6 @@ export class Timelinekey extends LitElement { white-space: nowrap; } - .chip__dot { - width: 8px; - height: 8px; - border-radius: 50%; - flex: 0 0 auto; - } - /* The time is the data: full foreground against the muted label, and figure widths that line up chip to chip without leaving the UI font. */ .chip__time { @@ -67,7 +61,7 @@ export class Timelinekey extends LitElement { (entry) => // data-category is the seam for the interactivity follow-up (hover/click → highlight). html` - + ${entry.label} ${ entry.selfTimeNs !== undefined diff --git a/log-viewer/src/features/timeline/optimised/FrameTooltipRenderer.ts b/log-viewer/src/features/timeline/optimised/FrameTooltipRenderer.ts index fab3b2f7..26e708f4 100644 --- a/log-viewer/src/features/timeline/optimised/FrameTooltipRenderer.ts +++ b/log-viewer/src/features/timeline/optimised/FrameTooltipRenderer.ts @@ -19,6 +19,9 @@ import { formatSOQL, type Dialect, type SoqlBudget } from '../../soql/format/for import type { TimelineMarker } from '../types/flamechart.types.js'; import { formatNumber } from './rendering/tooltip-utils.js'; +// web components +import '../../../components/ColorSwatch.js'; + /** Delay before a tooltip first appears, so sweeping across frames does not strobe. */ const SHOW_DELAY_MS = 60; /** Grace period before hiding, so crossing the gap between adjacent frames does not blink. */ @@ -697,9 +700,8 @@ export class FrameTooltipRenderer { const categoryRow = document.createElement('div'); categoryRow.className = 'tooltip-category'; - const swatch = document.createElement('span'); - swatch.className = 'tooltip-swatch'; - swatch.style.backgroundColor = color; + const swatch = document.createElement('color-swatch'); + swatch.color = color; const name = document.createElement('span'); name.textContent = categoryName; diff --git a/log-viewer/src/features/timeline/optimised/metric-strip/MetricStripTooltipRenderer.ts b/log-viewer/src/features/timeline/optimised/metric-strip/MetricStripTooltipRenderer.ts index 7d605fb3..4af8706e 100644 --- a/log-viewer/src/features/timeline/optimised/metric-strip/MetricStripTooltipRenderer.ts +++ b/log-viewer/src/features/timeline/optimised/metric-strip/MetricStripTooltipRenderer.ts @@ -35,6 +35,9 @@ import { } from '../rendering/tooltip-utils.js'; import { getMetricStripColors, type MetricStripColors } from './metric-strip-colors.js'; +// web components +import '../../../../components/ColorSwatch.js'; + /** * Metrics that should always be shown in the tooltip regardless of their value. * These are the "important" metrics users care about most. @@ -264,7 +267,7 @@ export class MetricStripTooltipRenderer extends BaseTooltipRenderer { rows.push( `
` + - `` + + `` + `${metric.displayName}` + `${percentStr}%` + `${rawValueStr}${ghost}` + @@ -281,7 +284,7 @@ export class MetricStripTooltipRenderer extends BaseTooltipRenderer { rows.push( `
` + - `` + + `` + `Other (${hiddenMetrics.length})` + `${otherPercentStr}%` + `` + diff --git a/log-viewer/src/features/timeline/styles/timeline.css.ts b/log-viewer/src/features/timeline/styles/timeline.css.ts index da570dc8..bb5d7a24 100644 --- a/log-viewer/src/features/timeline/styles/timeline.css.ts +++ b/log-viewer/src/features/timeline/styles/timeline.css.ts @@ -111,13 +111,6 @@ export const tooltipStyles = `${soqlSyntaxStyles} color: var(--tl-description-foreground, #999); } - .tooltip-swatch { - width: 8px; - height: 8px; - border-radius: var(--lana-radius-sm); - flex: 0 0 auto; - } - .tooltip-row { display: flex; justify-content: space-between; diff --git a/log-viewer/src/styles/tokens.css b/log-viewer/src/styles/tokens.css index 809fb0d1..d511663a 100644 --- a/log-viewer/src/styles/tokens.css +++ b/log-viewer/src/styles/tokens.css @@ -40,6 +40,12 @@ --lana-stroke: var(--vscode-strokeThickness, 1px); + /* The colour key every legend, reveal row and tooltip draws (``). Ours: + no VS Code var carries the role, and the chrome radius would round this size to a + circle. */ + --lana-swatch-size: var(--lana-space-sm); + --lana-swatch-radius: 2px; + /* Left inset for content that lines up past a section's accent bar: the gutter, plus the bar itself (`DatabaseSection`). */ --lana-section-inset: calc(var(--lana-space-md) + 3px); From a4180af6483a22bf9852681b20903b21454e71db Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:40:11 +0100 Subject: [PATCH 02/17] refactor(log-viewer): read the timeline legend from the shared palette MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The legend carried its own category-to-theme-key map, duplicating the one Themes.ts calls the single source of truth, and its own getTheme() call — so it resolved colours by a path nothing else in the app used. toTimelineKeys now takes the colour function, and TimelineView passes categoryPalette(), the same resolver the inspector's rows read. That collapses the legacy and modern legend branches into one: categoryPalette already maps a category to its legacy group, so the legend under the legacy chart now names the same categories as the modern one, with the colours that chart drew and the per-category self times it never used to show. --- .../__tests__/category-self-time.test.ts | 25 +++++++-------- .../timeline/components/TimelineView.ts | 32 ++++++++++++------- .../timeline/utils/category-self-time.ts | 31 ++++++++++-------- 3 files changed, 49 insertions(+), 39 deletions(-) diff --git a/log-viewer/src/features/timeline/__tests__/category-self-time.test.ts b/log-viewer/src/features/timeline/__tests__/category-self-time.test.ts index 1bf4f564..e311b6e3 100644 --- a/log-viewer/src/features/timeline/__tests__/category-self-time.test.ts +++ b/log-viewer/src/features/timeline/__tests__/category-self-time.test.ts @@ -4,7 +4,6 @@ import { describe, expect, it } from '@jest/globals'; import type { ApexLog, LogCategory, LogEvent } from 'apex-log-parser'; -import type { TimelineColors } from '../themes/Themes.js'; import { categorySelfTimes, toTimelineKeys } from '../utils/category-self-time.js'; function node(category: LogCategory, self: number, children: LogEvent[] = []): LogEvent { @@ -64,19 +63,19 @@ describe('categorySelfTimes', () => { }); describe('toTimelineKeys', () => { - const colors: TimelineColors = { - apex: '#a1', - codeUnit: '#a2', - system: '#a3', - automation: '#a4', - dml: '#a5', - soql: '#a6', - callout: '#a7', - validation: '#a8', + const palette: Record = { + Apex: '#a1', + 'Code Unit': '#a2', + System: '#a3', + Automation: '#a4', + DML: '#a5', + SOQL: '#a6', + Callout: '#a7', }; + const color = (category: string) => palette[category] ?? ''; it('builds the legend in category order with the palette colors', () => { - const keys = toTimelineKeys(colors); + const keys = toTimelineKeys(color); expect(keys.map((k) => k.label)).toEqual([ 'Apex', @@ -97,14 +96,14 @@ describe('toTimelineKeys', () => { ['SOQL', 5], ]); - const keys = toTimelineKeys(colors, selfTimes); + const keys = toTimelineKeys(color, selfTimes); expect(keys.find((k) => k.label === 'Apex')?.selfTimeNs).toBe(15); expect(keys.find((k) => k.label === 'SOQL')?.selfTimeNs).toBe(5); }); it('reads 0 for a category the log never used', () => { - const keys = toTimelineKeys(colors, new Map([['Apex', 15]])); + const keys = toTimelineKeys(color, new Map([['Apex', 15]])); expect(keys.find((k) => k.label === 'DML')?.selfTimeNs).toBe(0); expect(keys.find((k) => k.label === 'Callout')?.selfTimeNs).toBe(0); diff --git a/log-viewer/src/features/timeline/components/TimelineView.ts b/log-viewer/src/features/timeline/components/TimelineView.ts index 61dcf7da..109b4bfa 100644 --- a/log-viewer/src/features/timeline/components/TimelineView.ts +++ b/log-viewer/src/features/timeline/components/TimelineView.ts @@ -6,12 +6,13 @@ import { LitElement, css, html, type PropertyValues } from 'lit'; import { customElement, property, query, state } from 'lit/decorators.js'; import type { ApexLog, LogCategory } from 'apex-log-parser'; +import { categoryPalette } from '../../../components/categoryTime.js'; import { VSCodeExtensionMessenger } from '../../../core/messaging/VSCodeExtensionMessenger.js'; import { subscribeSettings, updateSetting, type LanaSettings } from '../../settings/Settings.js'; -import { keyMap, setColors } from '../services/Timeline.js'; +import { setColors } from '../services/Timeline.js'; import { DEFAULT_THEME_NAME, sameColors, type TimelineColors } from '../themes/Themes.js'; -import { addCustomThemes, getTheme } from '../themes/ThemeSelector.js'; +import { addCustomThemes } from '../themes/ThemeSelector.js'; import { categorySelfTimes, toTimelineKeys } from '../utils/category-self-time.js'; import type { TimeDisplayMode } from '../types/flamechart.types.js'; @@ -60,6 +61,9 @@ export class TimelineView extends LitElement { /** Per-category self time for the loaded log; drives the legend durations. */ private selfTimes?: Map; + /** The timeline settings last pushed; the legend's palette is resolved from them. */ + private timelineSettings: LanaSettings['timeline'] | null = null; + @state() private useLegacyTimeline: boolean | null = null; @@ -198,6 +202,7 @@ export class TimelineView extends LitElement { private applyTimelineSettings(settings: LanaSettings) { const { timeline } = settings; + this.timelineSettings = timeline; this.useLegacyTimeline = timeline.legacy; this.showTooltip = timeline.showTooltip; @@ -209,15 +214,14 @@ export class TimelineView extends LitElement { this.appliedCustomThemes = customThemes; addCustomThemes(customThemes); this.setTheme(themeName); - } else { - // A legacy → modern toggle re-enters here with the theme unchanged, so the - // legend may still hold the legacy keyMap entries. - this.rebuildTimelineKeys(); + return; } } else { setColors(timeline.colors); - this.timelineKeys = Array.from(keyMap.values()); } + // A legacy toggle re-enters with the theme unchanged, so the legend is rebuilt + // either way: the palette it reads differs between the two chart renderers. + this.rebuildTimelineKeys(); } /** True when the pushed custom themes match those already applied. */ @@ -313,13 +317,17 @@ export class TimelineView extends LitElement { this.rebuildTimelineKeys(); } - /** Rebuilds the legend from the active palette + the log's per-category self times. */ + /** + * Rebuilds the legend from the palette the chart drew with, plus the log's + * per-category self times. `activeTheme` overrides the pushed one, since a + * quick-pick preview is never persisted. + */ private rebuildTimelineKeys(): void { - if (this.useLegacyTimeline) { - return; // legacy keys come from keyMap in applyTimelineSettings - } + const timeline = this.timelineSettings; this.timelineKeys = toTimelineKeys( - getTheme(this.activeTheme ?? DEFAULT_THEME_NAME), + categoryPalette( + timeline && { ...timeline, activeTheme: this.activeTheme ?? timeline.activeTheme }, + ), this.selfTimes, ); } diff --git a/log-viewer/src/features/timeline/utils/category-self-time.ts b/log-viewer/src/features/timeline/utils/category-self-time.ts index 4a23d538..c08e64be 100644 --- a/log-viewer/src/features/timeline/utils/category-self-time.ts +++ b/log-viewer/src/features/timeline/utils/category-self-time.ts @@ -4,7 +4,6 @@ import { LOG_CATEGORY, type ApexLog, type LogCategory, type LogEvent } from 'apex-log-parser'; import type { TimelineKeyEntry } from '../components/TimelineKey.js'; -import type { TimelineColors } from '../themes/Themes.js'; /** * Sums self time (ns) per category across the whole event tree. Self time partitions @@ -25,26 +24,30 @@ export function categorySelfTimes(root: ApexLog): Map { return totals; } -/** Legend order; labels double as the `LogCategory` keys used by `categorySelfTimes`. */ -const KEY_CATEGORIES: readonly { category: LogCategory; colorKey: keyof TimelineColors }[] = [ - { category: LOG_CATEGORY.Apex, colorKey: 'apex' }, - { category: LOG_CATEGORY.CodeUnit, colorKey: 'codeUnit' }, - { category: LOG_CATEGORY.System, colorKey: 'system' }, - { category: LOG_CATEGORY.Automation, colorKey: 'automation' }, - { category: LOG_CATEGORY.DML, colorKey: 'dml' }, - { category: LOG_CATEGORY.SOQL, colorKey: 'soql' }, - { category: LOG_CATEGORY.Callout, colorKey: 'callout' }, +/** Legend order; the labels double as the `LogCategory` keys `categorySelfTimes` sums by. */ +const KEY_CATEGORIES: readonly LogCategory[] = [ + LOG_CATEGORY.Apex, + LOG_CATEGORY.CodeUnit, + LOG_CATEGORY.System, + LOG_CATEGORY.Automation, + LOG_CATEGORY.DML, + LOG_CATEGORY.SOQL, + LOG_CATEGORY.Callout, //NOTE: add Validation back once the parser is updated to include validation events ]; -/** Builds the legend entries for a palette, attaching per-category self time when known. */ +/** + * Builds the legend entries, attaching per-category self time when known. The colour + * comes from the caller so the legend reads the same palette the chart drew with — + * `categoryPalette` answers for both the themes and the legacy colours. + */ export function toTimelineKeys( - colors: TimelineColors, + color: (category: string) => string, selfTimes?: Map, ): TimelineKeyEntry[] { - return KEY_CATEGORIES.map(({ category, colorKey }) => ({ + return KEY_CATEGORIES.map((category) => ({ label: category, - fillColor: colors[colorKey], + fillColor: color(category), // A category the log never used still reads 0 — an absent time means "unknown", not "none". selfTimeNs: selfTimes ? (selfTimes.get(category) ?? 0) : undefined, })); From ba9780fcc0787b6774939a1db054e068f1e994f1 Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:42:36 +0100 Subject: [PATCH 03/17] refactor(log-viewer): share the frame metric list The metric list, the used/limit formatting and the byte format lived inside EventVitals, so the timeline tooltip carried a second hand-written copy that had already drifted: lower-cased labels, no percentage, and the wrong SOSL row denominator. Move them to core/metrics/eventMetrics.ts. usageParts returns the reading as parts plus the fraction of the limit, so a caller can render it as text or as a meter; EventVitals' usage() is now a wrapper over it and renders as before. --- log-viewer/src/components/EventVitals.ts | 70 +++----------- .../metrics/__tests__/eventMetrics.test.ts | 89 +++++++++++++++++ log-viewer/src/core/metrics/eventMetrics.ts | 96 +++++++++++++++++++ 3 files changed, 197 insertions(+), 58 deletions(-) create mode 100644 log-viewer/src/core/metrics/__tests__/eventMetrics.test.ts create mode 100644 log-viewer/src/core/metrics/eventMetrics.ts diff --git a/log-viewer/src/components/EventVitals.ts b/log-viewer/src/components/EventVitals.ts index 189301c4..58ccca9e 100644 --- a/log-viewer/src/components/EventVitals.ts +++ b/log-viewer/src/components/EventVitals.ts @@ -1,12 +1,7 @@ /* * Copyright (c) 2026 Certinia Inc. All rights reserved. */ -import { - SOQLExecuteBeginLine, - type GovernorLimits, - type LogEvent, - type SelfTotal, -} from 'apex-log-parser'; +import { SOQLExecuteBeginLine, type LogEvent, type SelfTotal } from 'apex-log-parser'; import { consume } from '@lit/context'; import { LitElement, css, html, type TemplateResult } from 'lit'; import { customElement, property } from 'lit/decorators.js'; @@ -14,10 +9,10 @@ import { ifDefined } from 'lit/directives/if-defined.js'; import { logContext } from '../core/log/logContext.js'; import type { LogStore } from '../core/log/LogStore.js'; +import { EVENT_METRICS, formatBytes, HEAP_PEAK, usageParts } from '../core/metrics/eventMetrics.js'; import { DEFAULT_NAMESPACE, getCallerNamespace } from '../core/utility/CallerNamespace.js'; import { formatMs } from '../core/utility/Duration.js'; import { formatInteger } from '../core/utility/Util.js'; -import { SOSL_ROWS_PER_QUERY_LIMIT } from '../features/database/limits.js'; import { globalStyles } from '../styles/global.styles.js'; // web components @@ -32,39 +27,6 @@ const CARDINALITY_DOC = 'The estimated number of records that the leading operation type would return \u2014 for example, the number of records returned if using an index table.'; const SOBJECT_CARDINALITY_DOC = 'The approximate record count for the queried object.'; -interface Metric { - label: string; - pick: (event: LogEvent) => SelfTotal; - /** The transaction limit this metric accumulates against; 0 when it has none. */ - limit: (limits: GovernorLimits, type?: 'dml' | 'soql' | 'sosl') => number; - bytes?: boolean; - /** Throws only ever records on the leaf, so its self reading is meaningless. */ - hasSelf?: boolean; -} - -/** - * Every governor-tracked metric a frame reports, most important first. The - * `limit` is the row's denominator, so a metric is shown once — never as both a - * count and a separate "limit" row. - */ -const METRICS: Metric[] = [ - { label: 'SOQL', pick: (e) => e.soqlCount, limit: (l) => l.soqlQueries.limit }, - { label: 'SOQL Rows', pick: (e) => e.soqlRowCount, limit: (l) => l.queryRows.limit }, - { label: 'DML', pick: (e) => e.dmlCount, limit: (l) => l.dmlStatements.limit }, - { label: 'DML Rows', pick: (e) => e.dmlRowCount, limit: (l) => l.dmlRows.limit }, - { label: 'SOSL', pick: (e) => e.soslCount, limit: (l) => l.soslQueries.limit }, - { - label: 'SOSL Rows', - pick: (e) => e.soslRowCount, - // SOSL rows have no transaction total — the 2,000 cap is per query, so it - // only reads as a limit when a single SOSL statement is selected. - limit: (_limits, type) => (type === 'sosl' ? SOSL_ROWS_PER_QUERY_LIMIT : 0), - }, - { label: 'Throws', pick: (e) => e.thrownCount, limit: () => 0, hasSelf: false }, - { label: 'Heap net', pick: (e) => e.heapAllocated, limit: () => 0, bytes: true }, - { label: 'Heap alloc', pick: (e) => e.heapGross, limit: () => 0, bytes: true }, -]; - /** * The details readout for a selection, on every tab. Shows the frame's text * (copyable) then every field it actually has — timing, database counts, heap, @@ -263,7 +225,7 @@ export class EventVitals extends LitElement { // zero ("returned nothing" is a result); other zero metrics stay hidden. const alwaysShow = this.type ? `${this.type.toUpperCase()} Rows` : ''; - for (const metric of METRICS) { + for (const metric of EVENT_METRICS) { const total = sum(metric.pick, 'total'); if (!total && metric.label !== alwaysShow) { continue; @@ -278,10 +240,13 @@ export class EventVitals extends LitElement { ); } - // Heap peak is the limit-comparable heap figure and has no self component. - const heapPeak = events.reduce((max, e) => Math.max(max, e.heapPeak), 0); + const heapPeak = events.reduce((max, e) => Math.max(max, HEAP_PEAK.pick(e)), 0); if (heapPeak) { - this._row(rows, 'Heap peak', usage(heapPeak, limits?.heapSize.limit ?? 0, formatBytes, null)); + this._row( + rows, + HEAP_PEAK.label, + usage(heapPeak, limits ? HEAP_PEAK.limit(limits) : 0, formatBytes, null), + ); } } @@ -327,11 +292,6 @@ export class EventVitals extends LitElement { } } -/** Heap values are byte counts; a signed net value keeps its sign. */ -function formatBytes(bytes: number): string { - return `${formatInteger(bytes)} bytes`; -} - /** * Secondary readings for a value, in a single bracketed group one step down in * size — never several adjacent brackets, which collide as `) (`. The leading @@ -342,21 +302,15 @@ function qualifier(...parts: Array): Template return shown.length ? html` (${shown.join(', ')})` : ''; } -/** - * `used / limit` followed by its derived percentage and any self reading, so the - * primary number reads first. Without a known limit there is no denominator and - * no percentage. - */ +/** {@link usageParts} as the row renders it. */ function usage( total: number, limit: number, format: (value: number) => string, self: string | null, ): TemplateResult { - const primary = limit > 0 ? `${format(total)} / ${format(limit)}` : format(total); - // Percentage first: it qualifies the ratio immediately before it. - const percent = limit > 0 ? `${((total / limit) * 100).toFixed(2)}%` : null; - return html`${primary}${qualifier(percent, self && `self ${self}`)}`; + const { primary, qualifiers } = usageParts(total, limit, format, self); + return html`${primary}${qualifier(...qualifiers)}`; } declare global { diff --git a/log-viewer/src/core/metrics/__tests__/eventMetrics.test.ts b/log-viewer/src/core/metrics/__tests__/eventMetrics.test.ts new file mode 100644 index 00000000..49b89344 --- /dev/null +++ b/log-viewer/src/core/metrics/__tests__/eventMetrics.test.ts @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ +import { describe, expect, it } from '@jest/globals'; +import type { GovernorLimits, LogEvent } from 'apex-log-parser'; + +import { EVENT_METRICS, formatBytes, HEAP_PEAK, usageParts } from '../eventMetrics.js'; + +const limits = { + soqlQueries: { limit: 100 }, + queryRows: { limit: 50_000 }, + dmlStatements: { limit: 150 }, + dmlRows: { limit: 10_000 }, + soslQueries: { limit: 20 }, + heapSize: { limit: 6_000_000 }, +} as unknown as GovernorLimits; + +describe('usageParts', () => { + it('reads used / limit with the percentage and the self reading', () => { + const parts = usageParts(3, 100, String, '1'); + + expect(parts.primary).toBe('3 / 100'); + expect(parts.qualifiers).toEqual(['3.00%', 'self 1']); + expect(parts.fraction).toBe(0.03); + }); + + // No denominator means no share of anything, so nothing to meter. + it('gives the count alone, and no fraction, where there is no limit', () => { + const parts = usageParts(7, 0, String, null); + + expect(parts.primary).toBe('7'); + expect(parts.qualifiers).toEqual([]); + expect(parts.fraction).toBeNull(); + }); + + it('reports a breach past the limit', () => { + expect(usageParts(120, 100, String, null).fraction).toBe(1.2); + }); +}); + +describe('EVENT_METRICS', () => { + it('denominates SOSL rows only on a SOSL statement', () => { + const soslRows = EVENT_METRICS.find((metric) => metric.label === 'SOSL Rows'); + + expect(soslRows?.limit(limits, 'sosl')).toBe(2000); + expect(soslRows?.limit(limits, 'soql')).toBe(0); + expect(soslRows?.limit(limits)).toBe(0); + }); + + it('leaves throws and the two heap totals undenominated', () => { + const undenominated = EVENT_METRICS.filter((metric) => metric.limit(limits) === 0); + + expect(undenominated.map((metric) => metric.label)).toEqual([ + 'SOSL Rows', + 'Throws', + 'Heap net', + 'Heap alloc', + ]); + }); + + // The order is the reading order in every view, so a row cannot overtake another. + it('keeps a stable declaration order', () => { + expect(EVENT_METRICS.map((metric) => metric.label)).toEqual([ + 'SOQL', + 'SOQL Rows', + 'DML', + 'DML Rows', + 'SOSL', + 'SOSL Rows', + 'Throws', + 'Heap net', + 'Heap alloc', + ]); + }); +}); + +describe('HEAP_PEAK', () => { + it('measures against the heap governor limit', () => { + expect(HEAP_PEAK.pick({ heapPeak: 4_000_000 } as LogEvent)).toBe(4_000_000); + expect(HEAP_PEAK.limit(limits)).toBe(6_000_000); + }); +}); + +describe('formatBytes', () => { + it('separates thousands and keeps a negative net', () => { + expect(formatBytes(1_572_864)).toBe('1,572,864 bytes'); + expect(formatBytes(-2048)).toBe('-2,048 bytes'); + }); +}); diff --git a/log-viewer/src/core/metrics/eventMetrics.ts b/log-viewer/src/core/metrics/eventMetrics.ts new file mode 100644 index 00000000..9260ac35 --- /dev/null +++ b/log-viewer/src/core/metrics/eventMetrics.ts @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ +import type { GovernorLimits, LogEvent, SelfTotal } from 'apex-log-parser'; + +import { SOSL_ROWS_PER_QUERY_LIMIT } from '../../features/database/limits.js'; +import { formatInteger } from '../utility/Util.js'; + +/** Which statement a selection is, where that decides a metric's denominator. */ +export type StatementType = 'dml' | 'soql' | 'sosl'; + +export interface EventMetric { + label: string; + pick: (event: LogEvent) => SelfTotal; + /** The transaction limit this metric accumulates against; 0 when it has none. */ + limit: (limits: GovernorLimits, type?: StatementType) => number; + bytes?: boolean; + /** Throws only ever records on the leaf, so its self reading is meaningless. */ + hasSelf?: boolean; +} + +/** + * Every governor-tracked metric a frame reports, most important first. The `limit` + * is the row's denominator, so a metric is shown once — never as both a count and a + * separate "limit" row. + * + * The order is the reading order everywhere these appear. A view may drop metrics, + * and may rank them to decide which to drop, but renders what it keeps in this + * order — so a row never overtakes another as a selection or a hover moves. + */ +export const EVENT_METRICS: readonly EventMetric[] = [ + { label: 'SOQL', pick: (e) => e.soqlCount, limit: (l) => l.soqlQueries.limit }, + { label: 'SOQL Rows', pick: (e) => e.soqlRowCount, limit: (l) => l.queryRows.limit }, + { label: 'DML', pick: (e) => e.dmlCount, limit: (l) => l.dmlStatements.limit }, + { label: 'DML Rows', pick: (e) => e.dmlRowCount, limit: (l) => l.dmlRows.limit }, + { label: 'SOSL', pick: (e) => e.soslCount, limit: (l) => l.soslQueries.limit }, + { + label: 'SOSL Rows', + pick: (e) => e.soslRowCount, + // SOSL rows have no transaction total — the 2,000 cap is per query, so it + // only reads as a limit when a single SOSL statement is selected. + limit: (_limits, type) => (type === 'sosl' ? SOSL_ROWS_PER_QUERY_LIMIT : 0), + }, + { label: 'Throws', pick: (e) => e.thrownCount, limit: () => 0, hasSelf: false }, + { label: 'Heap net', pick: (e) => e.heapAllocated, limit: () => 0, bytes: true }, + { label: 'Heap alloc', pick: (e) => e.heapGross, limit: () => 0, bytes: true }, +]; + +/** + * Heap peak: the limit-comparable heap figure. It carries no self component, so it + * is not a {@link SelfTotal} and sits outside {@link EVENT_METRICS}, read last. + */ +export const HEAP_PEAK = { + label: 'Heap peak', + pick: (event: LogEvent): number => event.heapPeak, + limit: (limits: GovernorLimits): number => limits.heapSize.limit, + bytes: true, +} as const; + +/** Heap values are byte counts; a signed net value keeps its sign. */ +export function formatBytes(bytes: number): string { + return `${formatInteger(bytes)} bytes`; +} + +/** A metric reading, split so a caller can lay the parts out however it likes. */ +export interface UsageParts { + /** `used / limit`, or the count alone where there is no limit. */ + primary: string; + /** The percentage and any self reading — secondary, in reading order. */ + qualifiers: string[]; + /** Share of the limit as a fraction, or null where there is no limit. */ + fraction: number | null; +} + +/** + * `used / limit` with its derived percentage and any self reading, so the primary + * number reads first. Without a known limit there is no denominator, no percentage + * and no meter. + */ +export function usageParts( + total: number, + limit: number, + format: (value: number) => string, + self: string | null, +): UsageParts { + const fraction = limit > 0 ? total / limit : null; + return { + primary: limit > 0 ? `${format(total)} / ${format(limit)}` : format(total), + // Percentage first: it qualifies the ratio immediately before it. + qualifiers: [ + fraction !== null ? `${(fraction * 100).toFixed(2)}%` : null, + self && `self ${self}`, + ].filter((part): part is string => !!part), + fraction, + }; +} From 2127a4b10e137ba7b9a72accac634c0e0ef928b2 Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Tue, 25 Aug 2026 10:52:22 +0100 Subject: [PATCH 04/17] fix(log-viewer): key the legacy timeline by the groups it draws MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The legend read the modern categories through the legacy colour map, and Apex and Callout both map to Method — so it drew two chips in one colour, and named neither of them what the legacy chart's own key says. It now folds the categories that share a group and sums their self time, naming the six groups the chart draws. The fold is stated rather than inverted from LEGACY_CATEGORY_MAP: the draw order and the non-empty membership are facts a derivation loses, and inverting it would pull the canvas service — and the window it reads as it loads — into a pure util. A chip carries the categories it stands for, so data-category names something a reader can resolve. Under legacy the label is a group name and matches no category. A group missing from lana.timeline.colors also left a swatch unpainted, where setColors leaves the chart on its built-in colour. The legend reads that default too. --- log-viewer/src/components/categoryTime.ts | 6 ++- .../timeline/__tests__/TimelineKey.test.ts | 22 ++++++-- .../__tests__/category-self-time.test.ts | 38 ++++++++++++++ .../timeline/components/TimelineKey.ts | 15 ++++-- .../timeline/components/TimelineView.ts | 1 + .../timeline/utils/category-self-time.ts | 50 ++++++++++++++++--- 6 files changed, 115 insertions(+), 17 deletions(-) diff --git a/log-viewer/src/components/categoryTime.ts b/log-viewer/src/components/categoryTime.ts index 65662514..fb388416 100644 --- a/log-viewer/src/components/categoryTime.ts +++ b/log-viewer/src/components/categoryTime.ts @@ -10,7 +10,7 @@ import { } from 'lit'; import { subscribeSettings, type LanaSettings } from '../features/settings/Settings.js'; -import { LEGACY_CATEGORY_MAP } from '../features/timeline/services/Timeline.js'; +import { keyMap, LEGACY_CATEGORY_MAP } from '../features/timeline/services/Timeline.js'; import { addCustomThemes, getTheme } from '../features/timeline/themes/ThemeSelector.js'; import { CATEGORY_THEME_KEY, DEFAULT_THEME_NAME } from '../features/timeline/themes/Themes.js'; @@ -88,7 +88,9 @@ export function categoryPalette( if (timeline?.legacy) { return (category) => { const group = LEGACY_CATEGORY_MAP[category]; - return group ? timeline.colors[group] : OTHER_COLOR; + // `setColors` skips a group the setting omits, leaving the chart on its built-in + // colour, so that default has to be readable here too. + return (group && (timeline.colors[group] || keyMap.get(group)?.fillColor)) || OTHER_COLOR; }; } if (timeline) { diff --git a/log-viewer/src/features/timeline/__tests__/TimelineKey.test.ts b/log-viewer/src/features/timeline/__tests__/TimelineKey.test.ts index db832f52..551ea4d3 100644 --- a/log-viewer/src/features/timeline/__tests__/TimelineKey.test.ts +++ b/log-viewer/src/features/timeline/__tests__/TimelineKey.test.ts @@ -26,8 +26,13 @@ function chips(el: Timelinekey): HTMLElement[] { describe('TimelineKey', () => { it('renders one chip per entry, with swatch color, label and data-category', async () => { const el = await mount([ - { label: 'Apex', fillColor: 'rgb(43, 143, 129)', selfTimeNs: 12_100_000_000 }, - { label: 'SOQL', fillColor: 'rgb(109, 76, 125)', selfTimeNs: 500_000 }, + { + label: 'Apex', + fillColor: 'rgb(43, 143, 129)', + categories: ['Apex'], + selfTimeNs: 12_100_000_000, + }, + { label: 'SOQL', fillColor: 'rgb(109, 76, 125)', categories: ['SOQL'], selfTimeNs: 500_000 }, ]); const rendered = chips(el); @@ -41,20 +46,27 @@ describe('TimelineKey', () => { it('shows the compact self time when present', async () => { const el = await mount([ - { label: 'Apex', fillColor: 'rgb(0, 0, 0)', selfTimeNs: 12_100_000_000 }, + { + label: 'Apex', + fillColor: 'rgb(0, 0, 0)', + categories: ['Apex'], + selfTimeNs: 12_100_000_000, + }, ]); expect(chips(el)[0]?.querySelector('.chip__time')?.textContent).toBe('12.1s'); }); it('omits the time when self time is unknown', async () => { - const el = await mount([{ label: 'Method', fillColor: 'rgb(0, 0, 0)' }]); + const el = await mount([{ label: 'Method', fillColor: 'rgb(0, 0, 0)', categories: ['Apex'] }]); expect(chips(el)[0]?.querySelector('.chip__time')).toBeNull(); }); it('keeps the chip itself unfilled — only the swatch carries the category color', async () => { - const el = await mount([{ label: 'DML', fillColor: 'rgb(176, 104, 104)' }]); + const el = await mount([ + { label: 'DML', fillColor: 'rgb(176, 104, 104)', categories: ['DML'] }, + ]); expect(chips(el)[0]?.getAttribute('style')).toBeNull(); }); diff --git a/log-viewer/src/features/timeline/__tests__/category-self-time.test.ts b/log-viewer/src/features/timeline/__tests__/category-self-time.test.ts index e311b6e3..c2976276 100644 --- a/log-viewer/src/features/timeline/__tests__/category-self-time.test.ts +++ b/log-viewer/src/features/timeline/__tests__/category-self-time.test.ts @@ -108,4 +108,42 @@ describe('toTimelineKeys', () => { expect(keys.find((k) => k.label === 'DML')?.selfTimeNs).toBe(0); expect(keys.find((k) => k.label === 'Callout')?.selfTimeNs).toBe(0); }); + + describe('the legacy chart', () => { + /** Its key names 6 groups, not the 7 categories, and draws them in this order. */ + it('names the groups the legacy chart draws', () => { + expect(toTimelineKeys(color, undefined, true).map((k) => k.label)).toEqual([ + 'Method', + 'Code Unit', + 'System Method', + 'Workflow', + 'DML', + 'SOQL', + ]); + }); + + /** + * Apex and Callout are both `Method` there. Left unfolded the legend showed two + * chips of one colour, naming neither of them what the chart's own key says. + * Validation folds into System Method the same way, once the parser reports it. + */ + it('folds the categories that share one group, and sums their self time', () => { + const keys = toTimelineKeys( + color, + new Map([ + ['Apex', 15], + ['Callout', 5], + ['System', 3], + ['Validation', 4], + ]), + true, + ); + + const method = keys.find((k) => k.label === 'Method'); + expect(method?.selfTimeNs).toBe(20); + expect(method?.fillColor).toBe('#a1'); + expect(method?.categories).toEqual(['Apex', 'Callout']); + expect(keys.find((k) => k.label === 'System Method')?.selfTimeNs).toBe(7); + }); + }); }); diff --git a/log-viewer/src/features/timeline/components/TimelineKey.ts b/log-viewer/src/features/timeline/components/TimelineKey.ts index de146478..929b05ef 100644 --- a/log-viewer/src/features/timeline/components/TimelineKey.ts +++ b/log-viewer/src/features/timeline/components/TimelineKey.ts @@ -1,6 +1,7 @@ /* * Copyright (c) 2023 Certinia Inc. All rights reserved. */ +import type { LogCategory } from 'apex-log-parser'; import { LitElement, css, html } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { repeat } from 'lit/directives/repeat.js'; @@ -14,11 +15,16 @@ import '../../../components/OverflowList.js'; // styles import { globalStyles } from '../../../styles/global.styles.js'; -/** One legend chip: category color dot, label, and (when known) the log's self time in that category. */ +/** One legend chip: colour dot, label, and (when known) the log's self time under it. */ export interface TimelineKeyEntry { label: string; fillColor: string; - /** Total self time (ns) spent in this category; omitted on the legacy timeline. */ + /** + * The categories this chip stands for. Usually the one the label names, but the legacy + * chart folds several into a group, and its label is then no category at all. + */ + categories: readonly LogCategory[]; + /** Total self time (ns) summed over {@link categories}; omitted where no log is loaded. */ selfTimeNs?: number; } @@ -59,8 +65,9 @@ export class Timelinekey extends LitElement { this.timelineKeys, (entry) => entry.label, (entry) => - // data-category is the seam for the interactivity follow-up (hover/click → highlight). - html` + // The seam for the interactivity follow-up (hover/click → highlight): the + // categories to match on, not the label, which names no category under legacy. + html` ${entry.label} ${ diff --git a/log-viewer/src/features/timeline/components/TimelineView.ts b/log-viewer/src/features/timeline/components/TimelineView.ts index 109b4bfa..ff67c565 100644 --- a/log-viewer/src/features/timeline/components/TimelineView.ts +++ b/log-viewer/src/features/timeline/components/TimelineView.ts @@ -329,6 +329,7 @@ export class TimelineView extends LitElement { timeline && { ...timeline, activeTheme: this.activeTheme ?? timeline.activeTheme }, ), this.selfTimes, + timeline?.legacy, ); } diff --git a/log-viewer/src/features/timeline/utils/category-self-time.ts b/log-viewer/src/features/timeline/utils/category-self-time.ts index c08e64be..ce2bc525 100644 --- a/log-viewer/src/features/timeline/utils/category-self-time.ts +++ b/log-viewer/src/features/timeline/utils/category-self-time.ts @@ -4,6 +4,7 @@ import { LOG_CATEGORY, type ApexLog, type LogCategory, type LogEvent } from 'apex-log-parser'; import type { TimelineKeyEntry } from '../components/TimelineKey.js'; +import type { LegacyTimelineGroup } from '../services/Timeline.js'; /** * Sums self time (ns) per category across the whole event tree. Self time partitions @@ -24,6 +25,13 @@ export function categorySelfTimes(root: ApexLog): Map { return totals; } +/** One legend chip: what it is called, and the categories whose self time it sums. */ +interface KeyGroup { + label: string; + /** Never empty — the first member is what resolves the chip's colour. */ + members: readonly [LogCategory, ...LogCategory[]]; +} + /** Legend order; the labels double as the `LogCategory` keys `categorySelfTimes` sums by. */ const KEY_CATEGORIES: readonly LogCategory[] = [ LOG_CATEGORY.Apex, @@ -37,18 +45,48 @@ const KEY_CATEGORIES: readonly LogCategory[] = [ ]; /** - * Builds the legend entries, attaching per-category self time when known. The colour - * comes from the caller so the legend reads the same palette the chart drew with — + * The legacy chart's own key: the six groups it draws, in that order, each naming the + * categories it folds. Several categories share a group there — Apex and Callout are + * both `Method` — so the legend must fold them, or it names one colour twice and names + * it something the chart's key never says. + * + * Must agree with `LEGACY_CATEGORY_MAP`, which maps the same pairs the other way. + * Stated rather than inverted from it: the draw order and the non-empty membership are + * both facts a derivation loses, and inverting it would pull the canvas service — and + * the `window` it reads as it loads — into this module. + */ +const LEGACY_GROUPS: readonly (KeyGroup & { label: LegacyTimelineGroup })[] = [ + { label: 'Method', members: ['Apex', 'Callout'] }, + { label: 'Code Unit', members: ['Code Unit'] }, + { label: 'System Method', members: ['System', 'Validation'] }, + { label: 'Workflow', members: ['Automation'] }, + { label: 'DML', members: ['DML'] }, + { label: 'SOQL', members: ['SOQL'] }, +]; + +/** + * Builds the legend entries, attaching per-group self time when known. The colour comes + * from the caller so the legend reads the same palette the chart drew with — * `categoryPalette` answers for both the themes and the legacy colours. + * @param legacy - True to key the legacy chart, whose groups differ from the categories. */ export function toTimelineKeys( color: (category: string) => string, selfTimes?: Map, + legacy = false, ): TimelineKeyEntry[] { - return KEY_CATEGORIES.map((category) => ({ - label: category, - fillColor: color(category), + const groups: readonly KeyGroup[] = legacy + ? LEGACY_GROUPS + : KEY_CATEGORIES.map((category) => ({ label: category, members: [category] as const })); + + return groups.map(({ label, members }) => ({ + label, + categories: members, + // Any member resolves to the group's colour, which is what makes the fold safe. + fillColor: color(members[0]), // A category the log never used still reads 0 — an absent time means "unknown", not "none". - selfTimeNs: selfTimes ? (selfTimes.get(category) ?? 0) : undefined, + selfTimeNs: selfTimes + ? members.reduce((sum, category) => sum + (selfTimes.get(category) ?? 0), 0) + : undefined, })); } From ceb97aa917d8be76d04f88d749f461767425701a Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Tue, 25 Aug 2026 11:03:40 +0100 Subject: [PATCH 05/17] feat(log-viewer): rebuild the timeline hover card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The card measured every reading against a governor limit. The hover is for deciding where to look, so it now measures a branch against the log's own total — 3 of 30, spelled "of" so it cannot read as a cap. Risk against limits stays with the governor strip and the Inspector. frameTooltipCard.ts holds what the card says and in what order, with no DOM, so it reads in a test without laying a panel out. The renderer keeps the DOM, the timers, the placement and the query budget. Rows are three columns: label, reading, and the frame's own figure. A self reading of zero stays — it is what says the work happened in a descendant. The duration and the byte readings state no denominator, because the unit follows the value and the pair would not compare. Six metric rows at most, the rest counted in the footer and ranked by the branch's share, so the choice holds as the pointer moves. A heap peak is unranked: it composes by max, so every frame spanning the transaction's peak reports the root's figure and would outrank every summed metric. A frame's name is its description block; category, type, namespace and call site read on one identity line. The row that the hover was for carries the card's emphasis, so a frame with no duration no longer puts it on the wall clock. The marker rail derives from MARKER_COLORS instead of restating it, the self reading is spelled once in eventMetrics, and usageParts drops the fraction nothing consumed once the card stopped metering. --- .../metrics/__tests__/eventMetrics.test.ts | 8 +- log-viewer/src/core/metrics/eventMetrics.ts | 13 +- .../__tests__/frameTooltipCard.test.ts | 293 +++++++++++++ .../timeline/__tests__/tooltip.test.ts | 249 +++++++----- .../optimised/FrameTooltipRenderer.ts | 384 +++++------------- .../timeline/optimised/frameTooltipCard.ts | 262 ++++++++++++ .../features/timeline/styles/timeline.css.ts | 155 ++++--- 7 files changed, 904 insertions(+), 460 deletions(-) create mode 100644 log-viewer/src/features/timeline/__tests__/frameTooltipCard.test.ts create mode 100644 log-viewer/src/features/timeline/optimised/frameTooltipCard.ts diff --git a/log-viewer/src/core/metrics/__tests__/eventMetrics.test.ts b/log-viewer/src/core/metrics/__tests__/eventMetrics.test.ts index 49b89344..c3d3d638 100644 --- a/log-viewer/src/core/metrics/__tests__/eventMetrics.test.ts +++ b/log-viewer/src/core/metrics/__tests__/eventMetrics.test.ts @@ -21,20 +21,18 @@ describe('usageParts', () => { expect(parts.primary).toBe('3 / 100'); expect(parts.qualifiers).toEqual(['3.00%', 'self 1']); - expect(parts.fraction).toBe(0.03); }); - // No denominator means no share of anything, so nothing to meter. - it('gives the count alone, and no fraction, where there is no limit', () => { + // No denominator means no share of anything, so nothing to qualify. + it('gives the count alone, and no percentage, where there is no limit', () => { const parts = usageParts(7, 0, String, null); expect(parts.primary).toBe('7'); expect(parts.qualifiers).toEqual([]); - expect(parts.fraction).toBeNull(); }); it('reports a breach past the limit', () => { - expect(usageParts(120, 100, String, null).fraction).toBe(1.2); + expect(usageParts(120, 100, String, null).qualifiers).toEqual(['120.00%']); }); }); diff --git a/log-viewer/src/core/metrics/eventMetrics.ts b/log-viewer/src/core/metrics/eventMetrics.ts index 9260ac35..a933b935 100644 --- a/log-viewer/src/core/metrics/eventMetrics.ts +++ b/log-viewer/src/core/metrics/eventMetrics.ts @@ -62,20 +62,22 @@ export function formatBytes(bytes: number): string { return `${formatInteger(bytes)} bytes`; } +/** A frame's own share of a reading, named the one way every view names it. */ +export function selfLabel(self: string): string { + return `self ${self}`; +} + /** A metric reading, split so a caller can lay the parts out however it likes. */ export interface UsageParts { /** `used / limit`, or the count alone where there is no limit. */ primary: string; /** The percentage and any self reading — secondary, in reading order. */ qualifiers: string[]; - /** Share of the limit as a fraction, or null where there is no limit. */ - fraction: number | null; } /** * `used / limit` with its derived percentage and any self reading, so the primary - * number reads first. Without a known limit there is no denominator, no percentage - * and no meter. + * number reads first. Without a known limit there is no denominator and no percentage. */ export function usageParts( total: number, @@ -89,8 +91,7 @@ export function usageParts( // Percentage first: it qualifies the ratio immediately before it. qualifiers: [ fraction !== null ? `${(fraction * 100).toFixed(2)}%` : null, - self && `self ${self}`, + self && selfLabel(self), ].filter((part): part is string => !!part), - fraction, }; } diff --git a/log-viewer/src/features/timeline/__tests__/frameTooltipCard.test.ts b/log-viewer/src/features/timeline/__tests__/frameTooltipCard.test.ts new file mode 100644 index 00000000..f2b75361 --- /dev/null +++ b/log-viewer/src/features/timeline/__tests__/frameTooltipCard.test.ts @@ -0,0 +1,293 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ +import { describe, expect, it } from '@jest/globals'; +import type { ApexLog, LogEvent } from 'apex-log-parser'; + +import { EVENT_METRICS, HEAP_PEAK } from '../../../core/metrics/eventMetrics.js'; + +import { + frameCard, + markerCard, + MAX_METRIC_ROWS, + type CardRow, + type TooltipCard, +} from '../optimised/frameTooltipCard.js'; +import type { TimelineMarker } from '../types/flamechart.types.js'; + +/** + * The log every branch is measured against. Ten times the fixture frame throughout, so + * the fixture's readings come out at a tenth and a branch holding all of something + * reads as the whole log. + */ +const logTotals = { + duration: { total: 10_000_000, self: 0 }, + soqlCount: { total: 30, self: 0 }, + soqlRowCount: { total: 5_000, self: 0 }, + dmlCount: { total: 10, self: 0 }, + dmlRowCount: { total: 1_000, self: 0 }, + soslCount: { total: 10, self: 0 }, + soslRowCount: { total: 100, self: 0 }, + thrownCount: { total: 20, self: 0 }, + heapAllocated: { total: 10_000, self: 0 }, + heapGross: { total: 20_000, self: 0 }, + heapPeak: 6_000_000, +}; + +const apexLog = { startTime: null, timestamp: 0, ...logTotals } as unknown as ApexLog; + +/** 10:29:24.600 at the first event, so a 1ms frame ends a millisecond later. */ +const withStart = { startTime: 37_764_600, timestamp: 0, ...logTotals } as unknown as ApexLog; + +function event(over: Record = {}): LogEvent { + return { + isParent: true, + type: 'METHOD_ENTRY', + category: 'Apex', + text: 'MyClass.myMethod()', + namespace: '', + lineNumber: null, + timestamp: 0, + exitStamp: 1_000_000, + duration: { total: 1_000_000, self: 400_000 }, + soqlCount: { total: 0, self: 0 }, + soqlRowCount: { total: 0, self: 0 }, + dmlCount: { total: 0, self: 0 }, + dmlRowCount: { total: 0, self: 0 }, + soslCount: { total: 0, self: 0 }, + soslRowCount: { total: 0, self: 0 }, + thrownCount: { total: 0, self: 0 }, + heapAllocated: { total: 0, self: 0 }, + heapGross: { total: 0, self: 0 }, + heapPeak: 0, + ...over, + } as unknown as LogEvent; +} + +function card(over: Record = {}): TooltipCard { + return frameCard(event(over), '#88ae58', apexLog); +} + +/** The row with this label, wherever it sits. */ +function row(built: TooltipCard, label: string): CardRow | undefined { + return built.groups.flat().find((candidate) => candidate.label === label); +} + +/** The labels of the metric group — the one the cap applies to. */ +function metricLabels(built: TooltipCard): string[] { + return (built.groups.at(1) ?? []).map((line) => line.label); +} + +/** Every metric non-zero, so the cap has to choose. */ +const crowded = { + soqlCount: { total: 1, self: 1 }, + soqlRowCount: { total: 4_000, self: 4_000 }, + dmlCount: { total: 1, self: 1 }, + dmlRowCount: { total: 800, self: 800 }, + soslCount: { total: 1, self: 1 }, + soslRowCount: { total: 80, self: 80 }, + thrownCount: { total: 2, self: 0 }, + heapAllocated: { total: 100, self: 100 }, + heapGross: { total: 200, self: 200 }, + heapPeak: 5_000_000, +}; + +describe('frameCard', () => { + /** No log figure: the chart already shows the log's span, and the counts do not. */ + it("leads with the duration and the frame's own share of it", () => { + expect(row(card(), 'Time')).toEqual({ + label: 'Time', + value: '1 ms', + self: '0.4 ms', + // Unranked: the timing row is its own group, so the cap never weighs it. + share: null, + // The card's emphasis follows the row, not its position: a frame with no duration + // would otherwise put it on the wall clock. + lead: true, + }); + }); + + /** + * In the label, not a figure column: the columns are a fixed width and the word ran + * into the reading beside it. Common on database frames, so it has to fit. + */ + it('names free time in the label', () => { + expect(row(card({ cpuType: 'free' }), 'Time · free')?.value).toBe('1 ms'); + }); + + it('has no timing row for a frame that never exited', () => { + expect(row(card({ exitStamp: undefined }), 'Time')).toBeUndefined(); + }); + + /** The line is the call site in the containing code, so it reads as where the call + * came from rather than as where the frame is defined. */ + it('names the category, type, namespace and call site on one identity line', () => { + expect(card({ namespace: 'acme', lineNumber: 42 }).identity).toEqual([ + 'Apex', + 'METHOD_ENTRY', + 'acme', + 'from line 42', + ]); + }); + + it('keeps a line number the parser could not resolve as it found it', () => { + expect(card({ lineNumber: 'EXTERNAL' }).identity).toContain('EXTERNAL'); + }); + + it('paints the rail in the category colour', () => { + expect(card().rail).toBe('#88ae58'); + }); + + /** + * The branch's share of the log, never of a governor limit: the reading states no + * denominator at all, so the meter cannot be read as governor pressure. + */ + it("reads a metric against the log's own figure", () => { + expect(row(card({ soqlCount: { total: 3, self: 1 } }), 'SOQL')).toEqual({ + label: 'SOQL', + value: '3 of 30', + self: '1', + share: 0.1, + }); + }); + + it('states the count alone where there is no log to read it against', () => { + const built = frameCard(event({ soqlCount: { total: 3, self: 1 } }), '', null); + + expect(row(built, 'SOQL')?.value).toBe('3'); + expect(row(built, 'SOQL')?.share).toBeNull(); + }); + + /** + * The unit follows the value, so a pair would read "100 bytes of 6 MB" — the same + * mismatch that keeps the log's span off the timing row. + */ + it('states a byte reading alone, with no log figure beside it', () => { + const heap = row(card({ heapAllocated: { total: 1_000, self: 500 } }), 'Heap net'); + + expect(heap?.value).toBe('1 KB'); + expect(heap?.self).toBe('500 bytes'); + // Still ranked, so the cap weighs it against the counts. + expect(heap?.share).toBe(0.1); + }); + + it('reads the heap peak alone: a max is no share of a total', () => { + const peak = row(card({ heapPeak: 3_000_000 }), 'Heap peak'); + + expect(peak?.value).toBe('3 MB'); + expect(peak?.share).toBeNull(); + }); + + // A share of a signed net says nothing, so the log's figure is left off. + it('states a negative net heap alone', () => { + const heap = row(card({ heapAllocated: { total: -500, self: -500 } }), 'Heap net'); + + expect(heap?.value).toBe('-500 bytes'); + expect(heap?.share).toBeNull(); + }); + + /** + * A zero self reading is the answer, not noise: it says the statements ran in a + * descendant rather than in this frame. + */ + it('keeps a self reading of zero', () => { + expect(row(card({ dmlRowCount: { total: 100, self: 0 } }), 'DML Rows')?.self).toBe('0'); + }); + + // Throws only ever record on the leaf, so a self reading would say nothing. + it('gives throws no self reading', () => { + expect(row(card({ thrownCount: { total: 2, self: 0 } }), 'Throws')?.self).toBeNull(); + }); + + it('gives the wall clock the self column, having no figure to line up', () => { + const built = frameCard(event(), '', withStart); + + expect(row(built, 'Wall clock')).toEqual({ + label: 'Wall clock', + value: '10:29:24.600 → 10:29:24.601', + self: null, + share: null, + wide: true, + }); + }); + + // Reference data, not what the hover asked, so it reads after the metrics. + it('reads the clock after the metrics', () => { + const built = frameCard(event({ soqlCount: { total: 3, self: 1 } }), '', withStart); + + expect(built.groups.map((group) => group.map((line) => line.label))).toEqual([ + ['Time'], + ['SOQL'], + ['Wall clock'], + ]); + }); + + it('has no wall-clock row where the log records no start time', () => { + expect(row(card(), 'Wall clock')).toBeUndefined(); + }); + + describe('the row cap', () => { + it('keeps every metric while they fit', () => { + expect(card({ soqlCount: { total: 3, self: 1 } }).hidden).toBe(0); + }); + + it('drops the least answerable past the cap, and counts what it dropped', () => { + const built = card(crowded); + + // The timing group is never capped; the metric group is. + expect(built.groups.at(1)).toHaveLength(MAX_METRIC_ROWS); + expect(built.hidden).toBe(4); + }); + + // A throw is rare and never incidental, so it outranks any share. + it('never drops a throw', () => { + expect(metricLabels(card(crowded))).toContain('Throws'); + }); + + it('keeps the metrics this branch holds most of', () => { + expect(metricLabels(card(crowded))).toContain('SOQL Rows'); + }); + + /** + * The share decides what survives; declaration order decides where it sits. + * Without that a row would overtake another as the pointer moved between frames. + */ + it('renders the survivors in declaration order, whatever their share', () => { + const kept = metricLabels(card(crowded)); + + expect(kept).toEqual([...kept].sort(byDeclaration)); + }); + }); +}); + +const ORDER = [...EVENT_METRICS.map((metric) => metric.label), HEAP_PEAK.label]; +const byDeclaration = (a: string, b: string) => ORDER.indexOf(a) - ORDER.indexOf(b); + +describe('markerCard', () => { + function marker(over: Partial = {}): TimelineMarker { + return { + id: 'm1', + type: 'exception', + summary: 'System.NullPointerException', + startTime: 1_000_000, + ...over, + } as TimelineMarker; + } + + it('leads with the summary and takes the rail colour it is given', () => { + const built = markerCard(marker(), '#e5484d'); + + expect(built.title).toBe('System.NullPointerException'); + expect(built.rail).toBe('#e5484d'); + }); + + it('reports how long a marker spans, where it spans anything', () => { + expect(markerCard(marker({ endTime: 3_000_000 }), '#e5484d').groups).toEqual([ + [{ label: 'Spans', value: '2 ms', self: null, share: null, lead: true }], + ]); + }); + + it('has no span row for a marker at a point in time', () => { + expect(markerCard(marker(), '#e5484d').groups).toEqual([]); + }); +}); diff --git a/log-viewer/src/features/timeline/__tests__/tooltip.test.ts b/log-viewer/src/features/timeline/__tests__/tooltip.test.ts index 9cbea416..e68e6307 100644 --- a/log-viewer/src/features/timeline/__tests__/tooltip.test.ts +++ b/log-viewer/src/features/timeline/__tests__/tooltip.test.ts @@ -16,9 +16,13 @@ * - The on/off switch */ import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals'; -import type { LogEvent } from 'apex-log-parser'; +import type { ApexLog, LogEvent } from 'apex-log-parser'; -import { FrameTooltipRenderer, type TooltipAnchor } from '../optimised/FrameTooltipRenderer.js'; +import { + FrameTooltipRenderer, + type TooltipAnchor, + type TooltipOptions, +} from '../optimised/FrameTooltipRenderer.js'; /** Delay before the first tooltip appears; mirrors SHOW_DELAY_MS. */ const SHOW_DELAY_MS = 60; @@ -60,9 +64,42 @@ describe('FrameTooltipRenderer', () => { soslRowCount: { total: 0, self: 0 }, thrownCount: { total: 0, self: 0 }, heapAllocated: { total: 0, self: 0 }, + heapGross: { total: 0, self: 0 }, + heapPeak: 0, + namespace: '', } as unknown as LogEvent; } + /** A log for the branch shares to divide by. */ + function logOf(over: Record = {}): ApexLog { + return { + startTime: null, + timestamp: 0, + duration: { total: 15_000_000, self: 0 }, + soqlCount: { total: 0, self: 0 }, + soqlRowCount: { total: 0, self: 0 }, + dmlCount: { total: 0, self: 0 }, + dmlRowCount: { total: 0, self: 0 }, + soslCount: { total: 0, self: 0 }, + soslRowCount: { total: 0, self: 0 }, + thrownCount: { total: 0, self: 0 }, + heapAllocated: { total: 0, self: 0 }, + heapGross: { total: 0, self: 0 }, + heapPeak: 0, + ...over, + } as unknown as ApexLog; + } + + /** Replaces the renderer, so a test can state only the option it cares about. */ + function rebuild(options: Partial = {}): void { + frameTooltipRenderer.destroy(); + frameTooltipRenderer = new FrameTooltipRenderer(container, { + categoryColors: {}, + cursorOffset: 10, + ...options, + }); + } + /** An anchor with no frame rect, so the tooltip falls back to cursor placement. */ function cursorAnchor(cursorX: number, cursorY: number): TooltipAnchor { return { rect: null, chartTopY: 0, cursorX, cursorY }; @@ -77,6 +114,27 @@ describe('FrameTooltipRenderer', () => { return { rect, chartTopY, cursorX, cursorY: rect.y }; } + /** The reading in the row with this label, or undefined when there is no such row. */ + function rowValue(label: string): string | undefined { + return row(label)?.querySelector('.tooltip-value')?.textContent ?? undefined; + } + + /** The frame's own reading in the row with this label. */ + function rowSelf(label: string): string | undefined { + return row(label)?.querySelector('.tooltip-self')?.textContent ?? undefined; + } + + function row(label: string): Element | undefined { + return [...container.querySelectorAll('.tooltip-row')].find( + (candidate) => candidate.querySelector('.tooltip-label')?.textContent === label, + ); + } + + /** The `·`-joined identity line. */ + function identity(): string | undefined { + return container.querySelector('.tooltip-identity')?.textContent ?? undefined; + } + function tooltipEl(): HTMLElement { return container.querySelector('#timeline-tooltip') as HTMLElement; } @@ -297,29 +355,12 @@ describe('FrameTooltipRenderer', () => { expect(tooltipEl().textContent).toContain('SOQL query execution'); }); - it('should display duration in milliseconds', () => { - // Duration: 1,500,000 ns = 1.5ms - showSettled(createEvent(0, 1_500_000), cursorAnchor(100, 100)); - - // Duration is formatted by formatDuration helper - expect(tooltipEl().textContent).toContain('ms'); - }); - - it('should display self duration', () => { - // Self duration: 50% of total = 750,000 ns = 0.75ms - showSettled(createEvent(0, 1_500_000), cursorAnchor(100, 100)); - - expect(tooltipEl().textContent).toContain('self'); - expect(tooltipEl().textContent).toContain('0.75'); - }); - - it('should display total duration', () => { - // Timestamp: 2,000,000 ns = 2.000ms, duration: 100,000 ns = 0.1ms + it("should lead with the duration and the frame's own share of it", () => { + // Timestamp: 2,000,000 ns = 2.000ms, duration: 100,000 ns = 0.1ms, self 50%. showSettled(createEvent(2_000_000, 100_000), cursorAnchor(100, 100)); - expect(tooltipEl().textContent).toContain('total'); - // Check for some duration value (format may vary) - expect(tooltipEl().textContent).toContain('ms'); + expect(rowValue('Time')).toBe('0.1 ms'); + expect(rowSelf('Time')).toBe('self 0.05 ms'); }); it('should display a Throws row with the total (no self) when exceptions were thrown', () => { @@ -328,8 +369,7 @@ describe('FrameTooltipRenderer', () => { showSettled(event, cursorAnchor(100, 100)); - expect(tooltipEl().textContent).toContain('Throws:'); - expect(tooltipEl().textContent).toContain('3'); + expect(rowValue('Throws')).toBe('3'); // self is intentionally omitted for throws (always 0 on a method). expect(tooltipEl().textContent).not.toContain('self 1'); }); @@ -340,18 +380,47 @@ describe('FrameTooltipRenderer', () => { showSettled(event, cursorAnchor(100, 100)); - expect(tooltipEl().textContent).not.toContain('Throws:'); + expect(rowValue('Throws')).toBeUndefined(); + }); + + /** + * The card measures a branch against the log, never against a governor limit: a + * denominator here would be the transaction's fact rather than the frame's, and it + * would read as governor pressure when it is not. + */ + it("should read the branch against the log's own figure, not a governor limit", () => { + rebuild({ apexLog: logOf({ soslRowCount: { total: 1000, self: 1000 } }) }); + const event = createEvent(0, 1_500_000, 'SOSL_EXECUTE_BEGIN'); + event.soslRowCount = { total: 500, self: 500 }; + + showSettled(event, cursorAnchor(100, 100)); + + expect(rowValue('SOSL Rows')).toBe('500 of 1,000'); + expect(rowSelf('SOSL Rows')).toBe('self 500'); }); - it('should display a lowercase net heap row as total (self N), thousand-separated', () => { + /** Spelled on every row: with no header line the figure has to name itself. */ + it('should name the self reading on every row', () => { + const event = createEvent(0, 1_500_000); + event.soqlCount = { total: 3, self: 1 }; + event.dmlCount = { total: 2, self: 0 }; + + showSettled(event, cursorAnchor(100, 100)); + + expect(rowSelf('SOQL')).toBe('self 1'); + expect(rowSelf('DML')).toBe('self 0'); + }); + + it("should read net heap compactly, with the method's own share", () => { const event = createEvent(0, 1_500_000); event.heapAllocated = { self: 1_572_864, total: 4_000_000 }; showSettled(event, cursorAnchor(100, 100)); - expect(tooltipEl().textContent).toContain('heap:'); - // Net subtree total (with the byte unit) and the method's own net in parens. - expect(tooltipEl().textContent).toContain('4,000,000 bytes (self 1,572,864)'); + // Net subtree total and the method's own net. The card is width-bound, so bytes + // read compactly here where the inspector separates thousands. + expect(rowValue('Heap net')).toBe('4 MB'); + expect(rowSelf('Heap net')).toBe('self 1.6 MB'); }); it('should not display a heap row when net heap is 0 (allocated then freed)', () => { @@ -360,7 +429,7 @@ describe('FrameTooltipRenderer', () => { showSettled(event, cursorAnchor(100, 100)); - expect(tooltipEl().textContent).not.toContain('heap:'); + expect(rowValue('Heap net')).toBeUndefined(); }); it('should display custom event text', () => { @@ -395,74 +464,58 @@ describe('FrameTooltipRenderer', () => { expect(tooltipEl().querySelector('script')).toBeNull(); }); - it('should display a category row with a swatch in the category color', () => { - frameTooltipRenderer.destroy(); - frameTooltipRenderer = new FrameTooltipRenderer(container, { - categoryColors: { Apex: '#88ae58' }, - cursorOffset: 10, - }); + it('should name the category on the identity line and paint the rail with it', () => { + rebuild({ categoryColors: { Apex: '#88ae58' } }); showSettled(createEvent(0, 100, 'Event', 'Apex'), cursorAnchor(100, 100)); - const swatch = container.querySelector('color-swatch'); - expect(swatch).not.toBeNull(); - expect(swatch?.color).toBe('#88ae58'); - expect(swatch?.parentElement?.textContent).toContain('Apex'); + expect(identity()).toBe('Apex · Event · from line 42'); + const body = container.querySelector('.timeline-tooltip'); + expect(body?.style.borderColor).toBe('rgb(136, 174, 88)'); }); - it('should not display a category row for an uncategorised event', () => { + it('should leave the category off the identity line for an uncategorised event', () => { showSettled(createEvent(0, 100, 'Event', ''), cursorAnchor(100, 100)); - expect(container.querySelector('color-swatch')).toBeNull(); + expect(identity()).toBe('Event · from line 42'); }); - it('should display wall-clock time row when apexLog has startTime', () => { - frameTooltipRenderer.destroy(); - const mockApexLog = { - startTime: 37764600, // 10:29:24.600 - timestamp: 6329577, // first event nanosecond offset - governorLimits: { - dmlStatements: { limit: 150 }, - dmlRows: { limit: 10000 }, - soqlQueries: { limit: 100 }, - queryRows: { limit: 50000 }, - soslQueries: { limit: 20 }, - }, - }; + it('should name the namespace only where it is not the default', () => { + const event = createEvent(0, 100, 'Event', 'Apex'); + event.namespace = 'acme'; - frameTooltipRenderer = new FrameTooltipRenderer(container, { - categoryColors: {}, - cursorOffset: 10, - apexLog: mockApexLog as never, - }); + showSettled(event, cursorAnchor(100, 100)); + + expect(identity()).toBe('Apex · Event · acme · from line 42'); + }); + + it('should display wall-clock time row when apexLog has startTime', () => { + // 10:29:24.600 at the first event, whose nanosecond offset is 6329577. + rebuild({ apexLog: logOf({ startTime: 37_764_600, timestamp: 6_329_577 }) }); // Event at timestamp 6329577ns with duration 1,000,000ns showSettled(createEvent(6329577, 1_000_000), cursorAnchor(100, 100)); - expect(tooltipEl().textContent).toContain('time:'); - expect(tooltipEl().textContent).toContain('10:29:24.600'); - // End time should be ~1ms later - expect(tooltipEl().textContent).toContain('10:29:24.601'); - expect(tooltipEl().textContent).toContain('→'); + expect(rowValue('Wall clock')).toBe('10:29:24.600 → 10:29:24.601'); + // A clock range is wider than the figure columns, so a row inside them would run + // off the panel's edge. + const clock = row('Wall clock'); + expect(clock?.classList.contains('tooltip-row--wide')).toBe(true); + expect(clock?.querySelector('.tooltip-self')).toBeNull(); }); it('should not display wall-clock time row when apexLog has no startTime', () => { - frameTooltipRenderer.destroy(); - frameTooltipRenderer = new FrameTooltipRenderer(container, { - categoryColors: {}, - cursorOffset: 10, - apexLog: { startTime: null, timestamp: 0 } as never, - }); + rebuild({ apexLog: logOf() }); showSettled(createEvent(0, 1_000_000), cursorAnchor(100, 100)); - expect(tooltipEl().textContent).not.toContain('time:'); + expect(rowValue('Wall clock')).toBeUndefined(); }); it('should not display wall-clock time row when no apexLog', () => { showSettled(createEvent(0, 1_000_000), cursorAnchor(100, 100)); - expect(tooltipEl().textContent).not.toContain('time:'); + expect(rowValue('Wall clock')).toBeUndefined(); }); }); @@ -485,7 +538,7 @@ describe('FrameTooltipRenderer', () => { it('should fit a long query to the budget and count the conditions it left out', () => { showSettled(soqlEvent(longQuery(40)), cursorAnchor(100, 100)); - const preview = container.querySelector('.tooltip-header.soql-block') as HTMLElement; + const preview = container.querySelector('.tooltip-description.soql-block') as HTMLElement; expect(preview).not.toBeNull(); const lines = preview.textContent?.split('\n') ?? []; @@ -498,9 +551,10 @@ describe('FrameTooltipRenderer', () => { it('should leave a query that fits on one line whole', () => { showSettled(soqlEvent('SELECT Id FROM Account'), cursorAnchor(100, 100)); - const preview = container.querySelector('.tooltip-header.soql-block') as HTMLElement; + const preview = container.querySelector('.tooltip-description.soql-block') as HTMLElement; expect(preview.textContent).toBe('SELECT Id FROM Account'); - expect(container.querySelector('.tooltip-status-info')?.textContent).toBe(''); + // Nothing was cut, so the card says nothing at its foot. + expect(container.querySelector('.tooltip-status')).toBeNull(); }); it('should keep the WHERE clause however long the field list is', () => { @@ -510,7 +564,7 @@ describe('FrameTooltipRenderer', () => { cursorAnchor(100, 100), ); - const preview = container.querySelector('.tooltip-header.soql-block') as HTMLElement; + const preview = container.querySelector('.tooltip-description.soql-block') as HTMLElement; const lines = preview.textContent?.split('\n') ?? []; expect(lines[0]).toMatch(/^SELECT .*\+\d+ fields$/); expect(lines).toContain(`WHERE Name = 'x'`); @@ -521,26 +575,20 @@ describe('FrameTooltipRenderer', () => { showSettled(soqlEvent(huge), cursorAnchor(100, 100)); - const preview = container.querySelector('.tooltip-header.soql-block') as HTMLElement; + const preview = container.querySelector('.tooltip-description.soql-block') as HTMLElement; expect(preview.classList.contains('is-clamped')).toBe(true); expect(preview.textContent).not.toContain('\n'); expect(preview.textContent?.length).toBe(160); - const info = container.querySelector('.tooltip-status-info') as HTMLElement; - expect(info.textContent).toBe('query too large to format'); - }); - - it('should point at the inspector for the full detail', () => { - showSettled(soqlEvent(longQuery(40)), cursorAnchor(100, 100)); - - const action = container.querySelector('.tooltip-status-action') as HTMLElement; - expect(action.textContent).toBe('Click to view in Inspector'); + expect(container.querySelector('.tooltip-status')?.textContent).toBe( + 'query too large to format', + ); }); it('should highlight the query with soql token classes', () => { showSettled(soqlEvent('SELECT Id FROM Account'), cursorAnchor(100, 100)); - const preview = container.querySelector('.tooltip-header.soql-block') as HTMLElement; + const preview = container.querySelector('.tooltip-description.soql-block') as HTMLElement; expect(preview.querySelector('span[class^="soql-tok"]')).not.toBeNull(); }); @@ -554,7 +602,7 @@ describe('FrameTooltipRenderer', () => { event.text = 'SELECT Name FROM Contact'; showSettled(event, cursorAnchor(100, 100)); - const preview = container.querySelector('.tooltip-header.soql-block') as HTMLElement; + const preview = container.querySelector('.tooltip-description.soql-block') as HTMLElement; expect(preview.textContent).toContain('Account'); }); }); @@ -666,11 +714,7 @@ describe('FrameTooltipRenderer', () => { }); it('should use a custom cursor offset', () => { - frameTooltipRenderer.destroy(); - frameTooltipRenderer = new FrameTooltipRenderer(container, { - categoryColors: {}, - cursorOffset: 20, - }); + rebuild({ cursorOffset: 20 }); sizeTooltip(200, 100); showSettled(createEvent(0, 100), cursorAnchor(100, 100)); @@ -735,21 +779,10 @@ describe('FrameTooltipRenderer', () => { describe('edge cases', () => { it('should handle event with minimal data', () => { const event = { - timestamp: 0, - category: 'Apex', - children: [], isParent: true, + timestamp: 0, text: 'Minimal event', - duration: { total: 100, self: 100 }, - exitStamp: 100, - dmlCount: { total: 0, self: 0 }, - dmlRowCount: { total: 0, self: 0 }, - soqlCount: { total: 0, self: 0 }, - soqlRowCount: { total: 0, self: 0 }, - soslCount: { total: 0, self: 0 }, - soslRowCount: { total: 0, self: 0 }, - thrownCount: { total: 0, self: 0 }, - heapAllocated: { total: 0, self: 0 }, + duration: { total: 0, self: 0 }, } as unknown as LogEvent; showSettled(event, cursorAnchor(100, 100)); diff --git a/log-viewer/src/features/timeline/optimised/FrameTooltipRenderer.ts b/log-viewer/src/features/timeline/optimised/FrameTooltipRenderer.ts index 26e708f4..3b8c43fd 100644 --- a/log-viewer/src/features/timeline/optimised/FrameTooltipRenderer.ts +++ b/log-viewer/src/features/timeline/optimised/FrameTooltipRenderer.ts @@ -10,17 +10,10 @@ */ import type { ApexLog, LogEvent } from 'apex-log-parser'; -import { - computeWallClockMs, - formatDuration, - formatWallClockTime, -} from '../../../core/utility/Util.js'; +import { selfLabel } from '../../../core/metrics/eventMetrics.js'; import { formatSOQL, type Dialect, type SoqlBudget } from '../../soql/format/formatter.js'; -import type { TimelineMarker } from '../types/flamechart.types.js'; -import { formatNumber } from './rendering/tooltip-utils.js'; - -// web components -import '../../../components/ColorSwatch.js'; +import { markerColorCss, type TimelineMarker } from '../types/flamechart.types.js'; +import { frameCard, markerCard, type CardRow, type TooltipCard } from './frameTooltipCard.js'; /** Delay before a tooltip first appears, so sweeping across frames does not strobe. */ const SHOW_DELAY_MS = 60; @@ -32,6 +25,8 @@ const ANCHOR_GAP = 3; const SOQL_FORMAT_MAX_CHARS = 2000; /** Single-line fallback length for queries too large to pretty-print. */ const SOQL_INLINE_MAX_CHARS = 160; +/** Plain descriptions clamp to three lines, so no card can read more than this. */ +const PLAIN_TEXT_MAX_CHARS = 512; /** Share of the panel's height a query may take. The rest holds the metric rows and the footer. */ const QUERY_HEIGHT_SHARE = 0.45; const MIN_QUERY_LINES = 4; @@ -71,7 +66,7 @@ type PendingTooltip = /** A built description block, plus the footer that says what was cut. */ interface DescriptionBlock { - node: HTMLDivElement; + node: HTMLElement; more: string | null; } @@ -353,11 +348,10 @@ export class FrameTooltipRenderer { return; } - while (this.tooltipElement.firstChild) { - this.tooltipElement.removeChild(this.tooltipElement.firstChild); - } if (content) { - this.tooltipElement.appendChild(content); + this.tooltipElement.replaceChildren(content); + } else { + this.tooltipElement.replaceChildren(); } this.tooltipElement.dataset.visible = 'true'; @@ -369,214 +363,43 @@ export class FrameTooltipRenderer { this.anchorTooltip(anchor); } - /** - * Generate tooltip content for truncation marker. - */ + /** The card for a truncation or exception marker. */ private generateTruncationTooltipContent(marker: TimelineMarker): HTMLDivElement | null { - const rows: { label: string; value: string }[] = []; - const color = this.getTruncationColor(marker.type); - return this.createTooltip(marker.summary, marker.metadata, rows, color); - } - - /** - * Get human-readable label for truncation type. - */ - private getTruncationTypeLabel(type: string): string { - switch (type) { - case 'error': - return 'Error'; - case 'skip': - return 'Skipped Lines'; - case 'unexpected': - return 'Unexpected Truncation'; - default: - return type; - } - } - - /** - * Format nanoseconds as milliseconds for display. - */ - private formatNanoseconds(ns: number): string { - const ms = ns / 1_000_000; - return `${ms.toFixed(2)}ms`; - } - - /** - * T017: Get CSS color string for truncation type tooltip borders. - * Converts PixiJS numeric colors (0xRRGGBB) to CSS hex strings (#RRGGBB). - */ - private getTruncationColor(type: string): string { - // Map marker types to CSS colors matching MARKER_COLORS - switch (type) { - case 'exception': - return '#e5484d'; // saturated red - discrete failure - case 'error': - return '#ff808033'; // rgba(255, 128, 128, 0.2) - case 'skip': - return '#1e80ff33'; // rgba(30, 128, 255, 0.2) - case 'unexpected': - return '#8080ff33'; // rgba(128, 128, 255, 0.2) - default: - return '#999999'; // Gray fallback - } + return this.createTooltip(markerCard(marker, markerColorCss(marker.type)), { + node: descriptionNode(marker.metadata ?? ''), + more: null, + }); } private generateTooltipContent(event: LogEvent): HTMLDivElement | null { - if (event?.isParent) { - const rows = []; - if (event.type) { - rows.push({ label: 'type:', value: event.type.toString() }); - } - - if (event.exitStamp) { - if (event.duration.total) { - let val = formatDuration(event.duration.total); - if (event.cpuType === 'free') { - val += ' (free)'; - } else if (event.duration.self) { - val += ` (self ${formatDuration(event.duration.self)})`; - } - - rows.push({ label: 'total:', value: val }); - } - - // Wall-clock time row (only if startTime is available) - const apexLog = this.options.apexLog; - if (apexLog?.startTime !== null && apexLog?.timestamp !== undefined) { - const startWallClock = computeWallClockMs( - apexLog.startTime, - apexLog.timestamp, - event.timestamp, - ); - let timeVal = formatWallClockTime(startWallClock); - if (event.exitStamp) { - const endWallClock = computeWallClockMs( - apexLog.startTime, - apexLog.timestamp, - event.exitStamp, - ); - timeVal += ` → ${formatWallClockTime(endWallClock)}`; - } - rows.push({ label: 'time:', value: timeVal }); - } - - const govLimits = this.options.apexLog?.governorLimits; - if (event.dmlCount.total) { - rows.push({ - label: 'DML:', - value: this.formatLimit( - event.dmlCount.total, - event.dmlCount.self, - govLimits?.dmlStatements.limit, - ), - }); - } - - if (event.dmlRowCount.total) { - rows.push({ - label: 'DML rows:', - value: this.formatLimit( - event.dmlRowCount.total, - event.dmlRowCount.self, - govLimits?.dmlRows.limit, - ), - }); - } - - if (event.soqlCount.total) { - rows.push({ - label: 'SOQL:', - value: this.formatLimit( - event.soqlCount.total, - event.soqlCount.self, - govLimits?.soqlQueries.limit, - ), - }); - } - - if (event.soqlRowCount.total) { - rows.push({ - label: 'SOQL rows:', - value: this.formatLimit( - event.soqlRowCount.total, - event.soqlRowCount.self, - govLimits?.queryRows.limit, - ), - }); - } - - if (event.soslCount.total) { - rows.push({ - label: 'SOSL:', - value: this.formatLimit( - event.soslCount.total, - event.soslCount.self, - govLimits?.soslQueries.limit, - ), - }); - } - - if (event.soslRowCount.total) { - rows.push({ - label: 'SOSL rows:', - value: this.formatLimit( - event.soslRowCount.total, - event.soslRowCount.self, - govLimits?.soslQueries.limit, - ), - }); - } - - if (event.thrownCount.total) { - // No `self`: on a method (the only hoverable frame) self is always 0 because the - // throw is a child leaf, so it would only ever read "(self 0)". - rows.push({ label: 'Throws:', value: `${event.thrownCount.total}` }); - } - - if (event.heapAllocated.total || event.heapAllocated.self) { - // Net heap retained (alloc − free): total for the subtree, self for this method's - // own body. ~0 net (allocated then freed) shows no row. Gross/peak live in the grid. - rows.push({ - label: 'heap:', - value: `${formatNumber(event.heapAllocated.total)} bytes (self ${formatNumber( - event.heapAllocated.self, - )})`, - }); - } - } - - const descriptionText = event.text + (event.suffix ?? ''); - return this.createTooltip( - '', - descriptionText, - rows, - this.options.categoryColors[event.category] || '', - this.getDescription(event, descriptionText), - event.category, - true, - ); + if (!event?.isParent) { + return null; } - - return null; + return this.createTooltip( + frameCard(event, this.options.categoryColors[event.category] ?? '', this.options.apexLog), + this.getDescription(event), + ); } /** * Build (or reuse) the description block for an event. Queries are pretty-printed and clamped; - * anything else falls back to the plain text the caller already has. + * anything else reads as the frame's own text. + * + * The text is joined only where it is used: a cache hit needs none of it, and a query can run + * to kilobytes. */ - private getDescription(event: LogEvent, text: string): DescriptionBlock | undefined { + private getDescription(event: LogEvent): DescriptionBlock { const isSosl = event.type === 'SOSL_EXECUTE_BEGIN'; if (event.type !== 'SOQL_EXECUTE_BEGIN' && !isSosl) { - return undefined; + return { node: descriptionNode(event.text + (event.suffix ?? '')), more: null }; } let block = this.descriptionCache.get(event); if (!block) { - block = this.buildQueryPreview(text, isSosl ? 'sosl' : 'soql'); + block = this.buildQueryPreview(event.text + (event.suffix ?? ''), isSosl ? 'sosl' : 'soql'); this.descriptionCache.set(event, block); } - return { node: block.node.cloneNode(true) as HTMLDivElement, more: block.more }; + return { node: block.node.cloneNode(true) as HTMLElement, more: block.more }; } /** @@ -585,7 +408,7 @@ export class FrameTooltipRenderer { */ private buildQueryPreview(text: string, dialect: Dialect): DescriptionBlock { const node = document.createElement('div'); - node.className = 'tooltip-header soql-block'; + node.className = 'tooltip-description soql-block'; // Pretty-printing a multi-kilobyte query on every hover is too slow to be worth it, and the // result is clamped away anyway. @@ -627,7 +450,7 @@ export class FrameTooltipRenderer { body.style.visibility = 'hidden'; const line = document.createElement('div'); - line.className = 'tooltip-header soql-block'; + line.className = 'tooltip-description soql-block'; const probe = document.createElement('span'); probe.style.whiteSpace = 'pre'; @@ -657,97 +480,40 @@ export class FrameTooltipRenderer { }; } - private formatLimit(val: number, self: number, total = 0) { - const outOf = total > 0 ? `/${total}` : ''; - return `${val}${outOf} (self ${self})`; - } - - private createTooltip( - title: string, - description = '', - rows: { label: string; value: string }[], - color: string, - descriptionBlock?: DescriptionBlock, - /** Category label for the swatch row; `color` fills the swatch. */ - categoryName?: string, - /** True when a click selects the frame, which the inspector then shows in full. */ - inspectable = false, - ) { - const tooltipBody = document.createElement('div'); - tooltipBody.className = 'timeline-tooltip'; - - if (color) { - tooltipBody.style.borderColor = color; + /** Renders a {@link TooltipCard}, with the description block the caller built. */ + private createTooltip(card: TooltipCard, description: DescriptionBlock): HTMLDivElement { + const body = document.createElement('div'); + body.className = 'timeline-tooltip'; + if (card.rail) { + body.style.borderColor = card.rail; } - if (title) { - const header = document.createElement('div'); - header.className = 'tooltip-header'; - header.textContent = title; - tooltipBody.appendChild(header); + if (card.title) { + body.appendChild(element('div', 'tooltip-title', card.title)); } - - if (descriptionBlock) { - tooltipBody.appendChild(descriptionBlock.node); - } else { - const descriptionDiv = document.createElement('div'); - descriptionDiv.className = 'tooltip-header'; - descriptionDiv.textContent = description; - tooltipBody.appendChild(descriptionDiv); + if (description.node.firstChild) { + body.appendChild(description.node); } - - if (categoryName && color) { - const categoryRow = document.createElement('div'); - categoryRow.className = 'tooltip-category'; - - const swatch = document.createElement('color-swatch'); - swatch.color = color; - - const name = document.createElement('span'); - name.textContent = categoryName; - - categoryRow.appendChild(swatch); - categoryRow.appendChild(name); - tooltipBody.appendChild(categoryRow); + if (card.identity?.length) { + body.appendChild(element('div', 'tooltip-identity', card.identity.join(' · '))); } - - rows.forEach(({ label, value }) => { - const row = document.createElement('div'); - row.className = 'tooltip-row'; - - const labelDiv = document.createElement('div'); - labelDiv.className = 'tooltip-label'; - labelDiv.textContent = label; - - const valueDiv = document.createElement('div'); - valueDiv.className = 'tooltip-value'; - valueDiv.textContent = value; - - row.appendChild(labelDiv); - row.appendChild(valueDiv); - tooltipBody.appendChild(row); + card.groups.forEach((group, index) => { + const box = document.createElement('div'); + // Only the first group carries the rule that parts what the frame is from what it + // measured. Sibling divs give CSS no "first group" selector to do it with. + box.className = index ? 'tooltip-group' : 'tooltip-group tooltip-group--ruled'; + group.forEach((row) => box.appendChild(rowElement(row))); + body.appendChild(box); }); - if (inspectable) { - // One fixed row at the foot, so what was cut and where to see it in full always read in - // the same place instead of interrupting the description. - const status = document.createElement('div'); - status.className = 'tooltip-status'; - - const info = document.createElement('span'); - info.className = 'tooltip-status-info'; - info.textContent = descriptionBlock?.more ?? ''; - - const action = document.createElement('span'); - action.className = 'tooltip-status-action'; - action.textContent = 'Click to view in Inspector'; - - status.appendChild(info); - status.appendChild(action); - tooltipBody.appendChild(status); + // Only what was left out, and only when something was: a footer that always says + // the same thing is a row spent on every card to tell you what one click teaches. + const cut = [description.more, card.hidden ? `+${card.hidden} more` : null].filter(Boolean); + if (cut.length) { + body.appendChild(element('div', 'tooltip-status', cut.join(' · '))); } - return tooltipBody; + return body; } /** @@ -841,3 +607,43 @@ export class FrameTooltipRenderer { this.descriptionCache = new WeakMap(); } } + +/** A classed element with text, the shape every row in the card takes. */ +function element(tag: string, className: string, text: string): HTMLElement { + const node = document.createElement(tag); + node.className = className; + node.textContent = text; + return node; +} + +/** A plain-text description, cut to what the stylesheet's clamp can show. */ +function descriptionNode(text: string): HTMLElement { + return element('div', 'tooltip-description', text.slice(0, PLAIN_TEXT_MAX_CHARS)); +} + +/** + * One row of the card: what it is, the reading against the log's own figure, and the + * frame's own figure. All text — a hover card is read as text, and a bar here has no + * room for the track and axis that would let it be read as a quantity rather than as + * a highlighted row. + * + * The figure columns hold a floor on every row and on every card, so they keep their + * place as the pointer moves from frame to frame. + */ +function rowElement(row: CardRow): HTMLElement { + const line = document.createElement('div'); + line.className = 'tooltip-row'; + if (row.wide) { + line.classList.add('tooltip-row--wide'); + } + if (row.lead) { + line.classList.add('tooltip-row--lead'); + } + + line.appendChild(element('span', 'tooltip-label', row.label)); + line.appendChild(element('span', 'tooltip-value', row.value)); + if (!row.wide) { + line.appendChild(element('span', 'tooltip-self', row.self ? selfLabel(row.self) : '')); + } + return line; +} diff --git a/log-viewer/src/features/timeline/optimised/frameTooltipCard.ts b/log-viewer/src/features/timeline/optimised/frameTooltipCard.ts new file mode 100644 index 00000000..4be72cac --- /dev/null +++ b/log-viewer/src/features/timeline/optimised/frameTooltipCard.ts @@ -0,0 +1,262 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ + +/** + * What the timeline's hover card says, and in what order — no DOM, so the content + * can be read in a test without laying a panel out. + * + * The card is a preview, not a report: it leads with the reading the hover was for, + * keeps the rows a glance can take in, and leaves the rest to the Inspector. Every row + * is the same three columns — label, reading, the frame's own figure — so the readings + * line up down the card. + * + * A reading is measured against the log's own total, never against a governor limit: + * the hover is for deciding where to look, and "is the work here" varies frame to frame + * where "is the transaction near a limit" does not. Risk against limits is the governor + * strip's job, and the Inspector's. The denominator is spelled "of", not "/", so a + * reading cannot be taken for consumption of a cap. + */ + +import type { ApexLog, LogEvent } from 'apex-log-parser'; + +import { EVENT_METRICS, HEAP_PEAK } from '../../../core/metrics/eventMetrics.js'; +import { DEFAULT_NAMESPACE } from '../../../core/utility/CallerNamespace.js'; +import { + computeWallClockMs, + formatByteSize, + formatDuration, + formatInteger, + formatWallClockTime, +} from '../../../core/utility/Util.js'; +import type { TimelineMarker } from '../types/flamechart.types.js'; + +/** + * How many metric rows the card holds. Past this the least-pressured are dropped and + * counted in the footer — a hover that has to be read top to bottom has stopped + * being a hover. + */ +export const MAX_METRIC_ROWS = 6; + +/** A metric that always earns its row: a throw is rare, and never incidental. */ +const NEVER_DROPPED = new Set(['Throws']); + +/** One line of the card: a reading, and the frame's own share of it. */ +export interface CardRow { + label: string; + value: string; + /** The frame's own reading, bare. Kept at zero — `0` says the work is in a child. */ + self: string | null; + /** + * The branch's share of the log's own total, 0–1. Not shown: the reading already + * names the log's figure, so the share is what ranks the rows for the cap. + */ + share: number | null; + /** The reading takes the self column too, for one that is not a figure. */ + wide?: boolean; + /** The reading the hover was for. Carries the card's one piece of emphasis. */ + lead?: boolean; +} + +/** A frame or a marker as the card reads it, top to bottom. */ +export interface TooltipCard { + /** The marker's summary. A frame's name is its description block instead. */ + title?: string; + /** Category, type, namespace and line — the parts of the identity line, in order. */ + identity?: string[]; + /** Row groups, parted by space. They are all readings, so no rule divides them. */ + groups: CardRow[][]; + /** Metrics the cap left out. */ + hidden?: number; + /** The colour of the rail down the card's left edge. */ + rail: string; +} + +/** The reading the hover was for: how long the frame took, and how much was its own. */ +function timeRow(event: LogEvent): CardRow | null { + if (!event.exitStamp || !event.duration.total) { + return null; + } + return { + // Free qualifies which time this is, so it rides with the label rather than in a + // figure column, where it collided with the reading. Salesforce does not charge + // this duration against CPU, which is why it shows on the database frames. + label: event.cpuType === 'free' ? 'Time · free' : 'Time', + // Alone of the readings, this one names no log figure to read against: the chart + // is the duration chart, so the log's span is already on the axis, in the minimap, + // and in the frame's own width. It would also pair mismatched units — "58.2 ms of + // 24.6 s" is a ratio no eye can take. + value: formatDuration(event.duration.total), + self: formatDuration(event.duration.self), + // Unranked: the timing row is its own group, so the cap never weighs it. + share: null, + lead: true, + }; +} + +/** + * A branch's share of the log's own total. Both come from the same tree sum, never + * from the cumulative figures Salesforce reports: mixing the two would put a branch + * over 100% whenever the log dropped events those figures still counted. + * + * A signed net can be negative, and a share of it says nothing, so it goes unstated. + */ +function shareOfLog(value: number, logTotal: number): number | null { + if (logTotal <= 0 || value < 0) { + return null; + } + return Math.min(1, value / logTotal); +} + +/** The frame's clock range, where the log records a start time to count from. */ +function wallClockRow(event: LogEvent, apexLog: ApexLog | null | undefined): CardRow | null { + const { startTime, timestamp } = apexLog ?? {}; + if (startTime === null || startTime === undefined || timestamp === undefined) { + return null; + } + const at = (ns: number) => formatWallClockTime(computeWallClockMs(startTime, timestamp, ns)); + return { + label: 'Wall clock', + value: event.exitStamp + ? `${at(event.timestamp)} → ${at(event.exitStamp)}` + : at(event.timestamp), + self: null, + share: null, + wide: true, + }; +} + +/** + * Every non-zero metric the frame reports, in {@link EVENT_METRICS} order. Ranking + * decides what the cap drops; the order never changes, so a row cannot overtake + * another as the pointer moves between frames. + */ +function metricRows(event: LogEvent, apexLog: ApexLog | null | undefined): CardRow[] { + const rows: CardRow[] = []; + + for (const metric of EVENT_METRICS) { + const { total, self } = metric.pick(event); + if (!total && !self) { + continue; + } + // Compact bytes: the card is width-bound where the Inspector is not. + const format = metric.bytes ? formatByteSize : formatInteger; + const logTotal = apexLog ? metric.pick(apexLog).total : 0; + const share = shareOfLog(total, logTotal); + rows.push({ + label: metric.label, + // Spelled "of" rather than "/": the log's total is not a cap, and a slash would + // read as one. A byte magnitude names no log figure at all — the unit follows the + // value, so the pair would read "100 bytes of 6 MB". + value: + share !== null && !metric.bytes ? `${format(total)} of ${format(logTotal)}` : format(total), + share, + // Zero included: it is what says the work happened in a descendant, not here. + self: metric.hasSelf === false ? null : format(self), + }); + } + + const peak = HEAP_PEAK.pick(event); + if (peak) { + rows.push({ + label: HEAP_PEAK.label, + value: formatByteSize(peak), + self: null, + // A peak composes by max, not by sum: every frame spanning the transaction's peak + // reports the root's own figure, so a share of it would be 1 and would outrank + // every summed metric on the card. Unranked, so the cap drops it first. + share: null, + }); + } + return rows; +} + +/** + * The rows the cap keeps, still in reading order. The branch's share of the log + * decides what survives, so the metrics this branch is most answerable for are the + * ones kept — except a throw, which is a signal in itself. + */ +function capped(rows: CardRow[]): { kept: CardRow[]; hidden: number } { + if (rows.length <= MAX_METRIC_ROWS) { + return { kept: rows, hidden: 0 }; + } + // Sort is stable, so equal pressure holds reading order; picking the survivors back out + // by membership restores that order without a second sort. + const keep = new Set( + [...rows] + .sort((a, b) => notable(b) - notable(a) || (b.share ?? 0) - (a.share ?? 0)) + .slice(0, MAX_METRIC_ROWS), + ); + return { kept: rows.filter((row) => keep.has(row)), hidden: rows.length - MAX_METRIC_ROWS }; +} + +/** A tier above any share, so a metric that always earns its row sorts first. */ +function notable(row: CardRow): number { + return NEVER_DROPPED.has(row.label) ? 1 : 0; +} + +/** The identity line: what the frame is, whose code it is, and where it came from. */ +function identityOf(event: LogEvent): string[] { + const parts: string[] = []; + if (event.category) { + parts.push(event.category); + } + if (event.type) { + parts.push(event.type); + } + if (event.namespace && event.namespace !== DEFAULT_NAMESPACE) { + parts.push(event.namespace); + } + // The call site, in the code that contains it — not where the frame is defined. So + // "from": it is what tells two calls to one method from different places apart, and + // it is the line Go to Source lands on. EXTERNAL stands alone: the caller is outside + // the classes the log covers, so there is no line to have come from. + if (event.lineNumber !== null && event.lineNumber !== undefined) { + parts.push( + typeof event.lineNumber === 'number' ? `from line ${event.lineNumber}` : event.lineNumber, + ); + } + return parts; +} + +/** + * The card for a hovered frame. + * @param rail - The category's colour, which the caller resolves from the palette. + */ +export function frameCard(event: LogEvent, rail: string, apexLog?: ApexLog | null): TooltipCard { + const time = timeRow(event); + // No exit means nothing measured: the counts are summed on the way out, so a frame + // still open has no reading to report. + const { kept, hidden } = capped(event.exitStamp ? metricRows(event, apexLog) : []); + // Last: the clock is reference data, not what the hover asked. + const clock = wallClockRow(event, apexLog); + + return { + // No title — a frame's name is the description block, pretty-printed where it is a + // query, so a title over it would only say the same thing twice. + identity: identityOf(event), + groups: [time ? [time] : [], kept, clock ? [clock] : []].filter((group) => group.length > 0), + hidden, + rail, + }; +} + +/** The card for a truncation or exception marker. */ +export function markerCard(marker: TimelineMarker, rail: string): TooltipCard { + const span = + marker.endTime !== undefined && marker.endTime > marker.startTime + ? { + label: 'Spans', + value: formatDuration(marker.endTime - marker.startTime), + self: null, + share: null, + // The marker's one reading, so it leads as a frame's timing row does. + lead: true, + } + : null; + return { + title: marker.summary, + groups: span ? [[span]] : [], + rail, + }; +} diff --git a/log-viewer/src/features/timeline/styles/timeline.css.ts b/log-viewer/src/features/timeline/styles/timeline.css.ts index bb5d7a24..c709e0a6 100644 --- a/log-viewer/src/features/timeline/styles/timeline.css.ts +++ b/log-viewer/src/features/timeline/styles/timeline.css.ts @@ -14,7 +14,7 @@ export const tooltipStyles = `${soqlSyntaxStyles} line budget means the same amount of text everywhere, and the height JS measures no longer depends on where the panel last sat. The percentage is of the chart area, so a wide chart shows more of a query and a narrow one never overflows. */ - width: clamp(300px, 36%, 620px); + width: clamp(300px, 30%, 520px); max-width: 100%; max-height: min(420px, 50vh); /* Never a scroll container: the content is clamped, and the panel takes no pointer. */ @@ -25,6 +25,9 @@ export const tooltipStyles = `${soqlSyntaxStyles} stay hoverable and clickable. */ pointer-events: none; transition: opacity 80ms ease; + /* The panel is moved by a transform on every pointer move. Without the blur that + used to promote it, only this keeps that off the paint path. */ + will-change: transform; } #timeline-tooltip[data-visible='true'] { @@ -38,102 +41,150 @@ export const tooltipStyles = `${soqlSyntaxStyles} } } + /* Flat and opaque, lifted by one shadow: a blurred surface that moves with the + pointer costs a composite every frame and reads as window chrome, not tooling. */ .timeline-tooltip { + /* The clamp fade must cover exactly one line, so both read the same value. */ + --tooltip-line: 1.3em; position: relative; box-shadow: var(--lana-shadow-overlay); - backdrop-filter: blur(6px); /* Tokenised so the status row can bleed to the panel edge with a matching negative margin. */ padding: var(--lana-space-xs); border-radius: var(--lana-radius-sm); - border-left: 4px solid; + border-left: var(--lana-space-2xs) solid; background-color: var(--tl-hover-background); color: var(--tl-hover-foreground); - font-family: var(--lana-font-mono); + /* One size throughout, and prose in the UI font: only the figures and the + frame's own text are alignment-bearing enough to earn mono. */ + font-family: var(--lana-font-ui); font-size: var(--lana-text-sm); } - .tooltip-header { + /* A marker's summary — a frame's name is its description block instead. Two lines at + most, and broken at a boundary where one exists: break-all chopped identifiers + mid-word. */ + .tooltip-title { font-weight: 500; - margin-bottom: 10px; - line-height: 1.3em; + overflow-wrap: anywhere; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + line-clamp: 2; + overflow: hidden; + } + + /* The frame's own text, and a marker's summary: mono, because it is code. */ + .tooltip-title, + .tooltip-description { + font-family: var(--lana-font-mono); + line-height: var(--tooltip-line); + } + + .tooltip-description { + margin-top: var(--lana-space-3xs); white-space: pre-wrap; - word-break: break-all; + overflow-wrap: anywhere; } /* Plain-text descriptions clamp to a few lines; SOQL clamps by line in JS. */ - .tooltip-header:not(.soql-block) { + .tooltip-description:not(.soql-block) { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 3; + line-clamp: 3; overflow: hidden; } /* soql-syntax.css sets display:inline, which drops the margin and breaks the mask. */ - .tooltip-header.soql-block { + .tooltip-description.soql-block { display: block; } - .tooltip-header.is-clamped { - mask-image: linear-gradient(to bottom, #000 calc(100% - 1.3em), transparent 100%); - } - - /* Thin foot rail: what was cut, and where the full detail is. */ - .tooltip-status { - display: flex; - align-items: baseline; - justify-content: space-between; - gap: var(--lana-space-sm); - margin: var(--lana-space-xs) calc(var(--lana-space-xs) * -1) - calc(var(--lana-space-xs) * -1); - padding: var(--lana-space-3xs) var(--lana-space-xs); - border-top: var(--lana-stroke) solid var(--lana-hover-border); - font-size: var(--lana-text-sm); - color: var(--tl-description-foreground, #999); + .tooltip-description.is-clamped { + mask-image: linear-gradient( + to bottom, + #000 calc(100% - var(--tooltip-line)), + transparent 100% + ); } - .tooltip-status-info, - .tooltip-status-action { + /* The secondary lines: one line each, ellipsised, and muted against the readings. */ + .tooltip-identity, + .tooltip-status, + .tooltip-label { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; + color: var(--tl-description-foreground, #999); } - .tooltip-status-action { - flex: 0 0 auto; - font-style: italic; + /* What the frame is, whose code it is, and where it came from — one line, since + no one of these is worth a row of its own. */ + .tooltip-identity { + margin-top: var(--lana-space-3xs); } - .tooltip-category { - display: flex; - align-items: center; - gap: var(--lana-space-2xs); - padding: var(--lana-space-3xs) 0; - color: var(--tl-description-foreground, #999); + /* The row groups are all readings, so space parts them and no rule asserts a + distinction that is not there. */ + .tooltip-group { + margin-top: var(--lana-space-2xs); + } + + /* One rule on the card, where the kind of thing changes: what the frame is, above; + what it measured, below. The renderer marks the group, since sibling divs give + CSS no way to select the first. */ + .tooltip-group--ruled { + margin-top: var(--lana-space-xs); + padding-top: var(--lana-space-xs); + border-top: var(--lana-stroke) solid var(--lana-hover-border); } + /* Says what the card left out, and appears only when it left something out. */ + .tooltip-status { + margin: var(--lana-space-xs) calc(var(--lana-space-xs) * -1) + calc(var(--lana-space-xs) * -1); + padding: var(--lana-space-3xs) var(--lana-space-xs); + border-top: var(--lana-stroke) solid var(--lana-hover-border); + } + + /* The figure columns hold a floor, so they line up from card to card and sweeping + the chart does not make them dance — and grow past it rather than collide, since + any width picked for them can be exceeded by a long reading. The label takes + what is left and truncates, being the one part that can. */ .tooltip-row { - display: flex; - justify-content: space-between; + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(12ch, auto) minmax(12ch, auto); align-items: baseline; - padding: 2px 0; + column-gap: var(--lana-space-sm); + padding: var(--lana-space-3xs) 0; } - .tooltip-label { - flex: 1 1 auto; - overflow: hidden; + /* A reading that is not a figure has nothing to line up with, so it keeps only + the label's column and takes whatever width it needs. */ + .tooltip-row--wide { + grid-template-columns: minmax(0, 1fr) auto; + } + + /* Figures in mono and tabular, so a column of them lines up on the digit. */ + .tooltip-value, + .tooltip-self { + font-family: var(--lana-font-mono); + font-variant-numeric: tabular-nums; + text-align: right; white-space: nowrap; - text-overflow: ellipsis; - padding-right: 12px; - color: var(--tl-description-foreground, #999); - opacity: 0.9; } .tooltip-value { - flex-shrink: 0; - font-variant-numeric: tabular-nums; font-weight: 500; - font-family: var(--lana-font-mono); - text-align: right; - white-space: pre-wrap; + } + + .tooltip-self { + color: var(--tl-description-foreground, #999); + } + + /* The card still leads somewhere: the reading the hover was for, at full strength + against the muted ones around it — weight, never a second type size. */ + .tooltip-row--lead .tooltip-value { + font-weight: 600; } `; From e144df6cd9457831708627a956a804dbff365d92 Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Tue, 25 Aug 2026 11:06:19 +0100 Subject: [PATCH 06/17] refactor(log-viewer): name the line number as the call site lineNumber is where the frame was called from in the containing code, not where it is defined. "Line" reads as the definition, so the inspector now says "Called from". A line the parser could not resolve still reads as EXTERNAL on its own, matching the timeline's identity line. --- log-viewer/src/components/EventVitals.ts | 3 ++- log-viewer/src/components/__tests__/EventVitals.test.ts | 9 ++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/log-viewer/src/components/EventVitals.ts b/log-viewer/src/components/EventVitals.ts index 58ccca9e..880d9f6e 100644 --- a/log-viewer/src/components/EventVitals.ts +++ b/log-viewer/src/components/EventVitals.ts @@ -164,7 +164,8 @@ export class EventVitals extends LitElement { if (callerNamespace !== (primary.namespace || DEFAULT_NAMESPACE)) { this._row(rows, 'Caller namespace', callerNamespace); } - this._optional(rows, 'Line', primary.lineNumber); + // The call site, in the code that contains the frame — not where it is defined. + this._optional(rows, 'Called from', primary.lineNumber, (line) => `line ${line}`); return html` diff --git a/log-viewer/src/components/__tests__/EventVitals.test.ts b/log-viewer/src/components/__tests__/EventVitals.test.ts index c1e40818..42797e78 100644 --- a/log-viewer/src/components/__tests__/EventVitals.test.ts +++ b/log-viewer/src/components/__tests__/EventVitals.test.ts @@ -75,10 +75,17 @@ describe('EventVitals', () => { 'SOQL Rows', 'Selective', 'Namespace', - 'Line', + 'Called from', ]); }); + /** The line is the call site in the containing code, not where the frame is defined. */ + it('reads the line as where the call came from', async () => { + const el = await mount(store, { eventIndex: soqlIndex, type: 'soql' }); + + expect(valueFor(el, 'Called from')).toMatch(/^line \d+$/); + }); + it('omits the caller namespace when it matches the namespace', async () => { // Nothing to learn from "default called default" — see the differing case below. const el = await mount(store, { eventIndex: soqlIndex, type: 'soql' }); From 291952505038c647dcd654164682ce804b1e35cc Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Tue, 25 Aug 2026 11:19:52 +0100 Subject: [PATCH 07/17] refactor(log-viewer): settle the swatch and the shared metric vocabulary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The metric strip reserved a 12px track for a swatch that is now 8px, and stated a size the token already owns. Both rows read --lana-swatch-size, and the row style is written once rather than twice. The swatch painted itself two ways — a --row-hue fallback in :host and a style written in updated(). No caller leaves the colour out, and --row-hue is only ever set by a reveal row, which draws no swatch, so the colour property is now the one mechanism. Its label became a title on an aria-hidden host, naming what the text beside it already said; the accessible name belongs in light DOM, as the reveal rows do it. eventMetrics owned the only import from features/ anywhere in core/, for a constant that is a governor fact rather than a Database-tab one. SOSL_ROWS_PER_QUERY_LIMIT moves down; the Database tab re-exports it so its own consumers are unchanged. StatementType was declared twice inside core/, so the metric list now owns it and the event bus re-exports. hasSelf was read only as `=== false`, where absent and true meant the same thing. It reads as noSelf. EventVitals' timing row spells the self reading through selfLabel, like every other view. --- log-viewer/src/components/ColorSwatch.ts | 32 +++++++------------ log-viewer/src/components/EventVitals.ts | 12 +++++-- .../components/__tests__/ColorSwatch.test.ts | 20 +++--------- log-viewer/src/core/events/EventBus.ts | 4 ++- .../metrics/__tests__/eventMetrics.test.ts | 4 --- log-viewer/src/core/metrics/eventMetrics.ts | 13 +++++--- log-viewer/src/features/database/limits.ts | 5 ++- .../timeline/components/TimelineKey.ts | 2 +- .../timeline/optimised/frameTooltipCard.ts | 2 +- .../MetricStripTooltipRenderer.ts | 9 ++++-- log-viewer/src/styles/tokens.css | 6 ++-- 11 files changed, 53 insertions(+), 56 deletions(-) diff --git a/log-viewer/src/components/ColorSwatch.ts b/log-viewer/src/components/ColorSwatch.ts index 6d8ddfa4..0e40ef9e 100644 --- a/log-viewer/src/components/ColorSwatch.ts +++ b/log-viewer/src/components/ColorSwatch.ts @@ -7,25 +7,22 @@ import { customElement, property } from 'lit/decorators.js'; import { tokenStyles } from '../styles/tokens.styles.js'; /** - * The colour key beside a label — a legend chip, a reveal row, a tooltip row. One - * shape for every one of them, so a hue means the same thing wherever it appears. + * The colour key beside a label — a legend chip, a stacked bar's key, a tooltip row. + * One shape for every one of them, so a hue means the same thing wherever it appears. * - * A custom element rather than a shared stylesheet, because the canvas tooltips - * build their panels imperatively and cannot adopt a Lit one. + * A custom element rather than a shared stylesheet, because the metric strip's tooltip + * builds its panel as an HTML string and carries no stylesheet of its own; the element + * brings its own, tokens included. * - * The hue is decorative: whatever it stands for is named in text beside it, so the - * swatch is hidden from a screen reader and only ever carries a hover title. + * The hue is decorative: whatever it stands for is always named in text beside it, so + * the swatch stays hidden from a screen reader. */ @customElement('color-swatch') export class ColorSwatch extends LitElement { - /** Any CSS colour. Unset, the swatch takes `--row-hue` from the row around it. */ + /** Any CSS colour. */ @property() color = ''; - /** What the colour stands for, shown on hover. */ - @property() - label = ''; - static styles = [ tokenStyles, css` @@ -37,25 +34,20 @@ export class ColorSwatch extends LitElement { width: var(--lana-swatch-size); height: var(--lana-swatch-size); border-radius: var(--lana-swatch-radius); - background: var(--row-hue); } `, ]; connectedCallback(): void { super.connectedCallback(); + // Lit offers no declarative host attribute, and the spec forbids setting one in a + // constructor, so this is the only place it can go. this.setAttribute('aria-hidden', 'true'); } protected updated(): void { - // A colour is data, not a token, so it is written as a style rather than declared - // above. Cleared rather than set empty, so `--row-hue` can answer again. - this.style.setProperty('background', this.color || null); - if (this.label) { - this.title = this.label; - } else { - this.removeAttribute('title'); - } + // A colour is data, not a token, so it is written as a style rather than declared above. + this.style.background = this.color; } } diff --git a/log-viewer/src/components/EventVitals.ts b/log-viewer/src/components/EventVitals.ts index 880d9f6e..61e1cdb4 100644 --- a/log-viewer/src/components/EventVitals.ts +++ b/log-viewer/src/components/EventVitals.ts @@ -9,7 +9,13 @@ import { ifDefined } from 'lit/directives/if-defined.js'; import { logContext } from '../core/log/logContext.js'; import type { LogStore } from '../core/log/LogStore.js'; -import { EVENT_METRICS, formatBytes, HEAP_PEAK, usageParts } from '../core/metrics/eventMetrics.js'; +import { + EVENT_METRICS, + formatBytes, + HEAP_PEAK, + selfLabel, + usageParts, +} from '../core/metrics/eventMetrics.js'; import { DEFAULT_NAMESPACE, getCallerNamespace } from '../core/utility/CallerNamespace.js'; import { formatMs } from '../core/utility/Duration.js'; import { formatInteger } from '../core/utility/Util.js'; @@ -148,7 +154,7 @@ export class EventVitals extends LitElement { // Total and self read together, so they share one row. const total = events.reduce((sum, e) => sum + e.duration.total, 0); const self = events.reduce((sum, e) => sum + e.duration.self, 0); - this._row(rows, 'Time', html`${this._ms(total)}${qualifier(`self ${this._ms(self)}`)}`); + this._row(rows, 'Time', html`${this._ms(total)}${qualifier(selfLabel(this._ms(self)))}`); if (isAggregate) { this._row(rows, 'Avg', this._ms(total / events.length)); } @@ -232,7 +238,7 @@ export class EventVitals extends LitElement { continue; } const limit = limits ? metric.limit(limits, this.type) : 0; - const self = metric.hasSelf === false ? 0 : sum(metric.pick, 'self'); + const self = metric.noSelf ? 0 : sum(metric.pick, 'self'); const format = metric.bytes ? formatBytes : formatInteger; this._row( rows, diff --git a/log-viewer/src/components/__tests__/ColorSwatch.test.ts b/log-viewer/src/components/__tests__/ColorSwatch.test.ts index ebe48f82..86983422 100644 --- a/log-viewer/src/components/__tests__/ColorSwatch.test.ts +++ b/log-viewer/src/components/__tests__/ColorSwatch.test.ts @@ -8,7 +8,7 @@ import { describe, expect, it } from '@jest/globals'; import type { ColorSwatch } from '../ColorSwatch.js'; import '../ColorSwatch.js'; -async function mount(props: Partial> = {}) { +async function mount(props: Partial> = {}) { const element = document.createElement('color-swatch'); Object.assign(element, props); document.body.appendChild(element); @@ -23,25 +23,13 @@ describe('ColorSwatch', () => { expect(element.style.background).toBe('rgb(136, 174, 88)'); }); - it('leaves the row hue to answer when it has no colour of its own', async () => { - const element = await mount(); - - expect(element.style.background).toBe(''); - }); - - it('drops back to the row hue when the colour is taken away', async () => { + it('repaints when the colour changes', async () => { const element = await mount({ color: '#88ae58' }); - element.color = ''; + element.color = '#6d4c7d'; await element.updateComplete; - expect(element.style.background).toBe(''); - }); - - it('names what the colour stands for on hover', async () => { - const element = await mount({ color: '#88ae58', label: 'Apex' }); - - expect(element.title).toBe('Apex'); + expect(element.style.background).toBe('rgb(109, 76, 125)'); }); // The hue repeats something the row already says in text. diff --git a/log-viewer/src/core/events/EventBus.ts b/log-viewer/src/core/events/EventBus.ts index ee486b76..a31ad2d5 100644 --- a/log-viewer/src/core/events/EventBus.ts +++ b/log-viewer/src/core/events/EventBus.ts @@ -20,7 +20,9 @@ export const TAB_TO_SOURCE: Record = { }; /** Which of the database grids a selection came from. */ -export type StatementType = 'dml' | 'soql' | 'sosl'; +import type { StatementType } from '../metrics/eventMetrics.js'; + +export type { StatementType }; /** * A selection to inspect in the inspector. A single frame maps to one `eventIndex`; diff --git a/log-viewer/src/core/metrics/__tests__/eventMetrics.test.ts b/log-viewer/src/core/metrics/__tests__/eventMetrics.test.ts index c3d3d638..9c218d7f 100644 --- a/log-viewer/src/core/metrics/__tests__/eventMetrics.test.ts +++ b/log-viewer/src/core/metrics/__tests__/eventMetrics.test.ts @@ -30,10 +30,6 @@ describe('usageParts', () => { expect(parts.primary).toBe('7'); expect(parts.qualifiers).toEqual([]); }); - - it('reports a breach past the limit', () => { - expect(usageParts(120, 100, String, null).qualifiers).toEqual(['120.00%']); - }); }); describe('EVENT_METRICS', () => { diff --git a/log-viewer/src/core/metrics/eventMetrics.ts b/log-viewer/src/core/metrics/eventMetrics.ts index a933b935..04ba1ae6 100644 --- a/log-viewer/src/core/metrics/eventMetrics.ts +++ b/log-viewer/src/core/metrics/eventMetrics.ts @@ -3,11 +3,16 @@ */ import type { GovernorLimits, LogEvent, SelfTotal } from 'apex-log-parser'; -import { SOSL_ROWS_PER_QUERY_LIMIT } from '../../features/database/limits.js'; +/** The statement a database metric belongs to. */ +export type StatementType = 'dml' | 'soql' | 'sosl'; import { formatInteger } from '../utility/Util.js'; /** Which statement a selection is, where that decides a metric's denominator. */ -export type StatementType = 'dml' | 'soql' | 'sosl'; +/** + * Maximum records returned by a *single* SOSL query. A per-query cap, not a cumulative + * per-transaction total, so it is metered per row rather than summed against a total. + */ +export const SOSL_ROWS_PER_QUERY_LIMIT = 2000; export interface EventMetric { label: string; @@ -16,7 +21,7 @@ export interface EventMetric { limit: (limits: GovernorLimits, type?: StatementType) => number; bytes?: boolean; /** Throws only ever records on the leaf, so its self reading is meaningless. */ - hasSelf?: boolean; + noSelf?: boolean; } /** @@ -41,7 +46,7 @@ export const EVENT_METRICS: readonly EventMetric[] = [ // only reads as a limit when a single SOSL statement is selected. limit: (_limits, type) => (type === 'sosl' ? SOSL_ROWS_PER_QUERY_LIMIT : 0), }, - { label: 'Throws', pick: (e) => e.thrownCount, limit: () => 0, hasSelf: false }, + { label: 'Throws', pick: (e) => e.thrownCount, limit: () => 0, noSelf: true }, { label: 'Heap net', pick: (e) => e.heapAllocated, limit: () => 0, bytes: true }, { label: 'Heap alloc', pick: (e) => e.heapGross, limit: () => 0, bytes: true }, ]; diff --git a/log-viewer/src/features/database/limits.ts b/log-viewer/src/features/database/limits.ts index 456f09a1..50876296 100644 --- a/log-viewer/src/features/database/limits.ts +++ b/log-viewer/src/features/database/limits.ts @@ -19,7 +19,10 @@ export const APEX_GOVERNOR_LIMITS_DOC = * not a cumulative per-transaction total — so it's metered per row in the SOSL * table, not summed against a transaction limit. */ -export const SOSL_ROWS_PER_QUERY_LIMIT = 2000; +import { SOSL_ROWS_PER_QUERY_LIMIT } from '../../core/metrics/eventMetrics.js'; + +// Re-exported so the Database tab's own consumers keep one import for its numbers. +export { SOSL_ROWS_PER_QUERY_LIMIT }; /** Derived SOSL-rows metric fields (label/found are supplied by the caller). */ export interface SoslRowsMetric { diff --git a/log-viewer/src/features/timeline/components/TimelineKey.ts b/log-viewer/src/features/timeline/components/TimelineKey.ts index 929b05ef..53d0342d 100644 --- a/log-viewer/src/features/timeline/components/TimelineKey.ts +++ b/log-viewer/src/features/timeline/components/TimelineKey.ts @@ -68,7 +68,7 @@ export class Timelinekey extends LitElement { // The seam for the interactivity follow-up (hover/click → highlight): the // categories to match on, not the label, which names no category under legacy. html` - + ${entry.label} ${ entry.selfTimeNs !== undefined diff --git a/log-viewer/src/features/timeline/optimised/frameTooltipCard.ts b/log-viewer/src/features/timeline/optimised/frameTooltipCard.ts index 4be72cac..4f95c516 100644 --- a/log-viewer/src/features/timeline/optimised/frameTooltipCard.ts +++ b/log-viewer/src/features/timeline/optimised/frameTooltipCard.ts @@ -152,7 +152,7 @@ function metricRows(event: LogEvent, apexLog: ApexLog | null | undefined): CardR share !== null && !metric.bytes ? `${format(total)} of ${format(logTotal)}` : format(total), share, // Zero included: it is what says the work happened in a descendant, not here. - self: metric.hasSelf === false ? null : format(self), + self: metric.noSelf ? null : format(self), }); } diff --git a/log-viewer/src/features/timeline/optimised/metric-strip/MetricStripTooltipRenderer.ts b/log-viewer/src/features/timeline/optimised/metric-strip/MetricStripTooltipRenderer.ts index 4af8706e..d94c7bd5 100644 --- a/log-viewer/src/features/timeline/optimised/metric-strip/MetricStripTooltipRenderer.ts +++ b/log-viewer/src/features/timeline/optimised/metric-strip/MetricStripTooltipRenderer.ts @@ -38,6 +38,11 @@ import { getMetricStripColors, type MetricStripColors } from './metric-strip-col // web components import '../../../../components/ColorSwatch.js'; +/** One metric row. The swatch track reads the same token the swatch sizes itself by. */ +const ROW_STYLE = + 'display:grid;grid-template-columns:var(--lana-swatch-size) 120px 55px auto;' + + 'gap:4px;align-items:center;margin:2px 0;'; + /** * Metrics that should always be shown in the tooltip regardless of their value. * These are the "important" metrics users care about most. @@ -266,7 +271,7 @@ export class MetricStripTooltipRenderer extends BaseTooltipRenderer { : ''; rows.push( - `
` + + `
` + `` + `${metric.displayName}` + `${percentStr}%` + @@ -283,7 +288,7 @@ export class MetricStripTooltipRenderer extends BaseTooltipRenderer { const otherLineColor = hexToCSS(this.colors.tier3); rows.push( - `
` + + `
` + `` + `Other (${hiddenMetrics.length})` + `${otherPercentStr}%` + diff --git a/log-viewer/src/styles/tokens.css b/log-viewer/src/styles/tokens.css index 13e10bbd..eb7a83b9 100644 --- a/log-viewer/src/styles/tokens.css +++ b/log-viewer/src/styles/tokens.css @@ -40,9 +40,9 @@ --lana-stroke: var(--vscode-strokeThickness, 1px); - /* The colour key every legend, reveal row and tooltip draws (``). Ours: - no VS Code var carries the role, and the chrome radius would round this size to a - circle. */ + /* The colour key a legend or a tooltip row draws (``, and the track the + metric strip reserves for one). Ours: no VS Code var carries the role, and the chrome + radius would round this size to a circle. */ --lana-swatch-size: var(--lana-space-sm); --lana-swatch-radius: 2px; From de9234a5355445de7ad88ceb81476377ba12e297 Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Tue, 25 Aug 2026 12:09:14 +0100 Subject: [PATCH 08/17] fix(log-viewer): keep Pixi's global texture pool across a timeline teardown Toggling the timeline between modern and legacy threw `Cannot read properties of undefined (reading 'push')` from `TexturePoolClass.returnTexture`. `app.destroy(true, ...)` releases Pixi's global resources, and TexturePool is one of them. A timeline runs three apps, so the first destroy emptied the pool the other two still return their text textures to. One `destroyTimelineApp` now owns the call, so the rule holds for the next app as well as these three. --- .../src/features/timeline/optimised/FlameChart.ts | 4 +++- .../metric-strip/MetricStripOrchestrator.ts | 4 +++- .../orchestrators/MinimapOrchestrator.ts | 4 +++- .../timeline/optimised/rendering/pixiApp.ts | 15 +++++++++++++++ 4 files changed, 24 insertions(+), 3 deletions(-) create mode 100644 log-viewer/src/features/timeline/optimised/rendering/pixiApp.ts diff --git a/log-viewer/src/features/timeline/optimised/FlameChart.ts b/log-viewer/src/features/timeline/optimised/FlameChart.ts index d95e41e1..5378dc1f 100644 --- a/log-viewer/src/features/timeline/optimised/FlameChart.ts +++ b/log-viewer/src/features/timeline/optimised/FlameChart.ts @@ -12,6 +12,8 @@ import type { LogEvent } from 'apex-log-parser'; import * as PIXI from 'pixi.js'; + +import { destroyTimelineApp } from './rendering/pixiApp.js'; import type { EditorColors, EventNode, @@ -516,7 +518,7 @@ export class FlameChart { // Destroy main app if (this.app) { - this.app.destroy(true, { children: true, texture: true }); + destroyTimelineApp(this.app); this.app = null; } diff --git a/log-viewer/src/features/timeline/optimised/metric-strip/MetricStripOrchestrator.ts b/log-viewer/src/features/timeline/optimised/metric-strip/MetricStripOrchestrator.ts index 8579d8ed..e3b00ef5 100644 --- a/log-viewer/src/features/timeline/optimised/metric-strip/MetricStripOrchestrator.ts +++ b/log-viewer/src/features/timeline/optimised/metric-strip/MetricStripOrchestrator.ts @@ -22,6 +22,8 @@ */ import * as PIXI from 'pixi.js'; + +import { destroyTimelineApp } from '../rendering/pixiApp.js'; import type { HeatStripTimeSeries, TimelineMarker, @@ -279,7 +281,7 @@ export class MetricStripOrchestrator { this.container = null; if (this.app) { - this.app.destroy(true, { children: true, texture: true }); + destroyTimelineApp(this.app); this.app = null; } diff --git a/log-viewer/src/features/timeline/optimised/orchestrators/MinimapOrchestrator.ts b/log-viewer/src/features/timeline/optimised/orchestrators/MinimapOrchestrator.ts index da643130..3e54d7bc 100644 --- a/log-viewer/src/features/timeline/optimised/orchestrators/MinimapOrchestrator.ts +++ b/log-viewer/src/features/timeline/optimised/orchestrators/MinimapOrchestrator.ts @@ -21,6 +21,8 @@ */ import * as PIXI from 'pixi.js'; + +import { destroyTimelineApp } from '../rendering/pixiApp.js'; import type { TimelineMarker, ViewportState } from '../../types/flamechart.types.js'; import { TIMELINE_CONSTANTS } from '../../types/flamechart.types.js'; import type { RectangleCache } from '../RectangleCache.js'; @@ -254,7 +256,7 @@ export class MinimapOrchestrator { this.container = null; if (this.app) { - this.app.destroy(true, { children: true, texture: true }); + destroyTimelineApp(this.app); this.app = null; } diff --git a/log-viewer/src/features/timeline/optimised/rendering/pixiApp.ts b/log-viewer/src/features/timeline/optimised/rendering/pixiApp.ts new file mode 100644 index 00000000..f21e52c1 --- /dev/null +++ b/log-viewer/src/features/timeline/optimised/rendering/pixiApp.ts @@ -0,0 +1,15 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ +import type * as PIXI from 'pixi.js'; + +/** + * Tears down one of the timeline's Pixi apps. + * + * `removeView`, never a bare `true`: a bare `true` also releases Pixi's global resources, + * and TexturePool is one of them. A timeline runs three apps, so releasing on the first + * destroy empties the pool the other two still return their text textures to. + */ +export function destroyTimelineApp(app: PIXI.Application): void { + app.destroy({ removeView: true }, { children: true, texture: true }); +} From a85c1f37c4b78fed1c1dd0cf486c601330ba4a45 Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Tue, 25 Aug 2026 12:09:14 +0100 Subject: [PATCH 09/17] fix(log-viewer): wait for the chart's container before drawing into it The chart takes its height from a flex row that a `lana.timeline.legacy` toggle re-lays-out around it. Measuring before that settled read 0, and `init` rejects that outright: "Container must have non-zero dimensions". Wait for a size first, so the user sees a chart rather than an error about a container that is fine. --- .../timeline/components/TimelineFlameChart.ts | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/log-viewer/src/features/timeline/components/TimelineFlameChart.ts b/log-viewer/src/features/timeline/components/TimelineFlameChart.ts index 6b5db0b9..463c292f 100644 --- a/log-viewer/src/features/timeline/components/TimelineFlameChart.ts +++ b/log-viewer/src/features/timeline/components/TimelineFlameChart.ts @@ -128,6 +128,9 @@ export class TimelineFlameChart extends LitElement { /** Bumped by every `cleanup()`, so an in-flight `init` can tell it was superseded. */ private initEpoch = 0; + /** Ends an outstanding layout wait, so a teardown never leaves one pending for ever. */ + private endLayoutWait: (() => void) | null = null; + override connectedCallback(): void { super.connectedCallback(); this.themeUnsubscribe ??= themeObserver.on(() => { @@ -168,6 +171,27 @@ export class TimelineFlameChart extends LitElement { } } + /** Settles once the container has a size to draw into, or when a teardown ends the wait. */ + private waitForLayout(container: HTMLElement): Promise { + if (hasSize(container)) { + return Promise.resolve(); + } + + return new Promise((resolve) => { + const observer = new ResizeObserver(() => { + if (hasSize(container)) { + this.endLayoutWait?.(); + } + }); + this.endLayoutWait = () => { + observer.disconnect(); + this.endLayoutWait = null; + resolve(); + }; + observer.observe(container); + }); + } + /** * Push the current appearance into the renderers. * @@ -211,6 +235,14 @@ export class TimelineFlameChart extends LitElement { }; const epoch = this.initEpoch; + // Height comes from a flex row that a `lana.timeline.legacy` toggle re-lays-out + // around the chart. Measuring before that settles reads 0, which `init` rejects + // outright — so wait for a size rather than report a container the user cannot see. + await this.waitForLayout(this.containerRef); + if (epoch !== this.initEpoch) { + return; + } + const timeline = new ApexLogTimeline(); await timeline.init(this.containerRef, this.apexLog, optionsWithTheme); @@ -298,6 +330,7 @@ export class TimelineFlameChart extends LitElement { private cleanup(): void { // Supersede any in-flight `initializeTimeline`. this.initEpoch++; + this.endLayoutWait?.(); // Destroy renderer if (this.apexLogTimeline) { @@ -335,3 +368,9 @@ export class TimelineFlameChart extends LitElement { `; } } + +/** A box the renderer can draw into: both axes measured, and neither of them zero. */ +function hasSize(element: HTMLElement): boolean { + const { width, height } = element.getBoundingClientRect(); + return width > 0 && height > 0; +} From 478ab7631fb6e141f141391485d11c08e50f7224 Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Tue, 25 Aug 2026 12:09:14 +0100 Subject: [PATCH 10/17] perf(log-viewer): rebuild the metric-strip hover only when the reading changes The classifier allocates its points once per `processData` and hands back the same object for every pointer position inside one time segment. Identity therefore tells a re-position from a new reading. Sweeping a segment no longer re-parses the panel or upgrades a swatch element per row, on a mousemove that is not throttled. --- .../MetricStripTooltipRenderer.ts | 26 +++++++++++++------ 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/log-viewer/src/features/timeline/optimised/metric-strip/MetricStripTooltipRenderer.ts b/log-viewer/src/features/timeline/optimised/metric-strip/MetricStripTooltipRenderer.ts index d94c7bd5..9f3ac9ca 100644 --- a/log-viewer/src/features/timeline/optimised/metric-strip/MetricStripTooltipRenderer.ts +++ b/log-viewer/src/features/timeline/optimised/metric-strip/MetricStripTooltipRenderer.ts @@ -79,6 +79,9 @@ export class MetricStripTooltipRenderer extends BaseTooltipRenderer { /** Strip height for positioning tooltip below. */ private stripHeight: number = 60; + /** The reading the panel holds, so sweeping one segment is not a rebuild. */ + private shownPoint: MetricStripDataPoint | null = null; + constructor(htmlContainer: HTMLElement, options: MetricStripTooltipOptions = {}) { super(htmlContainer, { mode: 'cursor-offset', offset: 8, padding: 4 }); @@ -114,17 +117,24 @@ export class MetricStripTooltipRenderer extends BaseTooltipRenderer { this.stripHeight = stripHeight; } - // Build tooltip content - const rows = this.buildTooltipRows(dataPoint, classifiedMetrics); + // The classifier allocates its points once per `processData` and hands back the same + // object for every pointer position inside one time segment, so identity tells a + // re-position from a new reading. A rebuild re-parses the panel and upgrades a swatch + // element per row, on a mousemove that is not throttled. `hide` leaves the markup in + // place, so the cache stays good across one. + if (dataPoint !== this.shownPoint) { + const rows = this.buildTooltipRows(dataPoint, classifiedMetrics); + + if (rows.length === 0) { + this.hide(); + return; + } - if (rows.length === 0) { - this.hide(); - return; + const titleHtml = `
${this.title}
`; + this.setContent(titleHtml + rows.join('')); + this.shownPoint = dataPoint; } - // Set content - const titleHtml = `
${this.title}
`; - this.setContent(titleHtml + rows.join('')); this.showElement(); // Position tooltip (Y is ignored, we always position below the strip) From af9bc499d4eea1b1830237e5243afe7d92f4dc66 Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Tue, 25 Aug 2026 12:09:15 +0100 Subject: [PATCH 11/17] docs(log-viewer): reattach the docs the metric move orphaned Moving `StatementType` and `SOSL_ROWS_PER_QUERY_LIMIT` left three doc blocks documenting `import` statements. --- log-viewer/src/core/events/EventBus.ts | 4 +--- log-viewer/src/core/metrics/eventMetrics.ts | 9 ++++++--- log-viewer/src/features/database/limits.ts | 8 +------- 3 files changed, 8 insertions(+), 13 deletions(-) diff --git a/log-viewer/src/core/events/EventBus.ts b/log-viewer/src/core/events/EventBus.ts index a31ad2d5..94c18091 100644 --- a/log-viewer/src/core/events/EventBus.ts +++ b/log-viewer/src/core/events/EventBus.ts @@ -1,6 +1,7 @@ /* * Copyright (c) 2026 Certinia Inc. All rights reserved. */ +import type { StatementType } from '../metrics/eventMetrics.js'; /** * Type-safe event bus for cross-component communication. @@ -19,9 +20,6 @@ export const TAB_TO_SOURCE: Record = { 'database-tab': 'database', }; -/** Which of the database grids a selection came from. */ -import type { StatementType } from '../metrics/eventMetrics.js'; - export type { StatementType }; /** diff --git a/log-viewer/src/core/metrics/eventMetrics.ts b/log-viewer/src/core/metrics/eventMetrics.ts index 04ba1ae6..165c0dd1 100644 --- a/log-viewer/src/core/metrics/eventMetrics.ts +++ b/log-viewer/src/core/metrics/eventMetrics.ts @@ -3,11 +3,14 @@ */ import type { GovernorLimits, LogEvent, SelfTotal } from 'apex-log-parser'; -/** The statement a database metric belongs to. */ -export type StatementType = 'dml' | 'soql' | 'sosl'; import { formatInteger } from '../utility/Util.js'; -/** Which statement a selection is, where that decides a metric's denominator. */ +/** + * The statement a database metric belongs to — which grid a selection came from, and + * which metric a per-statement denominator applies to. + */ +export type StatementType = 'dml' | 'soql' | 'sosl'; + /** * Maximum records returned by a *single* SOSL query. A per-query cap, not a cumulative * per-transaction total, so it is metered per row rather than summed against a total. diff --git a/log-viewer/src/features/database/limits.ts b/log-viewer/src/features/database/limits.ts index 50876296..4a3f94dd 100644 --- a/log-viewer/src/features/database/limits.ts +++ b/log-viewer/src/features/database/limits.ts @@ -1,6 +1,7 @@ /* * Copyright (c) 2026 Certinia Inc. All rights reserved. */ +import { SOSL_ROWS_PER_QUERY_LIMIT } from '../../core/metrics/eventMetrics.js'; /** * Canonical reference for the numbers used across the Database tab. Check here @@ -14,13 +15,6 @@ export const APEX_GOVERNOR_LIMITS_DOC = 'https://developer.salesforce.com/docs/atlas.en-us.apexcode.meta/apexcode/apex_gov_limits.htm'; -/** - * Maximum records returned by a *single* SOSL query. This is a per-query cap, - * not a cumulative per-transaction total — so it's metered per row in the SOSL - * table, not summed against a transaction limit. - */ -import { SOSL_ROWS_PER_QUERY_LIMIT } from '../../core/metrics/eventMetrics.js'; - // Re-exported so the Database tab's own consumers keep one import for its numbers. export { SOSL_ROWS_PER_QUERY_LIMIT }; From 39a90a82cfde56f6640334d208f55be08aa360f6 Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Tue, 25 Aug 2026 12:50:31 +0100 Subject: [PATCH 12/17] fix(log-viewer): follow a previewed timeline theme before settings arrive `categoryPalette(timeline && {...})` collapses to `null` while no settings have been pushed, so the previewed theme was dropped and the legend painted the default palette while the chart drew the chosen one. The palette now takes the previewed theme as its own argument, which also removes the spread that was papering over it. --- log-viewer/src/components/__tests__/categoryTime.test.ts | 9 +++++++++ log-viewer/src/components/categoryTime.ts | 5 ++++- .../src/features/timeline/components/TimelineView.ts | 4 +--- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/log-viewer/src/components/__tests__/categoryTime.test.ts b/log-viewer/src/components/__tests__/categoryTime.test.ts index 86189756..c5f3b75b 100644 --- a/log-viewer/src/components/__tests__/categoryTime.test.ts +++ b/log-viewer/src/components/__tests__/categoryTime.test.ts @@ -107,6 +107,15 @@ describe('categoryPalette', () => { expect(color('Automation')).toBe('#444444'); // Workflow }); + // The quick pick is never persisted, so its theme can arrive before any settings do. + it('follows a previewed theme with no settings pushed yet', () => { + const previewed = '50 Shades of Green Bright'; + const color = categoryPalette(null, previewed); + + expect(color('SOQL')).toBe(getTheme(previewed).soql); + expect(color('SOQL')).not.toBe(getTheme(DEFAULT_THEME_NAME).soql); + }); + it('falls back to the default theme with no settings, and grey for Other', () => { const color = categoryPalette(null); diff --git a/log-viewer/src/components/categoryTime.ts b/log-viewer/src/components/categoryTime.ts index fb388416..4b3442df 100644 --- a/log-viewer/src/components/categoryTime.ts +++ b/log-viewer/src/components/categoryTime.ts @@ -81,9 +81,12 @@ export function categorySelfTimes(root: ApexLog): CategoryTime[] { * first), or the legacy per-group colours when the legacy timeline is on. With * no settings yet (standalone host, or before the first push) the default * theme answers. + * @param activeTheme - A previewed theme, which wins over the pushed one. Never + * persisted, so it can arrive before any settings do. */ export function categoryPalette( timeline: LanaSettings['timeline'] | null, + activeTheme?: string | null, ): (category: string) => string { if (timeline?.legacy) { return (category) => { @@ -96,7 +99,7 @@ export function categoryPalette( if (timeline) { addCustomThemes(timeline.customThemes); } - const colors = getTheme(timeline?.activeTheme ?? DEFAULT_THEME_NAME); + const colors = getTheme(activeTheme ?? timeline?.activeTheme ?? DEFAULT_THEME_NAME); return (category) => { const key = CATEGORY_THEME_KEY[category]; return key ? colors[key] : OTHER_COLOR; diff --git a/log-viewer/src/features/timeline/components/TimelineView.ts b/log-viewer/src/features/timeline/components/TimelineView.ts index ff67c565..53a25906 100644 --- a/log-viewer/src/features/timeline/components/TimelineView.ts +++ b/log-viewer/src/features/timeline/components/TimelineView.ts @@ -325,9 +325,7 @@ export class TimelineView extends LitElement { private rebuildTimelineKeys(): void { const timeline = this.timelineSettings; this.timelineKeys = toTimelineKeys( - categoryPalette( - timeline && { ...timeline, activeTheme: this.activeTheme ?? timeline.activeTheme }, - ), + categoryPalette(timeline, this.activeTheme), this.selfTimes, timeline?.legacy, ); From 66d7ac53c1807cba0dede91a27a0a4a53b53fff6 Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Tue, 25 Aug 2026 12:50:31 +0100 Subject: [PATCH 13/17] fix(log-viewer): join the legend's categories on a delimiter they survive `data-category` joined on a space, but `Code Unit` is one category that contains a space, so a consumer splitting the list read two categories. --- .../timeline/__tests__/TimelineKey.test.ts | 18 ++++++++++++++++++ .../timeline/components/TimelineKey.ts | 3 ++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/log-viewer/src/features/timeline/__tests__/TimelineKey.test.ts b/log-viewer/src/features/timeline/__tests__/TimelineKey.test.ts index 551ea4d3..9fb041c3 100644 --- a/log-viewer/src/features/timeline/__tests__/TimelineKey.test.ts +++ b/log-viewer/src/features/timeline/__tests__/TimelineKey.test.ts @@ -44,6 +44,24 @@ describe('TimelineKey', () => { expect(apex?.querySelector('color-swatch')?.color).toBe('rgb(43, 143, 129)'); }); + // Comma, not space: `Code Unit` is one category that contains a space, so a + // space-joined list could not be split back apart. + it('lists every folded category, splittable on the comma', async () => { + const el = await mount([ + { + label: 'Method', + fillColor: 'rgb(1, 2, 3)', + categories: ['Apex', 'Callout'], + selfTimeNs: 1_000, + }, + { label: 'Code Unit', fillColor: 'rgb(4, 5, 6)', categories: ['Code Unit'] }, + ]); + + const [method, codeUnit] = chips(el); + expect(method?.dataset['category']?.split(',')).toEqual(['Apex', 'Callout']); + expect(codeUnit?.dataset['category']?.split(',')).toEqual(['Code Unit']); + }); + it('shows the compact self time when present', async () => { const el = await mount([ { diff --git a/log-viewer/src/features/timeline/components/TimelineKey.ts b/log-viewer/src/features/timeline/components/TimelineKey.ts index 53d0342d..8e45a9c1 100644 --- a/log-viewer/src/features/timeline/components/TimelineKey.ts +++ b/log-viewer/src/features/timeline/components/TimelineKey.ts @@ -67,7 +67,8 @@ export class Timelinekey extends LitElement { (entry) => // The seam for the interactivity follow-up (hover/click → highlight): the // categories to match on, not the label, which names no category under legacy. - html` + // Comma-joined, never space: `Code Unit` is one category with a space in it. + html` ${entry.label} ${ From 5e70c14e6e16c2f66dbc30dcd33dcaa8791204c4 Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Tue, 25 Aug 2026 12:50:31 +0100 Subject: [PATCH 14/17] fix(log-viewer): rule the hover card only where it has an identity to part MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rule parts what the frame is from what it measured. It went on the first group whatever the card held, so on a marker card — which has no identity line — it parted nothing. --- .../timeline/__tests__/tooltip.test.ts | 27 +++++++++++++++++++ .../optimised/FrameTooltipRenderer.ts | 8 +++--- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/log-viewer/src/features/timeline/__tests__/tooltip.test.ts b/log-viewer/src/features/timeline/__tests__/tooltip.test.ts index e68e6307..545f016e 100644 --- a/log-viewer/src/features/timeline/__tests__/tooltip.test.ts +++ b/log-viewer/src/features/timeline/__tests__/tooltip.test.ts @@ -23,6 +23,7 @@ import { type TooltipAnchor, type TooltipOptions, } from '../optimised/FrameTooltipRenderer.js'; +import type { TimelineMarker } from '../types/flamechart.types.js'; /** Delay before the first tooltip appears; mirrors SHOW_DELAY_MS. */ const SHOW_DELAY_MS = 60; @@ -805,4 +806,30 @@ describe('FrameTooltipRenderer', () => { expect(tooltipEl().textContent).toMatch(/\d+\s*(s|ms)/); }); }); + describe('the group rule', () => { + it('rules the first group, parting the identity from the readings', () => { + showSettled(createEvent(0, 100), cursorAnchor(100, 100)); + + expect(tooltipEl().querySelectorAll('.tooltip-group--ruled')).toHaveLength(1); + }); + + // A marker card has no identity line, so the rule would part nothing. + it('leaves a marker card unruled, though it still groups', () => { + frameTooltipRenderer.showTruncation( + { + id: 'm1', + type: 'exception', + summary: 'System.NullPointerException', + startTime: 1_000_000, + endTime: 3_000_000, + } as TimelineMarker, + cursorAnchor(100, 100), + ); + jest.advanceTimersByTime(SHOW_DELAY_MS); + + const panel = tooltipEl(); + expect(panel.querySelectorAll('.tooltip-group')).toHaveLength(1); + expect(panel.querySelectorAll('.tooltip-group--ruled')).toHaveLength(0); + }); + }); }); diff --git a/log-viewer/src/features/timeline/optimised/FrameTooltipRenderer.ts b/log-viewer/src/features/timeline/optimised/FrameTooltipRenderer.ts index 3b8c43fd..9b313850 100644 --- a/log-viewer/src/features/timeline/optimised/FrameTooltipRenderer.ts +++ b/log-viewer/src/features/timeline/optimised/FrameTooltipRenderer.ts @@ -497,11 +497,13 @@ export class FrameTooltipRenderer { if (card.identity?.length) { body.appendChild(element('div', 'tooltip-identity', card.identity.join(' · '))); } + const ruled = !!card.identity?.length; card.groups.forEach((group, index) => { const box = document.createElement('div'); - // Only the first group carries the rule that parts what the frame is from what it - // measured. Sibling divs give CSS no "first group" selector to do it with. - box.className = index ? 'tooltip-group' : 'tooltip-group tooltip-group--ruled'; + // The rule parts what the frame is from what it measured, so only the first group + // takes it, and only where there is an identity above it to part from — a marker + // card has none. Sibling divs give CSS no "first group" selector to do it with. + box.className = !index && ruled ? 'tooltip-group tooltip-group--ruled' : 'tooltip-group'; group.forEach((row) => box.appendChild(rowElement(row))); body.appendChild(box); }); From b55df1152d712ba4d42df6c6e54c36933214b3ba Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Tue, 25 Aug 2026 12:59:12 +0100 Subject: [PATCH 15/17] perf(log-viewer): batch the metric-strip hover's placement into a frame The renderer overrode `positionTooltip` to sit below the strip, and in doing so reimplemented the base's maths without its `requestAnimationFrame` batching. It read `offsetWidth` right after writing `display`, so every raw mousemove forced a layout flush. `below-anchor` puts the rule in the base class instead: cursor X with the same flip, a fixed offset below the anchor, and never flipping up over the band the panel must not cover. --- .../MetricStripTooltipRenderer.test.ts | 70 +++++++++++++++++++ .../MetricStripTooltipRenderer.ts | 35 ++-------- .../rendering/BaseTooltipRenderer.ts | 13 +++- 3 files changed, 86 insertions(+), 32 deletions(-) diff --git a/log-viewer/src/features/timeline/optimised/metric-strip/MetricStripTooltipRenderer.test.ts b/log-viewer/src/features/timeline/optimised/metric-strip/MetricStripTooltipRenderer.test.ts index 04fca6eb..79d347a9 100644 --- a/log-viewer/src/features/timeline/optimised/metric-strip/MetricStripTooltipRenderer.test.ts +++ b/log-viewer/src/features/timeline/optimised/metric-strip/MetricStripTooltipRenderer.test.ts @@ -49,6 +49,76 @@ describe('MetricStripTooltipRenderer', () => { document.body.removeChild(container); }); + /** The panel element. */ + function panel(): HTMLElement { + return container.querySelector('.metric-strip-tooltip') as HTMLElement; + } + + /** jsdom lays nothing out, so the widths the placement maths reads have to be declared. */ + function declareWidths(panelWidth: number, containerWidth: number): void { + Object.defineProperty(panel(), 'offsetWidth', { value: panelWidth, configurable: true }); + Object.defineProperty(container, 'offsetWidth', { value: containerWidth, configurable: true }); + } + + /** Placement is batched into a frame, so it has to be let through. */ + function flushFrame(): Promise { + return new Promise((resolve) => requestAnimationFrame(() => resolve())); + } + + /** One always-show metric, enough to get a row on the panel. */ + const oneMetric = [metric('cpuTime', 'CPU Time', 0.9)]; + const onePoint: MetricStripDataPoint = { + timestamp: 0, + values: new Map([['cpuTime', 0.5]]), + rawValues: new Map(), + tier3Max: 0, + }; + + describe('placement', () => { + it('sits a fixed offset below the strip, clear of what is being read', async () => { + renderer.show(100, 0, onePoint, oneMetric, 60); + declareWidths(200, 1000); + await flushFrame(); + + // stripHeight 60 + offset 8; never above, so the panel cannot cover the strip. + expect(panel().style.top).toBe('68px'); + expect(panel().style.left).toBe('108px'); + }); + + it('flips to the left of the cursor rather than overflow the container', async () => { + renderer.show(950, 0, onePoint, oneMetric, 60); + declareWidths(200, 1000); + await flushFrame(); + + // 950 + 8 + 200 overflows 1000, so the panel goes to the cursor's left. + expect(panel().style.left).toBe('742px'); + }); + }); + + // The classifier hands back the same point object across one time segment, so sweeping a + // segment must not rebuild: a mutation of the panel survives the second show. + it('re-positions without rebuilding when the reading has not changed', async () => { + renderer.show(100, 0, onePoint, oneMetric, 60); + panel().dataset['marked'] = 'yes'; + + renderer.show(140, 0, onePoint, oneMetric, 60); + declareWidths(200, 1000); + await flushFrame(); + + expect(panel().dataset['marked']).toBe('yes'); + expect(panel().style.left).toBe('148px'); + }); + + it('rebuilds when the reading changes', () => { + renderer.show(100, 0, onePoint, oneMetric, 60); + const first = panel().innerHTML; + + const later: MetricStripDataPoint = { ...onePoint, values: new Map([['cpuTime', 0.9]]) }; + renderer.show(100, 0, later, oneMetric, 60); + + expect(panel().innerHTML).not.toBe(first); + }); + it('orders rows by global peak, independent of the value at the cursor', () => { // All three are always-show metrics, so membership is fixed and we isolate ordering. const metrics = [ diff --git a/log-viewer/src/features/timeline/optimised/metric-strip/MetricStripTooltipRenderer.ts b/log-viewer/src/features/timeline/optimised/metric-strip/MetricStripTooltipRenderer.ts index 9f3ac9ca..10f22260 100644 --- a/log-viewer/src/features/timeline/optimised/metric-strip/MetricStripTooltipRenderer.ts +++ b/log-viewer/src/features/timeline/optimised/metric-strip/MetricStripTooltipRenderer.ts @@ -83,7 +83,7 @@ export class MetricStripTooltipRenderer extends BaseTooltipRenderer { private shownPoint: MetricStripDataPoint | null = null; constructor(htmlContainer: HTMLElement, options: MetricStripTooltipOptions = {}) { - super(htmlContainer, { mode: 'cursor-offset', offset: 8, padding: 4 }); + super(htmlContainer, { mode: 'below-anchor', offset: 8, padding: 4 }); this.colors = getMetricStripColors(); this.title = options.title ?? 'Governor Limits'; @@ -137,8 +137,9 @@ export class MetricStripTooltipRenderer extends BaseTooltipRenderer { this.showElement(); - // Position tooltip (Y is ignored, we always position below the strip) - this.positionTooltip(screenX, 0); + // The strip is the anchor: `below-anchor` keeps the panel clear of it, and batches the + // measurement into one frame rather than forcing a layout per pointer move. + this.positionTooltip(screenX, this.stripHeight); } // ============================================================================ @@ -155,34 +156,6 @@ export class MetricStripTooltipRenderer extends BaseTooltipRenderer { return tooltip; } - /** - * Position tooltip below the metric strip. - * Overrides base positioning to always place tooltip below the strip, - * preventing it from covering the visualization or the mouse cursor. - */ - protected override positionTooltip(screenX: number, _screenY: number): void { - const offset = this.positionOptions.offset ?? 8; - const padding = this.positionOptions.padding ?? 4; - - // Use base class positioning logic pattern (calls cancelPendingPositioning internally) - // But we need direct positioning here, so call super pattern manually - const tooltipWidth = this.tooltipElement.offsetWidth; - const containerWidth = this.container.offsetWidth; - - // X: position at cursor with flip if needed - let left = screenX + offset; - if (left + tooltipWidth > containerWidth) { - left = screenX - tooltipWidth - offset; - } - left = Math.max(padding, Math.min(containerWidth - tooltipWidth - padding, left)); - - // Y: always position below the strip - const top = this.stripHeight + offset; - - this.tooltipElement.style.left = `${left}px`; - this.tooltipElement.style.top = `${top}px`; - } - // ============================================================================ // PRIVATE METHODS // ============================================================================ diff --git a/log-viewer/src/features/timeline/optimised/rendering/BaseTooltipRenderer.ts b/log-viewer/src/features/timeline/optimised/rendering/BaseTooltipRenderer.ts index 441c5b10..16804bd7 100644 --- a/log-viewer/src/features/timeline/optimised/rendering/BaseTooltipRenderer.ts +++ b/log-viewer/src/features/timeline/optimised/rendering/BaseTooltipRenderer.ts @@ -26,7 +26,9 @@ export type TooltipPositionMode = /** Position at cursor with offset, flip if needed */ | 'cursor-offset' /** Center on X, position above Y but flip below if not enough room above */ - | 'adaptive'; + | 'adaptive' + /** Cursor X with flip; a fixed offset below the anchor's Y, never flipping up */ + | 'below-anchor'; /** * Options for tooltip positioning. @@ -190,6 +192,15 @@ export abstract class BaseTooltipRenderer { // Clamp to container bounds top = Math.min(containerHeight - tooltipHeight - padding, top); } + } else if (mode === 'below-anchor') { + // The anchor is a band the panel must not cover — the metric strip — so Y is fixed + // below it and never flips up over the thing being read. + left = screenX + offset; + if (left + tooltipWidth > containerWidth) { + left = screenX - tooltipWidth - offset; + } + left = Math.max(padding, Math.min(containerWidth - tooltipWidth - padding, left)); + top = screenY + offset; } else { // Cursor offset mode: position to the right and below, flip if needed left = screenX + offset; From 224ea352cc5e09c3c0de594f457add2ac17c02bb Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Tue, 25 Aug 2026 13:01:37 +0100 Subject: [PATCH 16/17] refactor(log-viewer): let the chart own the container it cannot draw into MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wait for a size sat in the Lit component, guarding one call while `FlameChart.init` still threw on zero for every other caller. It was also a second ResizeObserver on a container the chart already observes, and an unbounded one: a container that never gained a size hung instead of reporting. The chart now waits for its own container, bounded by a frame count, so the fault still surfaces. That covers every zero-size trigger — a hidden tab, a collapsed panel — not just the settings toggle, and drops the component's duplicate of the predicate `init` throws on. --- .../timeline/components/TimelineFlameChart.ts | 39 ------------------- .../features/timeline/optimised/FlameChart.ts | 38 ++++++++++++++---- 2 files changed, 30 insertions(+), 47 deletions(-) diff --git a/log-viewer/src/features/timeline/components/TimelineFlameChart.ts b/log-viewer/src/features/timeline/components/TimelineFlameChart.ts index 463c292f..6b5db0b9 100644 --- a/log-viewer/src/features/timeline/components/TimelineFlameChart.ts +++ b/log-viewer/src/features/timeline/components/TimelineFlameChart.ts @@ -128,9 +128,6 @@ export class TimelineFlameChart extends LitElement { /** Bumped by every `cleanup()`, so an in-flight `init` can tell it was superseded. */ private initEpoch = 0; - /** Ends an outstanding layout wait, so a teardown never leaves one pending for ever. */ - private endLayoutWait: (() => void) | null = null; - override connectedCallback(): void { super.connectedCallback(); this.themeUnsubscribe ??= themeObserver.on(() => { @@ -171,27 +168,6 @@ export class TimelineFlameChart extends LitElement { } } - /** Settles once the container has a size to draw into, or when a teardown ends the wait. */ - private waitForLayout(container: HTMLElement): Promise { - if (hasSize(container)) { - return Promise.resolve(); - } - - return new Promise((resolve) => { - const observer = new ResizeObserver(() => { - if (hasSize(container)) { - this.endLayoutWait?.(); - } - }); - this.endLayoutWait = () => { - observer.disconnect(); - this.endLayoutWait = null; - resolve(); - }; - observer.observe(container); - }); - } - /** * Push the current appearance into the renderers. * @@ -235,14 +211,6 @@ export class TimelineFlameChart extends LitElement { }; const epoch = this.initEpoch; - // Height comes from a flex row that a `lana.timeline.legacy` toggle re-lays-out - // around the chart. Measuring before that settles reads 0, which `init` rejects - // outright — so wait for a size rather than report a container the user cannot see. - await this.waitForLayout(this.containerRef); - if (epoch !== this.initEpoch) { - return; - } - const timeline = new ApexLogTimeline(); await timeline.init(this.containerRef, this.apexLog, optionsWithTheme); @@ -330,7 +298,6 @@ export class TimelineFlameChart extends LitElement { private cleanup(): void { // Supersede any in-flight `initializeTimeline`. this.initEpoch++; - this.endLayoutWait?.(); // Destroy renderer if (this.apexLogTimeline) { @@ -368,9 +335,3 @@ export class TimelineFlameChart extends LitElement { `; } } - -/** A box the renderer can draw into: both axes measured, and neither of them zero. */ -function hasSize(element: HTMLElement): boolean { - const { width, height } = element.getBoundingClientRect(); - return width > 0 && height > 0; -} diff --git a/log-viewer/src/features/timeline/optimised/FlameChart.ts b/log-viewer/src/features/timeline/optimised/FlameChart.ts index 5378dc1f..599696f9 100644 --- a/log-viewer/src/features/timeline/optimised/FlameChart.ts +++ b/log-viewer/src/features/timeline/optimised/FlameChart.ts @@ -70,6 +70,13 @@ import { METRIC_STRIP_GAP, MetricStripOrchestrator, } from './metric-strip/MetricStripOrchestrator.js'; +import { waitForNextFrame } from '../../../core/utility/FrameBudget.js'; + +/** + * How long to let a container settle before giving up on it. Bounded, so a container that + * genuinely never gains a size reports the fault rather than hanging on a promise. + */ +const SIZE_WAIT_FRAMES = 60; export interface FlameChartCallbacks { onMouseMove?: ( @@ -248,14 +255,7 @@ export class FlameChart { // Store truncation markers for rendering this.markers.push(...markers); - // Get container dimensions for validation - const { width, height } = container.getBoundingClientRect(); - if (width === 0 || height === 0) { - throw new TimelineError( - TimelineErrorCode.INVALID_CONTAINER, - 'Container must have non-zero dimensions', - ); - } + const { width, height } = await this.awaitContainerSize(container); // Create event index (use precomputed metrics if available) this.index = new TimelineEventIndex( @@ -1027,6 +1027,28 @@ export class FlameChart { return { mainTimelineHeight }; } + /** + * The container's size, waiting for a layout that has not settled. A hidden tab, a + * collapsed panel, or a flex row being re-laid-out around the chart all measure 0 before + * they settle, and none of those is a fault — but a container that never gains a size is, + * so the wait is bounded and still reports it. + */ + private async awaitContainerSize( + container: HTMLElement, + ): Promise<{ width: number; height: number }> { + for (let frame = 0; frame < SIZE_WAIT_FRAMES; frame++) { + const { width, height } = container.getBoundingClientRect(); + if (width > 0 && height > 0) { + return { width, height }; + } + await waitForNextFrame(); + } + throw new TimelineError( + TimelineErrorCode.INVALID_CONTAINER, + 'Container must have non-zero dimensions', + ); + } + private setupCoordinateSystem(): void { if (!this.app) { return; From eb0394a489407816d943e8852e52b715664bfbcc Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Tue, 25 Aug 2026 13:04:03 +0100 Subject: [PATCH 17/17] perf(log-viewer): write the metric-strip hover into rows it already has The panel was rebuilt from an HTML string, so every segment the pointer crossed reparsed it and upgraded a swatch custom element per row. The rows are elements now, held and written into. `selectRows` chooses and orders the readings, and says nothing about the DOM. --- .../MetricStripTooltipRenderer.test.ts | 38 ++++ .../MetricStripTooltipRenderer.ts | 198 ++++++++++++------ 2 files changed, 171 insertions(+), 65 deletions(-) diff --git a/log-viewer/src/features/timeline/optimised/metric-strip/MetricStripTooltipRenderer.test.ts b/log-viewer/src/features/timeline/optimised/metric-strip/MetricStripTooltipRenderer.test.ts index 79d347a9..9ebfa573 100644 --- a/log-viewer/src/features/timeline/optimised/metric-strip/MetricStripTooltipRenderer.test.ts +++ b/log-viewer/src/features/timeline/optimised/metric-strip/MetricStripTooltipRenderer.test.ts @@ -109,6 +109,44 @@ describe('MetricStripTooltipRenderer', () => { expect(panel().style.left).toBe('148px'); }); + /** The row elements, title aside. */ + function rows(): HTMLElement[] { + return [...panel().children].slice(1) as HTMLElement[]; + } + + describe('row elements', () => { + it('writes a new reading into the rows it already has', () => { + renderer.show(100, 0, onePoint, oneMetric, 60); + const [first] = rows(); + + const later: MetricStripDataPoint = { ...onePoint, values: new Map([['cpuTime', 0.9]]) }; + renderer.show(100, 0, later, oneMetric, 60); + + expect(rows()[0]).toBe(first); + expect(first?.textContent).toContain('90.0%'); + }); + + it('hides the spares for a shorter reading rather than discarding them', () => { + const two = [metric('cpuTime', 'CPU Time', 0.9), metric('heapSize', 'Heap Size', 0.5)]; + const twoPoint: MetricStripDataPoint = { + ...onePoint, + values: new Map([ + ['cpuTime', 0.5], + ['heapSize', 0.5], + ]), + }; + + renderer.show(100, 0, twoPoint, two, 60); + expect(rows()).toHaveLength(2); + + renderer.show(100, 0, onePoint, oneMetric, 60); + + // Still two elements, one of them held back for the next longer reading. + expect(rows()).toHaveLength(2); + expect(rows().map((row) => row.style.display)).toEqual(['grid', 'none']); + }); + }); + it('rebuilds when the reading changes', () => { renderer.show(100, 0, onePoint, oneMetric, 60); const first = panel().innerHTML; diff --git a/log-viewer/src/features/timeline/optimised/metric-strip/MetricStripTooltipRenderer.ts b/log-viewer/src/features/timeline/optimised/metric-strip/MetricStripTooltipRenderer.ts index 10f22260..905264d4 100644 --- a/log-viewer/src/features/timeline/optimised/metric-strip/MetricStripTooltipRenderer.ts +++ b/log-viewer/src/features/timeline/optimised/metric-strip/MetricStripTooltipRenderer.ts @@ -69,6 +69,29 @@ export interface MetricStripTooltipOptions { title?: string; } +/** One row's readings, with no DOM attached. */ +interface RowData { + color: string; + name: string; + /** 0-1, which decides both the figure and its colour. */ + percent: number; + value: string; + /** The corrective count, where the log dropped events Salesforce still counted. */ + ghost: string; + /** The "Other" summary reads quieter than the metrics it stands for. */ + muted?: boolean; +} + +/** The elements one row is written into, held so they are never rebuilt. */ +interface RowNodes { + root: HTMLElement; + swatch: HTMLElement; + name: HTMLElement; + percent: HTMLElement; + valueText: Text; + ghost: HTMLElement; +} + export class MetricStripTooltipRenderer extends BaseTooltipRenderer { /** Current color palette. */ private colors: MetricStripColors; @@ -82,6 +105,12 @@ export class MetricStripTooltipRenderer extends BaseTooltipRenderer { /** The reading the panel holds, so sweeping one segment is not a rebuild. */ private shownPoint: MetricStripDataPoint | null = null; + /** Row elements, reused across readings and only ever grown. */ + private readonly rowPool: RowNodes[] = []; + + /** Set once the panel's title exists. */ + private titleNode: HTMLElement | null = null; + constructor(htmlContainer: HTMLElement, options: MetricStripTooltipOptions = {}) { super(htmlContainer, { mode: 'below-anchor', offset: 8, padding: 4 }); @@ -123,15 +152,14 @@ export class MetricStripTooltipRenderer extends BaseTooltipRenderer { // element per row, on a mousemove that is not throttled. `hide` leaves the markup in // place, so the cache stays good across one. if (dataPoint !== this.shownPoint) { - const rows = this.buildTooltipRows(dataPoint, classifiedMetrics); + const rows = this.selectRows(dataPoint, classifiedMetrics); if (rows.length === 0) { this.hide(); return; } - const titleHtml = `
${this.title}
`; - this.setContent(titleHtml + rows.join('')); + this.renderRows(rows); this.shownPoint = dataPoint; } @@ -161,24 +189,21 @@ export class MetricStripTooltipRenderer extends BaseTooltipRenderer { // ============================================================================ /** - * Build tooltip rows using unified filtering rules. + * Choose which metrics the panel shows, and in what order. * * Rules: - * 1. Always show important metrics (cpuTime, heapSize, dmlStatements, dmlRows, soqlQueries, queryRows) - * - But only show zeros for these important metrics - * 2. Show top 3 by percentage (if not already in always-show list) - only if percent > 0 - * 3. Show any metric ≥80% - * 4. Combine remaining metrics with value > 0 into "Other: X metrics" summary - * 5. Sort shown rows by each metric's global peak percentage (stable across the timeline), - * so rows keep a fixed slot rather than reshuffling as the cursor moves + * 1. Always show important metrics (cpuTime, heapSize, dmlStatements, dmlRows, soqlQueries, + * queryRows) — these show even at 0% + * 2. Show any metric >= 80% + * 3. Show the top 3 by percentage, if not already shown and above 0% + * 4. Combine the rest into an "Other (N)" summary + * 5. Order by each metric's global peak percentage, which is stable across the timeline, so + * a row keeps its slot rather than reshuffling as the cursor moves */ - private buildTooltipRows( + private selectRows( dataPoint: MetricStripDataPoint, classifiedMetrics: MetricStripClassifiedMetric[], - ): string[] { - const rows: string[] = []; - - // Build list of all metrics with their current values + ): RowData[] { const allMetrics = classifiedMetrics .map((metric) => ({ metric, @@ -188,12 +213,11 @@ export class MetricStripTooltipRenderer extends BaseTooltipRenderer { })) .sort((a, b) => b.percent - a.percent); - // Determine which metrics to show based on unified rules const shownMetricIds = new Set(); const visibleMetrics: typeof allMetrics = []; const hiddenMetrics: typeof allMetrics = []; - // Pass 1: Add always-show metrics (important metrics shown even at 0%) + // Pass 1: the important metrics, shown even at 0%. for (const item of allMetrics) { if (item.isImportant) { visibleMetrics.push(item); @@ -201,7 +225,7 @@ export class MetricStripTooltipRenderer extends BaseTooltipRenderer { } } - // Pass 2: Add metrics ≥80% (danger threshold) + // Pass 2: anything at the danger threshold. for (const item of allMetrics) { if (!shownMetricIds.has(item.metric.metricId) && item.percent >= DANGER_THRESHOLD) { visibleMetrics.push(item); @@ -209,13 +233,12 @@ export class MetricStripTooltipRenderer extends BaseTooltipRenderer { } } - // Pass 3: Add top 3 by percentage (if not already shown) - only if percent > 0 + // Pass 3: the top 3 by percentage, zeros excluded. let addedFromTop3 = 0; for (const item of allMetrics) { if (addedFromTop3 >= 3) { break; } - // Only add non-zero metrics to top 3 if (!shownMetricIds.has(item.metric.metricId) && item.percent > 0) { visibleMetrics.push(item); shownMetricIds.add(item.metric.metricId); @@ -223,63 +246,108 @@ export class MetricStripTooltipRenderer extends BaseTooltipRenderer { } } - // Collect ALL remaining metrics for "Other" summary (including zeros) for (const item of allMetrics) { if (!shownMetricIds.has(item.metric.metricId)) { hiddenMetrics.push(item); } } - // Sort visible metrics by each metric's global peak percentage (highest first). - // Using the peak (not the value at the cursor) keeps rows in a fixed slot as the cursor - // moves along the timeline, so the tooltip is stable and easy to scan/compare. + // The peak, not the value at the cursor: a row keeps its slot as the cursor moves, so the + // panel can be scanned and compared rather than re-read. visibleMetrics.sort((a, b) => b.metric.globalMaxPercent - a.metric.globalMaxPercent); - // Render visible metric rows - for (const { metric, percent, rawValue } of visibleMetrics) { - const percentStr = (percent * 100).toFixed(1).padStart(5); - const percentColor = getPercentColor(percent); - const lineColor = hexToCSS(metric.color); - // Always show the "out of" value, even at 0% / before the metric's first observation, so the - // limit (headroom) is always visible. Limit is fixed across the series; fall back to the - // classified metric's limit when there's no data point for it at this timestamp. + const rows: RowData[] = visibleMetrics.map(({ metric, percent, rawValue }) => { + // Always state the limit, even at 0% or before the metric's first observation, so the + // headroom is visible. The limit is fixed across the series, so the classified metric + // answers where this timestamp has no data point. const limit = rawValue?.limit ?? metric.limit; - const rawValueStr = - limit > 0 ? formatMetricValueWithParens(rawValue?.used ?? 0, limit, metric.unit) : ''; - // Ghost text: only when the count we tracked from detailed events falls below the - // corrective cumulative total (the log dropped events Salesforce still counted). - const ghost = - rawValue && rawValue.tracked !== undefined && rawValue.tracked < rawValue.used - ? ` (${formatNumber(Math.round(rawValue.tracked))} seen)` - : ''; - - rows.push( - `
` + - `` + - `${metric.displayName}` + - `${percentStr}%` + - `${rawValueStr}${ghost}` + - `
`, - ); - } + return { + color: hexToCSS(metric.color), + name: metric.displayName, + percent, + value: + limit > 0 ? formatMetricValueWithParens(rawValue?.used ?? 0, limit, metric.unit) : '', + // Only where the count tracked from detailed events falls below the corrective + // cumulative total — the log dropped events Salesforce still counted. + ghost: + rawValue && rawValue.tracked !== undefined && rawValue.tracked < rawValue.used + ? ` (${formatNumber(Math.round(rawValue.tracked))} seen)` + : '', + }; + }); - // Add "Other" summary if there are hidden metrics with data if (hiddenMetrics.length > 0) { - const maxHiddenPercent = Math.max(...hiddenMetrics.map((m) => m.percent)); - const otherPercentStr = (maxHiddenPercent * 100).toFixed(1).padStart(5); - const otherPercentColor = getPercentColor(maxHiddenPercent); - const otherLineColor = hexToCSS(this.colors.tier3); - - rows.push( - `
` + - `` + - `Other (${hiddenMetrics.length})` + - `${otherPercentStr}%` + - `` + - `
`, - ); + const maxHiddenPercent = Math.max(...hiddenMetrics.map((item) => item.percent)); + rows.push({ + color: hexToCSS(this.colors.tier3), + name: `Other (${hiddenMetrics.length})`, + percent: maxHiddenPercent, + value: '', + ghost: '', + muted: true, + }); } return rows; } + + /** + * Writes the readings into the row elements, growing the pool as needed and hiding the + * spares. Reused rather than reparsed: a rebuild would upgrade a swatch custom element per + * row, and the panel changes on every segment the pointer crosses. + */ + private renderRows(rows: RowData[]): void { + if (!this.titleNode) { + const title = document.createElement('div'); + title.style.cssText = `font-weight:bold;margin-bottom:6px;color:${TOOLTIP_CSS.foreground};`; + title.textContent = this.title; + this.tooltipElement.appendChild(title); + this.titleNode = title; + } + + rows.forEach((data, index) => { + const pooled = this.rowPool[index]; + const row = pooled ?? this.createRow(); + if (!pooled) { + this.rowPool.push(row); + this.tooltipElement.appendChild(row.root); + } + + row.swatch.setAttribute('color', data.color); + row.name.textContent = data.name; + row.percent.textContent = `${(data.percent * 100).toFixed(1).padStart(5)}%`; + row.percent.style.color = getPercentColor(data.percent); + row.valueText.data = data.value; + row.ghost.textContent = data.ghost; + row.root.style.opacity = data.muted ? '0.7' : ''; + row.root.style.display = 'grid'; + }); + + for (const spare of this.rowPool.slice(rows.length)) { + spare.root.style.display = 'none'; + } + } + + /** One row's elements, in the order the grid lays them out. */ + private createRow(): RowNodes { + const root = document.createElement('div'); + root.style.cssText = ROW_STYLE; + + const swatch = document.createElement('color-swatch'); + const name = document.createElement('span'); + name.style.color = TOOLTIP_CSS.descriptionForeground; + + const percent = document.createElement('span'); + percent.style.cssText = 'text-align:right;font-weight:500;'; + + const value = document.createElement('span'); + value.style.color = TOOLTIP_CSS.descriptionForegroundMuted; + const valueText = document.createTextNode(''); + const ghost = document.createElement('span'); + ghost.style.cssText = 'font-style:italic;opacity:0.65;'; + value.append(valueText, ghost); + + root.append(swatch, name, percent, value); + return { root, swatch, name, percent, valueText, ghost }; + } }