diff --git a/CHANGELOG.md b/CHANGELOG.md index 00fa2fb9..5c443468 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - ๐Ÿงญ **Inspector**: select anything โ€” a timeline frame, a call tree or analysis row, a SOQL/DML/SOSL statement โ€” and inspect it without leaving the tab you're on. ([#113]) - **A selection** shows its details and governor metrics as `used / limit`, the call stack that led to it, and its own subtree in **Time Order**, **Aggregated** or **Bottom-Up**. Click a frame in the call stack to walk up it โ€” the details and subtree follow, and the stack stays anchored to what you selected. On the Timeline it also splits the self time under the selection by the namespace whose code ran it. - **Nothing selected** shows the whole log instead of an empty panel: a governor overview on every tab, time by category, self time by namespace and governor trends on the Timeline, log-wide findings and how per-call self time spreads on Analysis, the hot path and hot spots on the Call Tree, and, on Database, which namespaces asked for and burned the database time, every call path that ends in a query, DML or search with total and self time, and how few statements hold the time. ([#373]) - - Every row is a link: click it to reveal the frame, row or statement behind it in the tab you're on. Hover works both ways without moving the view โ€” hover a row to pick out what it names in the tab you're on, or hover there to mark the rows that name it, and what you click stays picked out until `Escape`. Right-click for copy actions. + - Every row is a link: click it to reveal the frame, row or statement behind it in the tab you're on. Hover works both ways without moving the view โ€” hover a row to pick out what it names in the tab you're on, or hover there to mark the rows that name it, and what you click stays picked out until `Escape`. Click a point on a governor usage chart to move the Timeline to that instant and zoom in on it. Right-click for copy actions. - **Findings** list the statements behind them, most repeated first with how often each ran, and report one query built per record and run a row at a time. The severities head the list and filter it, any number at once, a finding the log times shows how long it took and what that is of the log, and selecting an Analysis row narrows the list to the findings that name that method or anything it called. - **Detail | Summary** switches between what you picked and the tab's summary of the whole log, keeping the selection to come back to. - Dock it left, right or bottom, drag to resize any section โ€” double-click a divider to restore the defaults โ€” and collapse the sections you don't need; the layout is remembered. `Escape` clears the selection and returns the whole-log view. ([#63]) diff --git a/lana-docs/docs/docs/features/inspector.md b/lana-docs/docs/docs/features/inspector.md index 64d990f4..c7ea5ac4 100644 --- a/lana-docs/docs/docs/features/inspector.md +++ b/lana-docs/docs/docs/features/inspector.md @@ -39,7 +39,7 @@ Press **Summary** in the panel's header to read the whole log without giving up With nothing selected the inspector reads the whole log. Every tab opens with an **Overview** โ€” the six governor metrics closest to their limit โ€” then adds what its own tab can answer at log scope: -- **Timeline** โ€“ time by category, **self time by namespace**, governor usage over time, and the whole-log call tree. +- **Timeline** โ€“ time by category, **self time by namespace**, governor usage over time, and the whole-log call tree. Click a point on a usage chart, or step the arrow keys across a focused chart and press `Enter`, to move the Timeline to that instant and zoom in on it. Nothing is selected, so the inspector keeps this whole-log reading. - **Call Tree** โ€“ the **hot path** the log spent its time in, and the **hot spots** with the most self time. - **Database** โ€“ **Namespace duration**: **Called from namespace** โ€” the namespace that issued the statement โ€” and, when they differ, **Ran in namespace**, the namespaces of whatever ran beneath it, such as a package trigger firing on your DML. **Call tree**: every call path that ends in a query, DML or search, with **Total Time** โ€” the database time at or below the row โ€” beside **Self Time**, the row's own code. A row with all total and no self is waiting on the database; the reverse is the Apex around it. **Database duration**: how few statements hold the time, with cost per row, how often each ran, and its duration split into self time and descendants, so a DML that is cheap in itself but fires seven seconds of triggers reads as one. - **Analysis** โ€“ **Findings**: what is slow or wrong in the log, and what to do about it, led by the findings by severity โ€” press any number of them to hold the list to those. A finding whose events the log times also shows how long they took and what that is of the log. Each finding lists the statements behind it, most repeated first; click one to reveal its row in the grid. diff --git a/log-viewer/src/components/GovernorTrends.ts b/log-viewer/src/components/GovernorTrends.ts index 2d7d8415..53bdd72e 100644 --- a/log-viewer/src/components/GovernorTrends.ts +++ b/log-viewer/src/components/GovernorTrends.ts @@ -5,6 +5,7 @@ import { consume } from '@lit/context'; import { LitElement, css, html, svg } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; +import { eventBus } from '../core/events/EventBus.js'; import { logContext } from '../core/log/logContext.js'; import type { LogStore } from '../core/log/LogStore.js'; import { formatDuration } from '../core/utility/Util.js'; @@ -13,6 +14,7 @@ import { governorTier, } from '../features/database/components/GovernorSummary.js'; import { apexLimitTimeSeries } from '../features/timeline/optimised/apex-limit-series.js'; +import { SEEK_LOG_SHARE } from '../features/timeline/utils/navigate-window.js'; import { globalStyles } from '../styles/global.styles.js'; import { inspectorSectionStyles } from '../styles/inspectorSection.styles.js'; import { @@ -27,6 +29,11 @@ import { NO_CUMULATIVE_LIMITS_TEXT } from './logOverviewMetrics.js'; const VIEW_W = 100; const VIEW_H = 30; +/** How far one arrow key moves the cursor: the seek window's own share of the + * log, so successive steps sweep the log without leaving a gap between the + * windows they can reach. */ +const KEY_STEP = SEEK_LOG_SHARE; + /** The pieces of a chart that depend only on the data, never the hover. */ interface TrendGeometry { line: string; @@ -74,12 +81,18 @@ function trendGeometry(series: TrendSeries, logTotal: number): TrendGeometry { * metrics closest to their limits. Every chart shares the same x-domain (the * whole log) so shapes are comparable; each y-domain runs to at least 100% so * a flat safe line reads as safe. Colours follow the gauges' tiers. + * + * A click, or Enter on a focused chart, moves the timeline to that instant and + * zooms in on it, so a spike leads straight to its cause. The chart selects + * nothing, so the inspector keeps its whole-log reading. */ @customElement('governor-trends') export class GovernorTrends extends LitElement { - /** The sample under the pointer, on the one hovered chart. */ + /** The sample under the pointer or the arrow keys, on the one chart that holds + * it. `from` says which placed it: a pointer leaving takes its own cursor + * with it, never one the keys placed. */ @state() - private _hover: { label: string; point: TrendPoint } | null = null; + private _cursor: { label: string; point: TrendPoint; from: 'pointer' | 'key' } | null = null; /** The log on screen, from the app root. */ @consume({ context: logContext, subscribe: true }) @@ -133,11 +146,28 @@ export class GovernorTrends extends LitElement { color: var(--lana-fg-muted); } + /* A button, not the svg itself: Chromium matches :focus-visible on a + click for a focusable svg, so the ring appeared on every seek. */ .trend__chart { display: block; width: 100%; - height: 44px; + border: 0; border-bottom: 1px solid var(--lana-surface-border); + padding: 0; + background: none; + color: inherit; + cursor: pointer; + } + + .trend__plot { + display: block; + width: 100%; + height: 44px; + } + + .trend__chart:focus-visible { + outline: var(--lana-stroke) solid var(--lana-focus-border); + outline-offset: calc(-1 * var(--lana-stroke)); } .trend--safe { @@ -172,7 +202,7 @@ export class GovernorTrends extends LitElement { /* A vertical line, not a circle โ€” preserveAspectRatio="none" would distort any shape with area. */ - .trend__hover { + .trend__cursor { stroke: currentColor; stroke-width: 1; vector-effect: non-scaling-stroke; @@ -198,47 +228,119 @@ export class GovernorTrends extends LitElement { private _renderTrend(series: TrendSeries, logTotal: number) { const { line, area, guideY, x } = trendGeometry(series, logTotal); - const hovered = this._hover?.label === series.label ? this._hover.point : null; - const hoverX = hovered ? x(hovered.t).toFixed(2) : null; + const cursor = this._cursorFor(series); + const cursorX = cursor ? x(cursor.t).toFixed(2) : null; return html`
${series.label} - ${hovered ? html`${formatDuration(hovered.t)} ยท ` : ''}${series.format( - hovered ? hovered.used : series.used, + ${cursor ? html`${formatDuration(cursor.t)} ยท ` : ''}${series.format( + cursor ? cursor.used : series.used, )} / ${series.format(series.limit)}
- this._onPointerMove(event, series, logTotal)} - @pointerleave=${() => (this._hover = null)} + @pointerleave=${() => this._onPointerLeave()} + @click=${(event: PointerEvent) => this._onClick(event, series, logTotal)} + @keydown=${(event: KeyboardEvent) => this._onKeyDown(event, series, logTotal)} > - ${svg` - - - - ${hoverX === null ? '' : svg``} - `} - + +
`; } - private _onPointerMove(event: PointerEvent, series: TrendSeries, logTotal: number) { - // currentTarget is the the handler is bound to; event.target could be - // one of its paths, whose offsetX is useless here. - const rect = (event.currentTarget as SVGSVGElement).getBoundingClientRect(); - if (rect.width <= 0 || logTotal <= 0) { + private _onPointerMove(event: PointerEvent, series: TrendSeries, logTotal: number): void { + const point = this._pointFrom(event, series, logTotal); + this._cursor = point ? { label: series.label, point, from: 'pointer' } : null; + } + + private _onPointerLeave(): void { + if (this._cursor?.from === 'pointer') { + this._cursor = null; + } + } + + private _onClick(event: PointerEvent, series: TrendSeries, logTotal: number): void { + // Where the pointer is wins: the cursor may sit where the arrow keys left it. + const point = this._pointFrom(event, series, logTotal) ?? this._cursorFor(series); + if (point) { + this._seek(point.t); + } + } + + /** + * Arrows step the cursor across the log; Enter and Space move the timeline to + * it. With no cursor the last sample answers: consumption never falls inside + * a transaction, so that is where the metric stands highest. + */ + private _onKeyDown(event: KeyboardEvent, series: TrendSeries, logTotal: number): void { + const step = + event.key === 'ArrowRight' ? KEY_STEP : event.key === 'ArrowLeft' ? -KEY_STEP : undefined; + if (step !== undefined) { + const from = this._cursorFor(series)?.t ?? 0; + const t = Math.min(Math.max(from + step * logTotal, 0), logTotal); + const point = pointAt(series.points, t); + if (point) { + this._cursor = { label: series.label, point, from: 'key' }; + } + event.preventDefault(); return; } - const t = ((event.clientX - rect.left) / rect.width) * logTotal; - const point = pointAt(series.points, t); - this._hover = point ? { label: series.label, point } : null; + if (event.key === 'Enter' || event.key === ' ') { + const point = this._cursorFor(series) ?? series.points.at(-1); + if (point) { + this._seek(point.t); + } + event.preventDefault(); + } + } + + /** The cursor, when this chart is the one holding it. */ + private _cursorFor(series: TrendSeries): TrendPoint | null { + return this._cursor?.label === series.label ? this._cursor.point : null; + } + + /** The series' value at the pointer, in the log's own time. */ + private _pointFrom( + event: PointerEvent, + series: TrendSeries, + logTotal: number, + ): TrendPoint | null { + // currentTarget is the button the handler is bound to; event.target could be + // one of the plot's paths, whose offsetX is useless here. + const rect = (event.currentTarget as HTMLButtonElement).getBoundingClientRect(); + if (rect.width <= 0 || logTotal <= 0) { + return null; + } + return pointAt(series.points, ((event.clientX - rect.left) / rect.width) * logTotal); + } + + /** + * Move the timeline to `t` and zoom to a window of the log around it. Nothing + * is selected: these charts read the whole log, and a selection would swap the + * inspector to one frame's detail. + */ + private _seek(t: number): void { + eventBus.emit('timeline:navigate-to', { timestamp: t, mode: 'seek' }); } } diff --git a/log-viewer/src/components/__tests__/GovernorTrends.test.ts b/log-viewer/src/components/__tests__/GovernorTrends.test.ts new file mode 100644 index 00000000..23507584 --- /dev/null +++ b/log-viewer/src/components/__tests__/GovernorTrends.test.ts @@ -0,0 +1,140 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + * + * @jest-environment jsdom + */ +import { beforeEach, describe, expect, it } from '@jest/globals'; +import type { LitElement } from 'lit'; + +import { eventBus } from '../../core/events/EventBus.js'; +import type { LogStore } from '../../core/log/LogStore.js'; +import type { TrendSeries } from '../governorTrendData.js'; + +// The charts are driven from one stub series, so the seek is the only logic +// under test. `pointAt` stays real: the click reads the series through it. +let series: TrendSeries[]; +jest.mock('../governorTrendData.js', () => ({ + ...jest.requireActual('../governorTrendData.js'), + governorTrendSeries: () => series, +})); +jest.mock('../../features/timeline/optimised/apex-limit-series.js', () => ({ + apexLimitTimeSeries: () => ({ events: [] }), +})); + +import '../GovernorTrends.js'; + +const LOG_NS = 1_000; + +const trend = (): TrendSeries => ({ + label: 'SOQL queries', + points: [ + { t: 0, ratio: 0, used: 0 }, + { t: 400, ratio: 40, used: 40 }, + { t: 800, ratio: 90, used: 90 }, + ], + used: 90, + limit: 100, + finalRatio: 90, + format: String, +}); + +async function mount(): Promise { + const element = document.createElement('governor-trends'); + // No provider in the test, so the consumed store is assigned straight on. + (element as unknown as { logStore: LogStore }).logStore = { + log: { duration: { total: LOG_NS } }, + } as unknown as LogStore; + document.body.append(element); + await element.updateComplete; + return element; +} + +/** The chart, given a width so a pointer x maps to a time. */ +function chartOf(element: LitElement): HTMLButtonElement { + const chart = element.shadowRoot!.querySelector('.trend__chart') as HTMLButtonElement; + chart.getBoundingClientRect = () => ({ left: 0, width: 100, top: 0, height: 44 }) as DOMRect; + return chart; +} + +let seeks: { timestamp?: number; mode?: string }[]; +let unsubscribe: () => void; + +beforeEach(() => { + document.body.replaceChildren(); + series = [trend()]; + seeks = []; + unsubscribe?.(); + unsubscribe = eventBus.on('timeline:navigate-to', (detail) => { + seeks.push({ timestamp: detail.timestamp, mode: detail.mode }); + }); +}); + +describe('governor-trends', () => { + it('moves the timeline to the instant clicked on a chart', async () => { + const element = await mount(); + + chartOf(element).dispatchEvent(new MouseEvent('click', { clientX: 60 })); + + expect(seeks).toEqual([{ timestamp: 600, mode: 'seek' }]); + }); + + it('reads the sample under the pointer without moving the timeline', async () => { + const element = await mount(); + const chart = chartOf(element); + + chart.dispatchEvent(new MouseEvent('pointermove', { clientX: 40 })); + await element.updateComplete; + + expect(element.shadowRoot?.querySelector('.trend__value')?.textContent).toContain('40'); + expect(element.shadowRoot?.querySelector('.trend__cursor')).not.toBeNull(); + expect(seeks).toEqual([]); + }); + + it('steps the cursor with the arrow keys and seeks it with Enter', async () => { + const element = await mount(); + const chart = chartOf(element); + + chart.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight' })); + chart.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight' })); + chart.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })); + + // Two steps of a seek window (2%) across a 1,000ns log. + expect(seeks).toEqual([{ timestamp: 40, mode: 'seek' }]); + }); + + it('keeps an arrow-key cursor when the pointer leaves the chart', async () => { + const element = await mount(); + const chart = chartOf(element); + + chart.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight' })); + chart.dispatchEvent(new MouseEvent('pointerleave')); + chart.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })); + + expect(seeks).toEqual([{ timestamp: 20, mode: 'seek' }]); + }); + + it('holds the cursor inside the log at either end', async () => { + const element = await mount(); + const chart = chartOf(element); + + chart.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft' })); + chart.dispatchEvent(new KeyboardEvent('keydown', { key: ' ' })); + + expect(seeks).toEqual([{ timestamp: 0, mode: 'seek' }]); + }); + + it('seeks the last sample when no cursor has been placed', async () => { + const element = await mount(); + + chartOf(element).dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })); + + expect(seeks).toEqual([{ timestamp: 800, mode: 'seek' }]); + }); + + // A button, so the focus ring only shows for keyboard focus, never a click. + it('gives every chart keyboard reach', async () => { + const element = await mount(); + + expect(chartOf(element).tagName).toBe('BUTTON'); + }); +}); diff --git a/log-viewer/src/core/events/EventBus.ts b/log-viewer/src/core/events/EventBus.ts index a644041a..ee486b76 100644 --- a/log-viewer/src/core/events/EventBus.ts +++ b/log-viewer/src/core/events/EventBus.ts @@ -31,11 +31,18 @@ export type DetailSelection = | { kind: 'event'; eventIndex: number; type?: StatementType } | { kind: 'aggregate'; instances: number[]; label: string }; +export type TimelineNavigateMode = 'reveal' | 'seek'; + interface EventMap { // Supply eventIndex (preferred โ€” unique) OR timestamp (fallback for raw-log entry where eventIndex isn't known). + // 'reveal' (the default) selects the frame and zooms to it. 'seek' comes from a + // whole-log reading: it zooms to a window of the log around the instant and + // selects nothing, so the inspector keeps that reading. Only an instant can be + // sought, so only the timestamp form carries the mode. 'timeline:navigate-to': - { eventIndex: number; timestamp?: never } | { eventIndex?: never; timestamp: number }; + | { eventIndex: number; timestamp?: never; mode?: never } + | { eventIndex?: never; timestamp: number; mode?: TimelineNavigateMode }; // A tab's current selection changed โ€” the inspector rebuilds its // content for this source. `selection: null` clears that source's selection. diff --git a/log-viewer/src/features/timeline/__tests__/navigate-window.test.ts b/log-viewer/src/features/timeline/__tests__/navigate-window.test.ts new file mode 100644 index 00000000..f34f24d6 --- /dev/null +++ b/log-viewer/src/features/timeline/__tests__/navigate-window.test.ts @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ +import { describe, expect, it } from '@jest/globals'; + +import { seekWindow } from '../utils/navigate-window.js'; + +const MS = 1_000_000; + +describe('seekWindow', () => { + it('takes its width from the log', () => { + const { start, width } = seekWindow(6_000 * MS, 24_000 * MS); + + // 2% of a 24s log, centred on the instant. + expect(width).toBe(480 * MS); + expect(start + width / 2).toBe(6_000 * MS); + }); + + it('holds a short log at the smallest window', () => { + expect(seekWindow(500 * MS, 1_000 * MS).width).toBe(100 * MS); + }); + + it('keeps the window inside the log at the start', () => { + expect(seekWindow(0, 24_000 * MS).start).toBe(0); + }); +}); diff --git a/log-viewer/src/features/timeline/optimised/ApexLogTimeline.ts b/log-viewer/src/features/timeline/optimised/ApexLogTimeline.ts index ea1cac1c..1001ecc2 100644 --- a/log-viewer/src/features/timeline/optimised/ApexLogTimeline.ts +++ b/log-viewer/src/features/timeline/optimised/ApexLogTimeline.ts @@ -20,7 +20,7 @@ import type { ApexLog, LogEvent } from 'apex-log-parser'; import { ContextMenu } from '../../../components/ContextMenu.js'; import { ContextMenuBuilder } from '../../../components/ContextMenuBuilder.js'; -import { eventBus } from '../../../core/events/EventBus.js'; +import { eventBus, type TimelineNavigateMode } from '../../../core/events/EventBus.js'; import { SelectionEchoGuard } from '../../../core/events/SelectionEchoGuard.js'; import { copyToClipboard } from '../../../core/utility/Clipboard.js'; import { vscodeMessenger } from '../../../core/messaging/VSCodeExtensionMessenger.js'; @@ -43,6 +43,7 @@ import type { SearchCursor } from '../types/search.types.js'; import { InspectorEmphasis } from '../../../components/inspectorEmphasis.js'; import { isFrameOffscreen, toDetailSelection } from '../utils/detail-selection-sync.js'; import { extractExceptionMarkers, extractMarkers } from '../utils/marker-utils.js'; +import { seekWindow } from '../utils/navigate-window.js'; import { logEventToTreeAndRects } from '../utils/tree-converter.js'; import { FlameChart } from './FlameChart.js'; import { FrameTooltipRenderer, type TooltipAnchor } from './FrameTooltipRenderer.js'; @@ -207,7 +208,7 @@ export class ApexLogTimeline { if (detail.eventIndex !== undefined) { this.navigateToEventIndex(detail.eventIndex); } else { - this.navigateToTimestamp(detail.timestamp); + this.navigateToTimestamp(detail.timestamp, detail.mode); } }); @@ -308,33 +309,41 @@ export class ApexLogTimeline { return; } - const result = findEventByEventIndex(this.apexLog, eventIndex); - this._navigateToSearchResult(result); + this._reveal(findEventByEventIndex(this.apexLog, eventIndex)); } /** * Navigate to a specific timestamp in the timeline. * Called via EventBus 'timeline:navigate-to' event from CalltreeView, * or directly from TimelineFlameChart after initialization. - * Centers the viewport AND selects the event for visual highlighting. + * 'reveal' selects the frame at the timestamp and zooms to it; 'seek' zooms to + * a window of the log around the instant and selects nothing. */ - public navigateToTimestamp(timestamp: number): void { + public navigateToTimestamp(timestamp: number, mode: TimelineNavigateMode = 'reveal'): void { if (!this.events) { return; } // Find event by timestamp (binary search - events sorted by time) const result = findEventByTimestamp(this.events, timestamp); - this._navigateToSearchResult(result); + if (mode === 'reveal') { + this._reveal(result); + return; + } + // The frame only gives the depth to centre on; the window is the log's, and + // padding 0 keeps the width asked for. + const { start, width } = seekWindow(timestamp, this.apexLog?.duration.total ?? 0); + this.flamechart.getViewportManager()?.focusOnEvent(start, width, result?.depth ?? 0, 0); + this.flamechart.requestRender(); } - private _navigateToSearchResult(result: { event: LogEvent; depth: number } | null): void { + private _reveal(result: { event: LogEvent; depth: number } | null): void { if (!result) { return; } this.flamechart.selectByEventNode(this.toEventNode(result)); - const viewport = this.flamechart.getViewportManager(); - viewport?.focusOnEvent(result.event.timestamp, result.event.duration.total, result.depth); + const { timestamp, duration } = result.event; + this.flamechart.getViewportManager()?.focusOnEvent(timestamp, duration.total, result.depth); this.flamechart.requestRender(); } diff --git a/log-viewer/src/features/timeline/utils/navigate-window.ts b/log-viewer/src/features/timeline/utils/navigate-window.ts new file mode 100644 index 00000000..f70a0477 --- /dev/null +++ b/log-viewer/src/features/timeline/utils/navigate-window.ts @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ + +/** A seek shows this share of the log. Exported so a control that steps through + * the log can move by one window at a time and leave no gap. */ +export const SEEK_LOG_SHARE = 0.02; +/** 100ms in nanoseconds: the smallest window a seek zooms to. */ +const SEEK_MIN_WINDOW = 100_000_000; + +/** + * The time window the viewport zooms to for a seek: an instant at the centre of + * a share of the log. The width comes from the log, never from the frame the + * instant happens to land in. + */ +export function seekWindow(at: number, logTotal: number): { start: number; width: number } { + const width = Math.max(logTotal * SEEK_LOG_SHARE, SEEK_MIN_WINDOW); + return { start: Math.max(at - width / 2, 0), width }; +}