Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
8017efb
refactor(log-viewer): one swatch for every colour key
lukecotter Aug 24, 2026
9062808
Merge remote-tracking branch 'upstream/main' into feat-timeline-legen…
lukecotter Aug 24, 2026
a4180af
refactor(log-viewer): read the timeline legend from the shared palette
lukecotter Aug 24, 2026
ba9780f
refactor(log-viewer): share the frame metric list
lukecotter Aug 24, 2026
2127a4b
fix(log-viewer): key the legacy timeline by the groups it draws
lukecotter Aug 25, 2026
ceb97aa
feat(log-viewer): rebuild the timeline hover card
lukecotter Aug 25, 2026
e144df6
refactor(log-viewer): name the line number as the call site
lukecotter Aug 25, 2026
2919525
refactor(log-viewer): settle the swatch and the shared metric vocabulary
lukecotter Aug 25, 2026
de9234a
fix(log-viewer): keep Pixi's global texture pool across a timeline te…
lukecotter Aug 25, 2026
a85c1f3
fix(log-viewer): wait for the chart's container before drawing into it
lukecotter Aug 25, 2026
478ab76
perf(log-viewer): rebuild the metric-strip hover only when the readin…
lukecotter Aug 25, 2026
af9bc49
docs(log-viewer): reattach the docs the metric move orphaned
lukecotter Aug 25, 2026
39a90a8
fix(log-viewer): follow a previewed timeline theme before settings ar…
lukecotter Aug 25, 2026
66d7ac5
fix(log-viewer): join the legend's categories on a delimiter they sur…
lukecotter Aug 25, 2026
5e70c14
fix(log-viewer): rule the hover card only where it has an identity to…
lukecotter Aug 25, 2026
b55df11
perf(log-viewer): batch the metric-strip hover's placement into a frame
lukecotter Aug 25, 2026
224ea35
refactor(log-viewer): let the chart own the container it cannot draw …
lukecotter Aug 25, 2026
eb0394a
perf(log-viewer): write the metric-strip hover into rows it already has
lukecotter Aug 25, 2026
a3acbd2
Merge remote-tracking branch 'upstream/main'
lukecotter Aug 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions log-viewer/src/components/ColorSwatch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/*
* 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 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 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 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. */
@property()
color = '';

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);
}
`,
];

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.
this.style.background = this.color;
}
}

declare global {
interface HTMLElementTagNameMap {
'color-swatch': ColorSwatch;
}
}
83 changes: 22 additions & 61 deletions log-viewer/src/components/EventVitals.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,26 @@
/*
* 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';
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,
selfLabel,
usageParts,
} from '../core/metrics/eventMetrics.js';
import { DEFAULT_NAMESPACE, getCallerNamespace } from '../core/utility/CallerNamespace.js';
import { formatMs } from '../core/utility/Duration.js';
import { outermostEvents } from '../core/utility/EventTree.js';
import { formatInteger } from '../core/utility/Util.js';
import { sumDurationTotalForRootEvents } from '../features/analysis/services/CallStackSum.js';
import { SOSL_ROWS_PER_QUERY_LIMIT } from '../features/database/limits.js';
import { globalStyles } from '../styles/global.styles.js';

// web components
Expand All @@ -34,39 +35,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,
Expand Down Expand Up @@ -188,7 +156,7 @@ export class EventVitals extends LitElement {
// counts the outermost occurrences only.
const total = sumDurationTotalForRootEvents([events]);
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) {
// Self time never nests, so it and the call count cover the same calls.
this._row(rows, 'Avg self', this._ms(self / events.length));
Expand All @@ -205,7 +173,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`
<code-block language=${this._language()} .code=${this.label || primary.text}></code-block>
Expand Down Expand Up @@ -270,13 +239,13 @@ 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 = sumTotal(metric.pick);
if (!total && metric.label !== alwaysShow) {
continue;
}
const limit = limits ? metric.limit(limits, this.type) : 0;
const self = metric.hasSelf === false ? 0 : sumSelf(metric.pick);
const self = metric.noSelf ? 0 : sumSelf(metric.pick);
const format = metric.bytes ? formatBytes : formatInteger;
this._row(
rows,
Expand All @@ -285,10 +254,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),
);
}
}

Expand Down Expand Up @@ -334,11 +306,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
Expand All @@ -349,21 +316,15 @@ function qualifier(...parts: Array<string | false | null | undefined>): Template
return shown.length ? html` <span class="qualifier">(${shown.join(', ')})</span>` : '';
}

/**
* `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 {
Expand Down
13 changes: 4 additions & 9 deletions log-viewer/src/components/StackedTimeBar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}, in the bar's own unit. */
export interface StackedSegment {
label: string;
Expand Down Expand Up @@ -184,14 +187,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,
.tip__part-value {
font-family: var(--lana-font-mono);
Expand Down Expand Up @@ -379,7 +374,7 @@ export class StackedTimeBar extends LitElement {
@pointerenter=${() => (this._hover = { label: segment.label, onBar: false })}
@pointerleave=${() => (this._hover = null)}
>
<span class="legend__swatch" style=${styleMap({ background: segment.color })}></span>
<color-swatch color=${segment.color}></color-swatch>
<span>${segment.label}</span>
<span class="legend__value">${readout(segment.value, denominator, this.format)}</span>
</span>
Expand Down
41 changes: 41 additions & 0 deletions log-viewer/src/components/__tests__/ColorSwatch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/*
* 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<Pick<ColorSwatch, 'color'>> = {}) {
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('repaints when the colour changes', async () => {
const element = await mount({ color: '#88ae58' });

element.color = '#6d4c7d';
await element.updateComplete;

expect(element.style.background).toBe('rgb(109, 76, 125)');
});

// 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');
});
});
9 changes: 8 additions & 1 deletion log-viewer/src/components/__tests__/EventVitals.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' });
Expand Down
9 changes: 9 additions & 0 deletions log-viewer/src/components/__tests__/categoryTime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
11 changes: 8 additions & 3 deletions log-viewer/src/components/categoryTime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -81,20 +81,25 @@ 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) => {
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) {
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;
Expand Down
4 changes: 2 additions & 2 deletions log-viewer/src/core/events/EventBus.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -19,8 +20,7 @@ export const TAB_TO_SOURCE: Record<string, DetailSource> = {
'database-tab': 'database',
};

/** Which of the database grids a selection came from. */
export type StatementType = 'dml' | 'soql' | 'sosl';
export type { StatementType };

/**
* A selection to inspect in the inspector. A single frame maps to one `eventIndex`;
Expand Down
Loading