From 17ddf518c92db1ac430c7703b7f99a8b47023a17 Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Thu, 16 Jul 2026 20:22:29 +0100 Subject: [PATCH 1/6] feat(log-viewer): add configurable column views across grids MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New shared ColumnViews engine: preset views (General/Time/Governor Limits/Database/Memory), header-menu show/hide, and Reset — persisted per view via new lana.callTree/lana.database settings and an updateConfig message handler - Gov. Cost reworked to an average across all reported governors plus a Peak Limit (max) column; parser gains a per-node heapAllocated field + rollup to back the Heap columns - New call-tree columns (SOSL counts/rows, Heap + Self, Avg Self Time, self variants) rendered as % bars; SOSL plumbed through the aggregated/time-order/bottom-up row builds - Database SOQL/DML gain column views + a SOQL Query Plan view and % row-count bars; frozen Name column enabled via FrozenColumnsModule + full-width virtual renderer --- CHANGELOG.md | 4 + .../__tests__/ApexLogParser.test.ts | 26 ++ apex-log-parser/src/ApexLogParser.ts | 1 + apex-log-parser/src/LogEvents.ts | 20 ++ lana/package.json | 84 +++++++ lana/src/commands/LogView.ts | 10 +- lana/src/workspace/AppConfig.ts | 6 + .../analysis/components/AnalysisView.ts | 129 +++++++++- .../call-tree/components/AggregatedTable.ts | 194 ++++++++------- .../call-tree/components/BottomUpTable.ts | 114 ++++++++- .../call-tree/components/CalltreeView.ts | 152 ++++++++++++ .../call-tree/components/TableShared.ts | 172 ++++++++++++- .../call-tree/components/TimeOrderTable.ts | 175 ++++++-------- .../features/call-tree/utils/Aggregation.ts | 71 ++++++ .../features/call-tree/utils/GovernorCost.ts | 144 +++++++++++ .../features/call-tree/utils/TimeOrderTree.ts | 12 + .../utils/__tests__/Aggregation.test.ts | 63 ++++- .../utils/__tests__/GovernorCost.test.ts | 134 ++++++++++ .../features/database/components/DMLView.ts | 140 ++++++++++- .../features/database/components/SOQLView.ts | 202 ++++++++++++++++ log-viewer/src/features/settings/Settings.ts | 11 + log-viewer/src/tabulator/ColumnViews.ts | 221 +++++++++++++++++ .../tabulator/__tests__/ColumnViews.test.ts | 228 ++++++++++++++++++ .../src/tabulator/module/CommonModules.ts | 1 + .../renderer/VirtualVerticalRenderer.ts | 25 ++ .../__tests__/VirtualVerticalRenderer.test.ts | 8 +- .../VirtualVerticalRendererAttach.test.ts | 2 +- 27 files changed, 2139 insertions(+), 210 deletions(-) create mode 100644 log-viewer/src/features/call-tree/utils/GovernorCost.ts create mode 100644 log-viewer/src/features/call-tree/utils/__tests__/GovernorCost.test.ts create mode 100644 log-viewer/src/tabulator/ColumnViews.ts create mode 100644 log-viewer/src/tabulator/__tests__/ColumnViews.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ae80d04a..5b474b3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - 🔴 **Timeline exception markers**: exceptions show as red lines, with a **Throws** count in method tooltips. ([#828]) +- 🧩 **Column views**: switch the Call Tree, Analysis and Database (SOQL/DML) tables between preset column sets (`General`, `Time`, `Governor Limits`, `Database`, `Memory`), show/hide individual columns from the header right-click menu, and **Reset** a view to its defaults. Choices persist per view. ([#298]) +- 📊 New Call Tree columns: **SOSL Count/Rows**, **Heap Allocated** (+ Self), **Avg Self Time**, and optional **Self** variants for every governor metric — surfaced through the `Governor Limits`, `Database` and `Memory` views. ([#298]) +- 🔎 **Database tables**: Row Count renders as a **% bar** against the governor limit, and SOQL gains a **Query Plan** view (Relative Cost, Leading Operation, SObject Type, Cardinality). ([#298]) ### Changed @@ -505,6 +508,7 @@ Skipped due to adopting odd numbering for pre releases and even number for relea [#832]: https://github.com/certinia/debug-log-analyzer/issues/832 [#848]: https://github.com/certinia/debug-log-analyzer/issues/848 [#827]: https://github.com/certinia/debug-log-analyzer/issues/827 +[#298]: https://github.com/certinia/debug-log-analyzer/issues/298 diff --git a/apex-log-parser/__tests__/ApexLogParser.test.ts b/apex-log-parser/__tests__/ApexLogParser.test.ts index 940b3409..3f64ec12 100644 --- a/apex-log-parser/__tests__/ApexLogParser.test.ts +++ b/apex-log-parser/__tests__/ApexLogParser.test.ts @@ -381,6 +381,32 @@ describe('parseLog tests', () => { expect(thrown?.thrownCount).toEqual({ self: 1, total: 1 }); }); + it('heapAllocated is seeded on the allocation leaf and rolled up as total on ancestors', async () => { + const log = + '09:18:22.6 (100)|EXECUTION_STARTED\n\n' + + '15:20:52.222 (200)|METHOD_ENTRY|[185]|01p4J00000FpS6t|Outer.run()\n' + + '15:20:52.222 (300)|HEAP_ALLOCATE|[52]|Bytes:10\n' + + '15:20:52.222 (400)|METHOD_ENTRY|[190]|01p4J00000FpS6u|Inner.work()\n' + + '15:20:52.222 (500)|HEAP_ALLOCATE|[60]|Bytes:32\n' + + '15:20:52.222 (600)|METHOD_EXIT|[190]|01p4J00000FpS6u|Inner.work()\n' + + '15:20:52.222 (700)|METHOD_EXIT|[185]|01p4J00000FpS6t|Outer.run()\n' + + '09:19:13.82 (2000)|EXECUTION_FINISHED\n'; + + const apexLog = parse(log); + + // children[0] is the EXECUTION_STARTED wrapper; Outer.run() is its child. + const outer = apexLog.children[0]!.children[0]!; + const inner = outer.children.find((child) => child.type === 'METHOD_ENTRY')!; + + // The allocation leaf carries self === total === bytes. + const outerAlloc = outer.children.find((child) => child.type === 'HEAP_ALLOCATE'); + expect(outerAlloc?.heapAllocated).toEqual({ self: 10, total: 10 }); + + // Inner rolls up its own 32 bytes; Outer rolls up both (10 + 32). + expect(inner.heapAllocated).toEqual({ self: 0, total: 32 }); + expect(outer.heapAllocated).toEqual({ self: 0, total: 42 }); + }); + it('Methods should have line-numbers', async () => { const log = '09:18:22.6 (0)|EXECUTION_STARTED\n\n' + diff --git a/apex-log-parser/src/ApexLogParser.ts b/apex-log-parser/src/ApexLogParser.ts index a726ec43..9507f88c 100644 --- a/apex-log-parser/src/ApexLogParser.ts +++ b/apex-log-parser/src/ApexLogParser.ts @@ -452,6 +452,7 @@ export class ApexLogParser { parent.soslRowCount.total += child.soslRowCount.total; parent.duration.self -= child.duration.total; parent.thrownCount.total += child.thrownCount.total; + parent.heapAllocated.total += child.heapAllocated.total; } } } diff --git a/apex-log-parser/src/LogEvents.ts b/apex-log-parser/src/LogEvents.ts index 5cceffd5..adf7ea2b 100644 --- a/apex-log-parser/src/LogEvents.ts +++ b/apex-log-parser/src/LogEvents.ts @@ -246,6 +246,24 @@ export abstract class LogEvent { total: 0, }; + /** + * Total + self heap bytes allocated (HEAP_ALLOCATE / BULK_HEAP_ALLOCATE). + * + * `self` is seeded on the allocation leaf node (like DML/SOQL counts), so a method's + * `heapAllocated.self` is typically 0 and `heapAllocated.total` is the sum of allocations + * in this node and its children. + */ + heapAllocated: SelfTotal = { + /** + * The net bytes allocated directly by this node. + */ + self: 0, + /** + * The total bytes allocated in this node and child nodes + */ + total: 0, + }; + /** * The line types which would legitimately end this method */ @@ -515,6 +533,7 @@ export class BulkHeapAllocateLine extends LogEvent { super(parser, parts); this.text = parts[2] || ''; this.bytes = parseBytes(parts[2]); + this.heapAllocated.self = this.heapAllocated.total = this.bytes; } } @@ -1103,6 +1122,7 @@ export class HeapAllocateLine extends LogEvent { this.lineNumber = this.parseLineNumber(parts[2]); this.text = parts[3] || ''; this.bytes = parseBytes(parts[3]); + this.heapAllocated.self = this.heapAllocated.total = this.bytes; } } diff --git a/lana/package.json b/lana/package.json index 2fd28a58..99a5444e 100644 --- a/lana/package.json +++ b/lana/package.json @@ -138,6 +138,33 @@ "default": false, "markdownDescription": "Tint the Call Tree Name column by event category, matching the Timeline colors, instead of showing a small color chip. Default: `false`", "order": 0 + }, + "lana.callTree.columnView": { + "title": "Column view", + "type": "string", + "default": "General", + "enum": [ + "General", + "Time", + "Governor Limits", + "Database", + "Memory" + ], + "markdownDescription": "The column view applied to the Call Tree and Analysis tables. Default: `General`", + "order": 1 + }, + "lana.callTree.columnOverrides": { + "title": "Column view overrides", + "type": "object", + "default": {}, + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + }, + "markdownDescription": "Per-view column customisations for the Call Tree and Analysis tables (view id → visible column fields). Set automatically when you show/hide columns from a table's header menu; reset a view from that menu to clear its entry.", + "order": 2 } } }, @@ -326,6 +353,63 @@ } } } + }, + { + "type": "object", + "id": "lana", + "title": "Database", + "order": 2, + "properties": { + "lana.database.soql.columnView": { + "title": "SOQL column view", + "type": "string", + "default": "General", + "enum": [ + "General", + "Performance", + "Query Plan" + ], + "markdownDescription": "The column view applied to the Database SOQL table. Default: `General`", + "order": 0 + }, + "lana.database.soql.columnOverrides": { + "title": "SOQL column view overrides", + "type": "object", + "default": {}, + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + }, + "markdownDescription": "Per-view column customisations for the Database SOQL table (view id → visible column fields). Set automatically when you show/hide columns from the table's header menu.", + "order": 1 + }, + "lana.database.dml.columnView": { + "title": "DML column view", + "type": "string", + "default": "General", + "enum": [ + "General", + "Timing" + ], + "markdownDescription": "The column view applied to the Database DML table. Default: `General`", + "order": 2 + }, + "lana.database.dml.columnOverrides": { + "title": "DML column view overrides", + "type": "object", + "default": {}, + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + }, + "markdownDescription": "Per-view column customisations for the Database DML table (view id → visible column fields). Set automatically when you show/hide columns from the table's header menu.", + "order": 3 + } + } } ] }, diff --git a/lana/src/commands/LogView.ts b/lana/src/commands/LogView.ts index f926ba98..44674767 100644 --- a/lana/src/commands/LogView.ts +++ b/lana/src/commands/LogView.ts @@ -11,7 +11,7 @@ import type { Context } from '../Context.js'; import { OpenFileInPackage } from '../display/OpenFileInPackage.js'; import { WebView } from '../display/WebView.js'; import { RawLogNavigation } from '../log-features/RawLogNavigation.js'; -import { getConfig } from '../workspace/AppConfig.js'; +import { getConfig, updateConfig } from '../workspace/AppConfig.js'; interface WebViewLogFileRequest { requestId: string; @@ -110,6 +110,14 @@ export class LogView { break; } + case 'updateConfig': { + const { section, value } = payload as { section: string; value: unknown }; + if (section) { + updateConfig(section, value); + } + break; + } + case 'saveFile': { const { fileContent, options } = payload as { fileContent: string; diff --git a/lana/src/workspace/AppConfig.ts b/lana/src/workspace/AppConfig.ts index 60b5c6d6..55eda661 100644 --- a/lana/src/workspace/AppConfig.ts +++ b/lana/src/workspace/AppConfig.ts @@ -35,6 +35,12 @@ interface Config { }; callTree: { categoryColorize: boolean; + columnView: string; + columnOverrides: Record; + }; + database: { + soql: { columnView: string; columnOverrides: Record }; + dml: { columnView: string; columnOverrides: Record }; }; } diff --git a/log-viewer/src/features/analysis/components/AnalysisView.ts b/log-viewer/src/features/analysis/components/AnalysisView.ts index de8868ab..349d121d 100644 --- a/log-viewer/src/features/analysis/components/AnalysisView.ts +++ b/log-viewer/src/features/analysis/components/AnalysisView.ts @@ -7,12 +7,25 @@ import '#vscode-elements/vscode-option.js'; import '../../../components/VsSelect.js'; import '#vscode-elements/vscode-toolbar-button.js'; import { LitElement, css, html, unsafeCSS, type PropertyValues } from 'lit'; -import { customElement, property } from 'lit/decorators.js'; +import { customElement, property, state } from 'lit/decorators.js'; +import { repeat } from 'lit/directives/repeat.js'; import type { RowComponent, Tabulator } from 'tabulator-tables'; import type { ApexLog } from 'apex-log-parser'; +import '#vscode-elements/vscode-option.js'; +import '../../../components/ContextMenu.js'; +import type { ContextMenu } from '../../../components/ContextMenu.js'; import { isVisible } from '../../../core/utility/Util.js'; +import { getSettings, updateSetting } from '../../settings/Settings.js'; import { createBottomUpTable } from '../../call-tree/components/BottomUpTable.js'; +import { + applyColumnView, + buildColumnMenuItems, + CALL_TREE_VIEWS, + getColumnView, + getTableFields, + toggleField, +} from '../../../tabulator/ColumnViews.js'; import type { BottomUpRow } from '../../call-tree/utils/Aggregation.js'; import { categoryColoringStyles, @@ -31,6 +44,9 @@ import { soqlSyntaxStyles } from '../../soql/styles/soql-syntax.css.js'; import '../../../components/GridSkeleton.js'; import '../../../components/datagrid-filter-bar.js'; +/** The Name column is always shown in the analysis table. */ +const ALWAYS_VISIBLE = ['text']; + @customElement('analysis-view') export class AnalysisView extends LitElement { static styles = [ @@ -84,6 +100,14 @@ export class AnalysisView extends LitElement { timelineRoot: ApexLog | null = null; analysisTable: Tabulator | null = null; + + @state() + columnView = 'General'; + + /** Per-view column overrides (view id → visible fields); empty until edited. */ + @state() + private columnOverrides: Record = {}; + private contextMenu: ContextMenu | null = null; tableContainer: HTMLDivElement | null = null; findMap: { [key: number]: RowComponent } = {}; findArgs: { text: string; count: number; options: { matchCase: boolean } } = { @@ -120,6 +144,17 @@ export class AnalysisView extends LitElement { document.removeEventListener('lv-find-close', this._findEvt); } + firstUpdated(): void { + this.contextMenu = this.renderRoot.querySelector('context-menu'); + void this._loadColumnSettings(); + } + + private async _loadColumnSettings(): Promise { + const settings = await getSettings(); + this.columnOverrides = settings.callTree?.columnOverrides ?? {}; + this._setColumnView(settings.callTree?.columnView ?? 'General'); + } + updated(changedProperties: PropertyValues): void { if ( this.timelineRoot && @@ -168,6 +203,24 @@ export class AnalysisView extends LitElement { Type + + ${repeat( + CALL_TREE_VIEWS, + (view) => view.id, + (view) => + html`${this.columnOverrides[view.id] ? `${view.id} •` : view.id}`, + )} + +
+ `; } + private _handleColumnViewChange(event: Event) { + const target = event.target as HTMLInputElement; + const id = target.value || 'General'; + this._setColumnView(id); + updateSetting('callTree.columnView', id); + } + + /** Effective fields for a view id: the user override, else the built-in preset. */ + private _columnViewFields(id: string): string[] | null { + return this.columnOverrides[id] ?? getColumnView(CALL_TREE_VIEWS, id)?.fields ?? null; + } + + private _setColumnView(id: string) { + this.columnView = id; + if (this.analysisTable) { + applyColumnView(this.analysisTable, this._columnViewFields(id), ALWAYS_VISIBLE); + } + } + + /** Applies the active view and wires the header menu once the table is built. */ + private _initTableColumns(table: Tabulator) { + applyColumnView(table, this._columnViewFields(this.columnView), ALWAYS_VISIBLE); + const header = table.element.querySelector('.tabulator-header'); + header?.addEventListener('contextmenu', (event) => { + event.preventDefault(); + if (this.contextMenu) { + const hasOverride = !!this.columnOverrides[this.columnView]; + this.contextMenu.show( + buildColumnMenuItems( + table, + this.columnView, + CALL_TREE_VIEWS, + ALWAYS_VISIBLE, + hasOverride, + ), + event.clientX, + event.clientY, + ); + } + }); + } + + private _handleColumnMenuSelect(e: CustomEvent<{ itemId: string }>) { + const { itemId } = e.detail; + const table = this.analysisTable; + if (!table) { + return; + } + if (itemId.startsWith('view:')) { + this._setColumnView(itemId.slice('view:'.length)); + return; + } + if (itemId.startsWith('col:')) { + const field = itemId.slice('col:'.length); + const fields = toggleField( + this._columnViewFields(this.columnView), + field, + getTableFields(table), + ); + this.columnOverrides = { ...this.columnOverrides, [this.columnView]: fields }; + applyColumnView(table, fields, ALWAYS_VISIBLE); + updateSetting('callTree.columnOverrides', this.columnOverrides); + return; + } + if (itemId === 'reset' && this.columnOverrides[this.columnView]) { + const { [this.columnView]: _removed, ...rest } = this.columnOverrides; + this.columnOverrides = rest; + applyColumnView(table, this._columnViewFields(this.columnView), ALWAYS_VISIBLE); + updateSetting('callTree.columnOverrides', this.columnOverrides); + } + } + _copyToClipboard() { this.analysisTable?.copyToClipboard('all'); } @@ -361,6 +487,7 @@ export class AnalysisView extends LitElement { }); await tableBuilt; + this._initTableColumns(this.analysisTable); } _resetFindWidget() { diff --git a/log-viewer/src/features/call-tree/components/AggregatedTable.ts b/log-viewer/src/features/call-tree/components/AggregatedTable.ts index b6e16653..5ba030ba 100644 --- a/log-viewer/src/features/call-tree/components/AggregatedTable.ts +++ b/log-viewer/src/features/call-tree/components/AggregatedTable.ts @@ -8,13 +8,17 @@ import { vscodeMessenger } from '../../../core/messaging/VSCodeExtensionMessenge import { formatDuration } from '../../../core/utility/Util.js'; import MinMaxEditor from '../../../tabulator/editors/MinMax.js'; import { minMaxTreeFilter } from '../../../tabulator/filters/MinMax.js'; -import { progressFormatter } from '../../../tabulator/format/Progress.js'; import { progressFormatterMS } from '../../../tabulator/format/ProgressMS.js'; import { VirtualVerticalRenderer } from '../../../tabulator/renderer/VirtualVerticalRenderer.js'; import { toAggregatedCallTree, type AggregatedRow } from '../utils/Aggregation.js'; import { makeSumSelfTimeAllVisible } from '../utils/BottomCalcs.js'; +import { annotateGovernorCost } from '../utils/GovernorCost.js'; import { commonColumnDefaults, + createGovernorColumn, + createGovernorCostColumn, + createGovernorPeakColumn, + createHeapColumn, headerSortElement, registerTableModules, type TableCallbacks, @@ -40,8 +44,11 @@ export function createAggregatedTable( const tableRef: { current: Tabulator | undefined } = { current: undefined }; const selfTimeBottomCalc = makeSumSelfTimeAllVisible(() => tableRef.current); + const tableData = toAggregatedCallTree(rootMethod.children); + annotateGovernorCost(tableData, rootMethod.governorLimits); + const table = new Tabulator(container, { - data: toAggregatedCallTree(rootMethod.children), + data: tableData, index: 'id', layout: 'fitColumns', placeholder: 'No Call Tree Available', @@ -65,6 +72,8 @@ export function createAggregatedTable( { title: 'Name', field: 'text', + frozen: true, + minWidth: 200, headerSortTristate: true, bottomCalc: () => 'Total', cssClass: 'datagrid-textarea datagrid-code-text', @@ -108,6 +117,7 @@ export function createAggregatedTable( } }, widthGrow: 5, + widthShrink: 1, }, { title: 'Namespace', @@ -125,6 +135,13 @@ export function createAggregatedTable( }, headerFilterLiveFilter: false, }, + { + title: 'Caller Namespace', + field: 'callerNamespace', + sorter: 'string', + width: 120, + visible: false, + }, { title: 'Calls', field: 'callCount', @@ -136,60 +153,39 @@ export function createAggregatedTable( headerHozAlign: 'right', bottomCalc: 'sum', }, - { + createGovernorColumn({ title: 'DML Count', field: 'dmlCount.total', - sorter: 'number', - cssClass: 'number-cell', - width: 70, - minWidth: 60, - bottomCalc: 'sum', - bottomCalcFormatter: progressFormatter, - bottomCalcFormatterParams: { - precision: 0, - totalValue: rootMethod.governorLimits.dmlStatements.limit, - showPercentageText: false, - }, - formatter: progressFormatter, - formatterParams: { - precision: 0, - totalValue: rootMethod.governorLimits.dmlStatements.limit, - showPercentageText: false, - }, - hozAlign: 'right', - headerHozAlign: 'right', - tooltip(_event, cell, _onRender) { - const maxDmlStatements = rootMethod.governorLimits.dmlStatements.limit; - return cell.getValue() + (maxDmlStatements > 0 ? '/' + maxDmlStatements : ''); - }, - }, - { + limit: rootMethod.governorLimits.dmlStatements.limit, + }), + createGovernorColumn({ + title: 'DML Count (self)', + field: 'dmlCount.self', + limit: rootMethod.governorLimits.dmlStatements.limit, + visible: false, + }), + createGovernorColumn({ title: 'SOQL Count', field: 'soqlCount.total', - sorter: 'number', - cssClass: 'number-cell', - width: 70, - minWidth: 60, - bottomCalc: 'sum', - bottomCalcFormatter: progressFormatter, - bottomCalcFormatterParams: { - precision: 0, - totalValue: rootMethod.governorLimits.soqlQueries.limit, - showPercentageText: false, - }, - formatter: progressFormatter, - formatterParams: { - precision: 0, - totalValue: rootMethod.governorLimits.soqlQueries.limit, - showPercentageText: false, - }, - hozAlign: 'right', - headerHozAlign: 'right', - tooltip(_event, cell, _onRender) { - const maxSoql = rootMethod.governorLimits.soqlQueries.limit; - return cell.getValue() + (maxSoql > 0 ? '/' + maxSoql : ''); - }, - }, + limit: rootMethod.governorLimits.soqlQueries.limit, + }), + createGovernorColumn({ + title: 'SOQL Count (self)', + field: 'soqlCount.self', + limit: rootMethod.governorLimits.soqlQueries.limit, + visible: false, + }), + createGovernorColumn({ + title: 'SOSL Count', + field: 'soslCount.total', + limit: rootMethod.governorLimits.soslQueries.limit, + }), + createGovernorColumn({ + title: 'SOSL Count (self)', + field: 'soslCount.self', + limit: rootMethod.governorLimits.soslQueries.limit, + visible: false, + }), { title: 'Throws Count', field: 'thrownCount.total', @@ -200,58 +196,39 @@ export function createAggregatedTable( headerHozAlign: 'right', bottomCalc: 'sum', }, - { + createGovernorColumn({ title: 'DML Rows', field: 'dmlRowCount.total', - sorter: 'number', - cssClass: 'number-cell', - width: 60, - bottomCalc: 'sum', - bottomCalcFormatter: progressFormatter, - bottomCalcFormatterParams: { - precision: 0, - totalValue: rootMethod.governorLimits.dmlRows.limit, - showPercentageText: false, - }, - formatter: progressFormatter, - formatterParams: { - precision: 0, - totalValue: rootMethod.governorLimits.dmlRows.limit, - showPercentageText: false, - }, - hozAlign: 'right', - headerHozAlign: 'right', - tooltip(_event, cell, _onRender) { - const maxDmlRows = rootMethod.governorLimits.dmlRows.limit; - return cell.getValue() + (maxDmlRows > 0 ? '/' + maxDmlRows : ''); - }, - }, - { + limit: rootMethod.governorLimits.dmlRows.limit, + }), + createGovernorColumn({ + title: 'DML Rows (self)', + field: 'dmlRowCount.self', + limit: rootMethod.governorLimits.dmlRows.limit, + visible: false, + }), + createGovernorColumn({ title: 'SOQL Rows', field: 'soqlRowCount.total', - sorter: 'number', - cssClass: 'number-cell', - width: 60, - bottomCalc: 'sum', - bottomCalcFormatter: progressFormatter, - bottomCalcFormatterParams: { - precision: 0, - totalValue: rootMethod.governorLimits.queryRows.limit, - showPercentageText: false, - }, - formatter: progressFormatter, - formatterParams: { - precision: 0, - totalValue: rootMethod.governorLimits.queryRows.limit, - showPercentageText: false, - }, - hozAlign: 'right', - headerHozAlign: 'right', - tooltip(_event, cell, _onRender) { - const maxQueryRows = rootMethod.governorLimits.queryRows.limit; - return cell.getValue() + (maxQueryRows > 0 ? '/' + maxQueryRows : ''); - }, - }, + limit: rootMethod.governorLimits.queryRows.limit, + }), + createGovernorColumn({ + title: 'SOQL Rows (self)', + field: 'soqlRowCount.self', + limit: rootMethod.governorLimits.queryRows.limit, + visible: false, + }), + createGovernorColumn({ + title: 'SOSL Rows', + field: 'soslRowCount.total', + limit: rootMethod.governorLimits.queryRows.limit, + }), + createGovernorColumn({ + title: 'SOSL Rows (self)', + field: 'soslRowCount.self', + limit: rootMethod.governorLimits.queryRows.limit, + visible: false, + }), { title: 'Total Time (ms)', field: 'totalTime', @@ -298,6 +275,23 @@ export function createAggregatedTable( headerFilterLiveFilter: false, tooltip: (_event, cell) => formatDuration(cell.getValue()), }, + { + title: 'Avg Self Time (ms)', + field: 'avgSelfTime', + sorter: 'number', + headerSortTristate: true, + width: 150, + minWidth: 120, + hozAlign: 'right', + headerHozAlign: 'right', + formatter: progressFormatterMS, + formatterParams: { precision: 2, totalValue: rootMethod.duration.total }, + tooltip: (_event, cell) => formatDuration(cell.getValue()), + }, + createHeapColumn(rootMethod.governorLimits), + createHeapColumn(rootMethod.governorLimits, 'heapAllocated.self', 'Heap (self)', false), + createGovernorCostColumn(rootMethod.governorLimits), + createGovernorPeakColumn(rootMethod.governorLimits), ], }); tableRef.current = table; diff --git a/log-viewer/src/features/call-tree/components/BottomUpTable.ts b/log-viewer/src/features/call-tree/components/BottomUpTable.ts index a2760c46..e0813f3d 100644 --- a/log-viewer/src/features/call-tree/components/BottomUpTable.ts +++ b/log-viewer/src/features/call-tree/components/BottomUpTable.ts @@ -16,8 +16,13 @@ import { VirtualVerticalRenderer } from '../../../tabulator/renderer/VirtualVert import { sumDurationTotalForRootEvents } from '../../analysis/services/CallStackSum.js'; import { soqlGroupHeader } from '../../soql/format/groupHeader.js'; import { toBottomUpTree, type BottomUpRow } from '../utils/Aggregation.js'; +import { annotateGovernorCost } from '../utils/GovernorCost.js'; import { commonColumnDefaults, + createGovernorColumn, + createGovernorCostColumn, + createGovernorPeakColumn, + createHeapColumn, headerSortElement, registerTableModules, type TableCallbacks, @@ -94,8 +99,11 @@ export function createBottomUpTable( } : {}; + const tableData = toBottomUpTree(rootMethod.children); + annotateGovernorCost(tableData, rootMethod.governorLimits); + const tabulatorOptions = { - data: toBottomUpTree(rootMethod.children), + data: tableData, index: 'id', layout: 'fitColumns', placeholder: options.placeholder ?? 'No Call Tree Available', @@ -130,6 +138,8 @@ export function createBottomUpTable( { title: 'Name', field: 'text', + frozen: true, + minWidth: 200, headerSortTristate: true, bottomCalc: () => 'Total', cssClass: 'datagrid-textarea datagrid-code-text', @@ -152,6 +162,7 @@ export function createBottomUpTable( } }, widthGrow: 5, + widthShrink: 1, }, { title: 'Namespace', @@ -168,6 +179,13 @@ export function createBottomUpTable( }, headerFilterLiveFilter: false, }, + { + title: 'Caller Namespace', + field: 'callerNamespace', + sorter: 'string', + width: 120, + visible: false, + }, { title: 'Type', field: 'type', @@ -187,6 +205,82 @@ export function createBottomUpTable( headerHozAlign: 'right', bottomCalc: 'sum', }, + createGovernorColumn({ + title: 'DML Count', + field: 'dmlCount.total', + limit: rootMethod.governorLimits.dmlStatements.limit, + }), + createGovernorColumn({ + title: 'DML Count (self)', + field: 'dmlCount.self', + limit: rootMethod.governorLimits.dmlStatements.limit, + visible: false, + }), + createGovernorColumn({ + title: 'SOQL Count', + field: 'soqlCount.total', + limit: rootMethod.governorLimits.soqlQueries.limit, + }), + createGovernorColumn({ + title: 'SOQL Count (self)', + field: 'soqlCount.self', + limit: rootMethod.governorLimits.soqlQueries.limit, + visible: false, + }), + createGovernorColumn({ + title: 'SOSL Count', + field: 'soslCount.total', + limit: rootMethod.governorLimits.soslQueries.limit, + }), + createGovernorColumn({ + title: 'SOSL Count (self)', + field: 'soslCount.self', + limit: rootMethod.governorLimits.soslQueries.limit, + visible: false, + }), + { + title: 'Throws Count', + field: 'thrownCount.total', + sorter: 'number', + cssClass: 'number-cell', + width: 60, + hozAlign: 'right', + headerHozAlign: 'right', + bottomCalc: 'sum', + }, + createGovernorColumn({ + title: 'DML Rows', + field: 'dmlRowCount.total', + limit: rootMethod.governorLimits.dmlRows.limit, + }), + createGovernorColumn({ + title: 'DML Rows (self)', + field: 'dmlRowCount.self', + limit: rootMethod.governorLimits.dmlRows.limit, + visible: false, + }), + createGovernorColumn({ + title: 'SOQL Rows', + field: 'soqlRowCount.total', + limit: rootMethod.governorLimits.queryRows.limit, + }), + createGovernorColumn({ + title: 'SOQL Rows (self)', + field: 'soqlRowCount.self', + limit: rootMethod.governorLimits.queryRows.limit, + visible: false, + }), + createGovernorColumn({ + title: 'SOSL Rows', + field: 'soslRowCount.total', + limit: rootMethod.governorLimits.queryRows.limit, + }), + createGovernorColumn({ + title: 'SOSL Rows (self)', + field: 'soslRowCount.self', + limit: rootMethod.governorLimits.queryRows.limit, + visible: false, + }), { title: 'Total Time (ms)', field: 'totalTime', @@ -231,6 +325,24 @@ export function createBottomUpTable( headerFilterLiveFilter: false, tooltip: (_event, cell) => formatDuration(cell.getValue()), }, + { + title: 'Avg Self Time (ms)', + field: 'avgSelfTime', + sorter: 'number', + headerSortTristate: true, + width: 165, + minWidth: 120, + hozAlign: 'right', + headerHozAlign: 'right', + visible: false, + formatter: progressFormatterMS, + formatterParams: { precision: 2, totalValue: rootMethod.duration.total }, + tooltip: (_event, cell) => formatDuration(cell.getValue()), + }, + createHeapColumn(rootMethod.governorLimits), + createHeapColumn(rootMethod.governorLimits, 'heapAllocated.self', 'Heap (self)', false), + createGovernorCostColumn(rootMethod.governorLimits), + createGovernorPeakColumn(rootMethod.governorLimits), ], ...tabulatorOptionOverrides, }); diff --git a/log-viewer/src/features/call-tree/components/CalltreeView.ts b/log-viewer/src/features/call-tree/components/CalltreeView.ts index f0ee5fa3..1373f5f8 100644 --- a/log-viewer/src/features/call-tree/components/CalltreeView.ts +++ b/log-viewer/src/features/call-tree/components/CalltreeView.ts @@ -15,6 +15,7 @@ import { eventBus } from '../../../core/events/EventBus.js'; import { vscodeMessenger } from '../../../core/messaging/VSCodeExtensionMessenger.js'; import { findEventByEventIndex } from '../../../core/utility/EventSearch.js'; import { isVisible } from '../../../core/utility/Util.js'; +import { getSettings, updateSetting } from '../../settings/Settings.js'; import type { AggregatedRow, BottomUpRow } from '../utils/Aggregation.js'; import { categoryColoringStyles, @@ -39,10 +40,21 @@ import '../../../components/GridSkeleton.js'; // Table creation functions import { createAggregatedTable } from './AggregatedTable.js'; import { createBottomUpTable } from './BottomUpTable.js'; +import { + applyColumnView, + buildColumnMenuItems, + CALL_TREE_VIEWS, + getColumnView, + getTableFields, + toggleField, +} from '../../../tabulator/ColumnViews.js'; import { createTimeOrderTable } from './TimeOrderTable.js'; type ViewMode = 'time-order' | 'aggregated' | 'bottom-up'; +/** The Name column is always shown in the call-tree tables. */ +const ALWAYS_VISIBLE = ['text']; + const DEBUG_VALUE_TYPES: ReadonlySet = new Set([ 'USER_DEBUG', 'DATAWEAVE_USER_DEBUG', @@ -94,8 +106,17 @@ export class CalltreeView extends LitElement { tableContainer: HTMLDivElement | null = null; rootMethod: ApexLog | null = null; + @state() + columnView = 'General'; + + /** Per-view column overrides (view id → visible fields); empty until edited. */ + @state() + private columnOverrides: Record = {}; + private contextMenu: ContextMenu | null = null; private contextMenuRow: TimeOrderRow | null = null; + /** The table whose header was right-clicked (for column-toggle actions). */ + private contextMenuTable: Tabulator | null = null; private viewSwitchEpoch = 0; get _callTreeTableWrapper(): HTMLDivElement | null { @@ -141,6 +162,13 @@ export class CalltreeView extends LitElement { firstUpdated(): void { this.contextMenu = this.renderRoot.querySelector('context-menu'); + void this._loadColumnSettings(); + } + + private async _loadColumnSettings(): Promise { + const settings = await getSettings(); + this.columnOverrides = settings.callTree?.columnOverrides ?? {}; + this._setColumnView(settings.callTree?.columnView ?? 'General'); } static styles = [ @@ -268,6 +296,27 @@ export class CalltreeView extends LitElement { > +
+ + ${repeat( + CALL_TREE_VIEWS, + (view) => view.id, + (view) => + html`${this.columnOverrides[view.id] ? `${view.id} •` : view.id}`, + )} + +
+
Expand !!table, + ); + } + + private _setColumnView(id: string) { + this.columnView = id; + const fields = this._columnViewFields(id); + for (const table of this._tables) { + applyColumnView(table, fields, ALWAYS_VISIBLE); + } + } + + /** Applies the active view and wires the header menu once a table is built. */ + private _initTableColumns(table: Tabulator) { + applyColumnView(table, this._columnViewFields(this.columnView), ALWAYS_VISIBLE); + const header = table.element.querySelector('.tabulator-header'); + header?.addEventListener('contextmenu', (event) => { + event.preventDefault(); + this._showHeaderContextMenu(table, event.clientX, event.clientY); + }); + } + + private _showHeaderContextMenu(table: Tabulator, clientX: number, clientY: number) { + if (!this.contextMenu) { + return; + } + this.contextMenuRow = null; + this.contextMenuTable = table; + const hasOverride = !!this.columnOverrides[this.columnView]; + this.contextMenu.show( + buildColumnMenuItems(table, this.columnView, CALL_TREE_VIEWS, ALWAYS_VISIBLE, hasOverride), + clientX, + clientY, + ); + } + + /** Toggles a column in the active view's override, shared across all tables. */ + private _toggleColumn(field: string) { + const table = this.contextMenuTable; + this.contextMenuTable = null; + if (!table) { + return; + } + const fields = toggleField( + this._columnViewFields(this.columnView), + field, + getTableFields(table), + ); + this.columnOverrides = { ...this.columnOverrides, [this.columnView]: fields }; + for (const t of this._tables) { + applyColumnView(t, fields, ALWAYS_VISIBLE); + } + updateSetting('callTree.columnOverrides', this.columnOverrides); + } + + /** Clears the active view's override, restoring its built-in columns. */ + private _resetColumns() { + if (!this.columnOverrides[this.columnView]) { + return; + } + const { [this.columnView]: _removed, ...rest } = this.columnOverrides; + this.columnOverrides = rest; + for (const table of this._tables) { + applyColumnView(table, this._columnViewFields(this.columnView), ALWAYS_VISIBLE); + } + updateSetting('callTree.columnOverrides', this.columnOverrides); + } + _handleTypeFilter(event: Event) { const target = event.target as HTMLInputElement; this.typeFilter = target.value || 'All'; @@ -710,6 +841,7 @@ export class CalltreeView extends LitElement { }); this.calltreeTable = table; await tableBuilt; + this._initTableColumns(table); } private async _renderAggregatedTree( @@ -738,6 +870,7 @@ export class CalltreeView extends LitElement { }); this.aggregatedTreeTable = table; await tableBuilt; + this._initTableColumns(table); } private async _renderBottomUpTree(container: HTMLDivElement, rootMethod: ApexLog): Promise { @@ -768,6 +901,7 @@ export class CalltreeView extends LitElement { ); this.bottomUpTreeTable = table; await tableBuilt; + this._initTableColumns(table); } private _waitForNextFrame(): Promise { @@ -844,6 +978,24 @@ export class CalltreeView extends LitElement { } private _handleContextMenuSelect(e: CustomEvent<{ itemId: string }>): void { + const { itemId } = e.detail; + + // Column-header menu actions (see _showHeaderContextMenu). + if (itemId.startsWith('view:')) { + this._setColumnView(itemId.slice('view:'.length)); + this.contextMenuTable = null; + return; + } + if (itemId.startsWith('col:')) { + this._toggleColumn(itemId.slice('col:'.length)); + return; + } + if (itemId === 'reset') { + this._resetColumns(); + this.contextMenuTable = null; + return; + } + if (!this.contextMenuRow) { return; } diff --git a/log-viewer/src/features/call-tree/components/TableShared.ts b/log-viewer/src/features/call-tree/components/TableShared.ts index ea379657..9399ba41 100644 --- a/log-viewer/src/features/call-tree/components/TableShared.ts +++ b/log-viewer/src/features/call-tree/components/TableShared.ts @@ -1,14 +1,17 @@ /* * Copyright (c) 2025 Certinia Inc. All rights reserved. */ -import { Tabulator, type RowComponent } from 'tabulator-tables'; +import type { GovernorLimits } from 'apex-log-parser'; +import { Tabulator, type ColumnDefinition, type RowComponent } from 'tabulator-tables'; +import { progressFormatter } from '../../../tabulator/format/Progress.js'; import { AnchoringPolicy } from '../../../tabulator/module/AnchoringPolicy.js'; import * as CommonModules from '../../../tabulator/module/CommonModules.js'; import { Find } from '../../../tabulator/module/Find.js'; import { RowKeyboardNavigation } from '../../../tabulator/module/RowKeyboardNavigation.js'; import { RowNavigation } from '../../../tabulator/module/RowNavigation.js'; import type { AggregatedRow, BottomUpRow } from '../utils/Aggregation.js'; +import { governorCostBreakdown, type GovernorCostRow } from '../utils/GovernorCost.js'; import type { TimeOrderRow } from '../utils/TimeOrderTree.js'; export interface TableCallbacks { @@ -45,4 +48,171 @@ export const commonColumnDefaults = { headerSortStartingDir: 'desc' as const, headerTooltip: true, headerWordWrap: true, + // Name-only flex: every column keeps its content width by default (no + // stretch, no squeeze). The Name column overrides these to absorb slack. + widthGrow: 0, + widthShrink: 0, }; + +/** Bytes → human-readable (e.g. 1536 → "1.5 KB"), for heap columns/tooltips. */ +export function formatBytes(bytes: number): string { + if (bytes < 1024) { + return `${bytes} B`; + } + const units = ['KB', 'MB', 'GB']; + let value = bytes / 1024; + let unit = 0; + while (value >= 1024 && unit < units.length - 1) { + value /= 1024; + unit++; + } + return `${value.toFixed(1)} ${units[unit]}`; +} + +/** + * The shared "Gov. Cost (%)" column — the average governor consumption across all + * governors on a call path (see {@link governorCost}), rendered as a progress + * bar. Reused across all call-tree/analysis tables. `governorCost` is populated + * by {@link annotateGovernorCost}; the tooltip breaks the average down per metric. + */ +export function createGovernorCostColumn(governorLimits: GovernorLimits): ColumnDefinition { + const formatterParams = { precision: 0, totalValue: 100, showPercentageText: false }; + return { + title: 'Gov. Cost (%)', + field: 'governorCost', + sorter: 'number', + cssClass: 'number-cell', + width: 100, + minWidth: 80, + hozAlign: 'right', + headerHozAlign: 'right', + formatter: progressFormatter, + formatterParams, + bottomCalc: 'max', + bottomCalcFormatter: progressFormatter, + bottomCalcFormatterParams: formatterParams, + tooltip(_event, cell) { + const total = (cell.getValue() ?? 0) as number; + const breakdown = governorCostBreakdown(cell.getData() as GovernorCostRow, governorLimits); + if (!breakdown.length) { + return `${total.toFixed(1)}%`; + } + const rows = breakdown.map((m) => { + const used = m.label === 'Heap' ? formatBytes(m.used) : `${m.used}`; + const limit = m.label === 'Heap' ? formatBytes(m.limit) : `${m.limit}`; + return `${m.label} ${used}/${limit} (${m.percent.toFixed(1)}%)`; + }); + return `${total.toFixed(1)}% avg across all governors
${rows.join('
')}`; + }, + }; +} + +/** + * The "Peak Limit (%)" column — the single tightest governor consumed on a path + * (see {@link governorCostMax}), rendered as a bar. Complements the averaged + * Gov. Cost column; hidden by default (surfaced by the Governor Limits view or a + * user toggle). The tooltip names which governor is the peak. + */ +export function createGovernorPeakColumn(governorLimits: GovernorLimits): ColumnDefinition { + const formatterParams = { precision: 0, totalValue: 100, showPercentageText: false }; + return { + title: 'Peak Limit (%)', + field: 'governorCostMax', + visible: false, + sorter: 'number', + cssClass: 'number-cell', + width: 100, + minWidth: 80, + hozAlign: 'right', + headerHozAlign: 'right', + formatter: progressFormatter, + formatterParams, + bottomCalc: 'max', + bottomCalcFormatter: progressFormatter, + bottomCalcFormatterParams: formatterParams, + tooltip(_event, cell) { + const peak = (cell.getValue() ?? 0) as number; + const [top] = governorCostBreakdown(cell.getData() as GovernorCostRow, governorLimits); + if (!top) { + return `${peak.toFixed(1)}%`; + } + const used = top.label === 'Heap' ? formatBytes(top.used) : `${top.used}`; + const limit = top.label === 'Heap' ? formatBytes(top.limit) : `${top.limit}`; + return `Peak ${top.label} ${used}/${limit} (${peak.toFixed(1)}%)`; + }, + }; +} + +/** + * A governor-metric column (DML/SOQL/SOSL counts & rows) rendered as a bar + * relative to its governor `limit`. Shared by all call-tree/analysis tables so + * the Total and Self variants stay consistent. Pass `visible: false` for the + * Self variants, which are hidden until a view or the user shows them. + */ +export function createGovernorColumn(opts: { + title: string; + field: string; + limit: number; + width?: number; + minWidth?: number; + visible?: boolean; +}): ColumnDefinition { + const { title, field, limit, width = 70, minWidth = 60, visible } = opts; + const formatterParams = { precision: 0, totalValue: limit, showPercentageText: false }; + return { + title, + field, + visible, + sorter: 'number', + cssClass: 'number-cell', + width, + minWidth, + hozAlign: 'right', + headerHozAlign: 'right', + formatter: progressFormatter, + formatterParams, + bottomCalc: 'sum', + bottomCalcFormatter: progressFormatter, + bottomCalcFormatterParams: formatterParams, + tooltip(_event, cell) { + const value = cell.getValue(); + return value + (limit > 0 ? '/' + limit : ''); + }, + }; +} + +/** + * The shared "Heap Allocated" column — bytes allocated on a call path, rendered + * as a bar relative to the heap governor limit. Reused across all + * call-tree/analysis tables. Pass a `heapAllocated.self` field + `visible: false` + * for the Self variant. + */ +export function createHeapColumn( + governorLimits: GovernorLimits, + field = 'heapAllocated.total', + title = 'Heap Allocated', + visible?: boolean, +): ColumnDefinition { + const limit = governorLimits.heapSize.limit; + const formatterParams = { precision: 0, totalValue: limit, showPercentageText: false }; + return { + title, + field, + visible, + sorter: 'number', + cssClass: 'number-cell', + width: 90, + minWidth: 70, + hozAlign: 'right', + headerHozAlign: 'right', + formatter: progressFormatter, + formatterParams, + bottomCalc: 'sum', + bottomCalcFormatter: progressFormatter, + bottomCalcFormatterParams: formatterParams, + tooltip(_event, cell) { + const used = (cell.getValue() ?? 0) as number; + return limit > 0 ? `${formatBytes(used)} / ${formatBytes(limit)}` : formatBytes(used); + }, + }; +} diff --git a/log-viewer/src/features/call-tree/components/TimeOrderTable.ts b/log-viewer/src/features/call-tree/components/TimeOrderTable.ts index 70195e35..ce6e550a 100644 --- a/log-viewer/src/features/call-tree/components/TimeOrderTable.ts +++ b/log-viewer/src/features/call-tree/components/TimeOrderTable.ts @@ -8,14 +8,18 @@ import { vscodeMessenger } from '../../../core/messaging/VSCodeExtensionMessenge import { formatDuration } from '../../../core/utility/Util.js'; import MinMaxEditor from '../../../tabulator/editors/MinMax.js'; import { minMaxTreeFilter } from '../../../tabulator/filters/MinMax.js'; -import { progressFormatter } from '../../../tabulator/format/Progress.js'; import { progressFormatterMS } from '../../../tabulator/format/ProgressMS.js'; import { VirtualVerticalRenderer } from '../../../tabulator/renderer/VirtualVerticalRenderer.js'; import { makeSumSelfTimeAllVisible } from '../utils/BottomCalcs.js'; +import { annotateGovernorCost } from '../utils/GovernorCost.js'; import { toTimeOrderTree, type TimeOrderRow } from '../utils/TimeOrderTree.js'; import { createCalltreeNameFormatter } from './CalltreeNameFormatter.js'; import { commonColumnDefaults, + createGovernorColumn, + createGovernorCostColumn, + createGovernorPeakColumn, + createHeapColumn, headerSortElement, registerTableModules, type TableCallbacks, @@ -41,6 +45,7 @@ export function createTimeOrderTable( const governorLimits = rootMethod.governorLimits; const tableData = toTimeOrderTree(rootMethod.children); + annotateGovernorCost(tableData, governorLimits); const nameFormatter = createCalltreeNameFormatter(excludedTypes); const tableRef: { current: Tabulator | undefined } = { current: undefined }; @@ -73,6 +78,8 @@ export function createTimeOrderTable( { title: 'Name', field: 'text', + frozen: true, + minWidth: 200, headerSortTristate: true, bottomCalc: () => 'Total', cssClass: 'datagrid-textarea datagrid-code-text', @@ -93,6 +100,7 @@ export function createTimeOrderTable( } }, widthGrow: 5, + widthShrink: 1, }, { title: 'Namespace', @@ -111,59 +119,45 @@ export function createTimeOrderTable( headerFilterLiveFilter: false, }, { + title: 'Caller Namespace', + field: 'callerNamespace', + sorter: 'string', + width: 120, + visible: false, + }, + createGovernorColumn({ title: 'DML Count', field: 'dmlCount.total', - sorter: 'number', - cssClass: 'number-cell', - width: 70, - minWidth: 60, - bottomCalc: 'sum', - bottomCalcFormatter: progressFormatter, - bottomCalcFormatterParams: { - precision: 0, - totalValue: governorLimits.dmlStatements.limit, - showPercentageText: false, - }, - formatter: progressFormatter, - formatterParams: { - precision: 0, - totalValue: governorLimits.dmlStatements.limit, - showPercentageText: false, - }, - hozAlign: 'right', - headerHozAlign: 'right', - tooltip(_event, cell, _onRender) { - const maxDmlStatements = governorLimits.dmlStatements.limit; - return cell.getValue() + (maxDmlStatements > 0 ? '/' + maxDmlStatements : ''); - }, - }, - { + limit: governorLimits.dmlStatements.limit, + }), + createGovernorColumn({ + title: 'DML Count (self)', + field: 'dmlCount.self', + limit: governorLimits.dmlStatements.limit, + visible: false, + }), + createGovernorColumn({ title: 'SOQL Count', field: 'soqlCount.total', - sorter: 'number', - cssClass: 'number-cell', - width: 70, - minWidth: 60, - bottomCalc: 'sum', - bottomCalcFormatter: progressFormatter, - bottomCalcFormatterParams: { - precision: 0, - totalValue: governorLimits.soqlQueries.limit, - showPercentageText: false, - }, - formatter: progressFormatter, - formatterParams: { - precision: 0, - totalValue: governorLimits.soqlQueries.limit, - showPercentageText: false, - }, - hozAlign: 'right', - headerHozAlign: 'right', - tooltip(_event, cell, _onRender) { - const maxSoql = governorLimits.soqlQueries.limit; - return cell.getValue() + (maxSoql > 0 ? '/' + maxSoql : ''); - }, - }, + limit: governorLimits.soqlQueries.limit, + }), + createGovernorColumn({ + title: 'SOQL Count (self)', + field: 'soqlCount.self', + limit: governorLimits.soqlQueries.limit, + visible: false, + }), + createGovernorColumn({ + title: 'SOSL Count', + field: 'soslCount.total', + limit: governorLimits.soslQueries.limit, + }), + createGovernorColumn({ + title: 'SOSL Count (self)', + field: 'soslCount.self', + limit: governorLimits.soslQueries.limit, + visible: false, + }), { title: 'Throws Count', field: 'thrownCount.total', @@ -174,58 +168,39 @@ export function createTimeOrderTable( headerHozAlign: 'right', bottomCalc: 'sum', }, - { + createGovernorColumn({ title: 'DML Rows', field: 'dmlRowCount.total', - sorter: 'number', - cssClass: 'number-cell', - width: 60, - bottomCalc: 'sum', - bottomCalcFormatter: progressFormatter, - bottomCalcFormatterParams: { - precision: 0, - totalValue: governorLimits.dmlRows.limit, - showPercentageText: false, - }, - formatter: progressFormatter, - formatterParams: { - precision: 0, - totalValue: governorLimits.dmlRows.limit, - showPercentageText: false, - }, - hozAlign: 'right', - headerHozAlign: 'right', - tooltip(_event, cell, _onRender) { - const maxDmlRows = governorLimits.dmlRows.limit; - return cell.getValue() + (maxDmlRows > 0 ? '/' + maxDmlRows : ''); - }, - }, - { + limit: governorLimits.dmlRows.limit, + }), + createGovernorColumn({ + title: 'DML Rows (self)', + field: 'dmlRowCount.self', + limit: governorLimits.dmlRows.limit, + visible: false, + }), + createGovernorColumn({ title: 'SOQL Rows', field: 'soqlRowCount.total', - sorter: 'number', - cssClass: 'number-cell', - width: 60, - bottomCalc: 'sum', - bottomCalcFormatter: progressFormatter, - bottomCalcFormatterParams: { - precision: 0, - totalValue: governorLimits.queryRows.limit, - showPercentageText: false, - }, - formatter: progressFormatter, - formatterParams: { - precision: 0, - totalValue: governorLimits.queryRows.limit, - showPercentageText: false, - }, - hozAlign: 'right', - headerHozAlign: 'right', - tooltip(_event, cell, _onRender) { - const maxQueryRows = governorLimits.queryRows.limit; - return cell.getValue() + (maxQueryRows > 0 ? '/' + maxQueryRows : ''); - }, - }, + limit: governorLimits.queryRows.limit, + }), + createGovernorColumn({ + title: 'SOQL Rows (self)', + field: 'soqlRowCount.self', + limit: governorLimits.queryRows.limit, + visible: false, + }), + createGovernorColumn({ + title: 'SOSL Rows', + field: 'soslRowCount.total', + limit: governorLimits.queryRows.limit, + }), + createGovernorColumn({ + title: 'SOSL Rows (self)', + field: 'soslRowCount.self', + limit: governorLimits.queryRows.limit, + visible: false, + }), { title: 'Total Time (ms)', field: 'duration.total', @@ -277,6 +252,10 @@ export function createTimeOrderTable( return formatDuration(cell.getValue()); }, }, + createHeapColumn(governorLimits), + createHeapColumn(governorLimits, 'heapAllocated.self', 'Heap (self)', false), + createGovernorCostColumn(governorLimits), + createGovernorPeakColumn(governorLimits), ], }); tableRef.current = table; diff --git a/log-viewer/src/features/call-tree/utils/Aggregation.ts b/log-viewer/src/features/call-tree/utils/Aggregation.ts index cbee8217..2a20e60c 100644 --- a/log-viewer/src/features/call-tree/utils/Aggregation.ts +++ b/log-viewer/src/features/call-tree/utils/Aggregation.ts @@ -34,12 +34,22 @@ export interface AggregatedRow { dmlCount: SelfTotal; /** Total SOQL count */ soqlCount: SelfTotal; + /** Total SOSL count */ + soslCount: SelfTotal; /** Total DML rows */ dmlRowCount: SelfTotal; /** Total SOQL rows */ soqlRowCount: SelfTotal; + /** Total SOSL rows */ + soslRowCount: SelfTotal; /** Total + self exceptions thrown */ thrownCount: SelfTotal; + /** Total + self heap bytes allocated */ + heapAllocated: SelfTotal; + /** Average governor consumption across all reported governors (0–100%). */ + governorCost: number; + /** The single tightest governor consumed on this path (0–100+%). */ + governorCostMax: number; /** Aggregated children (callees grouped by signature) */ _children?: AggregatedRow[] | null; /** References to original events for drill-down */ @@ -82,12 +92,22 @@ export interface BottomUpRow { dmlCount: SelfTotal; /** Total SOQL count */ soqlCount: SelfTotal; + /** Total SOSL count */ + soslCount: SelfTotal; /** Total DML rows */ dmlRowCount: SelfTotal; /** Total SOQL rows */ soqlRowCount: SelfTotal; + /** Total SOSL rows */ + soslRowCount: SelfTotal; /** Total + self exceptions thrown */ thrownCount: SelfTotal; + /** Total + self heap bytes allocated */ + heapAllocated: SelfTotal; + /** Average governor consumption across all reported governors (0–100%). */ + governorCost: number; + /** The single tightest governor consumed on this path (0–100+%). */ + governorCostMax: number; /** Callers (parent functions) as children - lazy loaded */ _children?: BottomUpRow[] | null; /** @@ -239,12 +259,18 @@ function addEventToAggregatedRowWithStack( row.dmlCount.total += event.dmlCount.total; row.soqlCount.self += event.soqlCount.self; row.soqlCount.total += event.soqlCount.total; + row.soslCount.self += event.soslCount.self; + row.soslCount.total += event.soslCount.total; row.dmlRowCount.self += event.dmlRowCount.self; row.dmlRowCount.total += event.dmlRowCount.total; row.soqlRowCount.self += event.soqlRowCount.self; row.soqlRowCount.total += event.soqlRowCount.total; + row.soslRowCount.self += event.soslRowCount.self; + row.soslRowCount.total += event.soslRowCount.total; row.thrownCount.self += event.thrownCount.self; row.thrownCount.total += event.thrownCount.total; + row.heapAllocated.self += event.heapAllocated.self; + row.heapAllocated.total += event.heapAllocated.total; row.instances.push(event); } @@ -276,8 +302,10 @@ function addEventToAggregatedRowWithStack( * - duration.self / duration.total * - dmlCount.self / dmlCount.total * - soqlCount.self / soqlCount.total + * - soslCount.self / soslCount.total * - dmlRowCount.self / dmlRowCount.total * - soqlRowCount.self / soqlRowCount.total + * - soslRowCount.self / soslRowCount.total * - thrownCount.self / thrownCount.total */ type FrameContext = { @@ -290,9 +318,12 @@ type FrameContext = { totalTime: number; dmlTotal: number; soqlTotal: number; + soslTotal: number; dmlRowTotal: number; soqlRowTotal: number; + soslRowTotal: number; thrownTotal: number; + heapTotal: number; }; type DfsEntry = { @@ -356,18 +387,24 @@ export function toBottomUpTree(rootChildren: LogEvent[]): BottomUpRow[] { totalTime: node.duration.total, dmlTotal: node.dmlCount.total, soqlTotal: node.soqlCount.total, + soslTotal: node.soslCount.total, dmlRowTotal: node.dmlRowCount.total, soqlRowTotal: node.soqlRowCount.total, + soslRowTotal: node.soslRowCount.total, thrownTotal: node.thrownCount.total, + heapTotal: node.heapAllocated.total, }; if (prior) { prior.totalTime -= node.duration.total; prior.dmlTotal -= node.dmlCount.total; prior.soqlTotal -= node.soqlCount.total; + prior.soslTotal -= node.soslCount.total; prior.dmlRowTotal -= node.dmlRowCount.total; prior.soqlRowTotal -= node.soqlRowCount.total; + prior.soslRowTotal -= node.soslRowCount.total; prior.thrownTotal -= node.thrownCount.total; + prior.heapTotal -= node.heapAllocated.total; } activeByName.set(stackKey, ctx); dfs.push({ node, childIdx: 0, ctx }); @@ -381,15 +418,21 @@ export function toBottomUpTree(rootChildren: LogEvent[]): BottomUpRow[] { const selfTime = node.duration.self; const dmlSelf = node.dmlCount.self; const soqlSelf = node.soqlCount.self; + const soslSelf = node.soslCount.self; const dmlRowSelf = node.dmlRowCount.self; const soqlRowSelf = node.soqlRowCount.self; + const soslRowSelf = node.soslRowCount.self; const thrownSelf = node.thrownCount.self; + const heapSelf = node.heapAllocated.self; const totalTime = ctx.totalTime; const dmlTotal = ctx.dmlTotal; const soqlTotal = ctx.soqlTotal; + const soslTotal = ctx.soslTotal; const dmlRowTotal = ctx.dmlRowTotal; const soqlRowTotal = ctx.soqlRowTotal; + const soslRowTotal = ctx.soslRowTotal; const thrownTotal = ctx.thrownTotal; + const heapTotal = ctx.heapTotal; // Closure captures the hoisted locals; zero-delta guards skip no-op writes // for logs without heavy DB work. @@ -409,6 +452,12 @@ export function toBottomUpTree(rootChildren: LogEvent[]): BottomUpRow[] { if (soqlTotal) { b.soqlCount.total += soqlTotal; } + if (soslSelf) { + b.soslCount.self += soslSelf; + } + if (soslTotal) { + b.soslCount.total += soslTotal; + } if (dmlRowSelf) { b.dmlRowCount.self += dmlRowSelf; } @@ -421,12 +470,24 @@ export function toBottomUpTree(rootChildren: LogEvent[]): BottomUpRow[] { if (soqlRowTotal) { b.soqlRowCount.total += soqlRowTotal; } + if (soslRowSelf) { + b.soslRowCount.self += soslRowSelf; + } + if (soslRowTotal) { + b.soslRowCount.total += soslRowTotal; + } if (thrownSelf) { b.thrownCount.self += thrownSelf; } if (thrownTotal) { b.thrownCount.total += thrownTotal; } + if (heapSelf) { + b.heapAllocated.self += heapSelf; + } + if (heapTotal) { + b.heapAllocated.total += heapTotal; + } }; const top = chainIds.length - 1; @@ -535,9 +596,14 @@ function createEmptyAggregatedRow( avgSelfTime: 0, dmlCount: { self: 0, total: 0 }, soqlCount: { self: 0, total: 0 }, + soslCount: { self: 0, total: 0 }, dmlRowCount: { self: 0, total: 0 }, soqlRowCount: { self: 0, total: 0 }, + soslRowCount: { self: 0, total: 0 }, thrownCount: { self: 0, total: 0 }, + heapAllocated: { self: 0, total: 0 }, + governorCost: 0, + governorCostMax: 0, _children: null, instances: [], originalData: event, @@ -565,9 +631,14 @@ function createEmptyBottomUpRow( avgSelfTime: 0, dmlCount: { self: 0, total: 0 }, soqlCount: { self: 0, total: 0 }, + soslCount: { self: 0, total: 0 }, dmlRowCount: { self: 0, total: 0 }, soqlRowCount: { self: 0, total: 0 }, + soslRowCount: { self: 0, total: 0 }, thrownCount: { self: 0, total: 0 }, + heapAllocated: { self: 0, total: 0 }, + governorCost: 0, + governorCostMax: 0, _children: null, instances: [], originalData: event, diff --git a/log-viewer/src/features/call-tree/utils/GovernorCost.ts b/log-viewer/src/features/call-tree/utils/GovernorCost.ts new file mode 100644 index 00000000..f7f3a301 --- /dev/null +++ b/log-viewer/src/features/call-tree/utils/GovernorCost.ts @@ -0,0 +1,144 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ +import type { GovernorLimits, SelfTotal } from 'apex-log-parser'; + +/** + * Minimal per-node metric shape needed to derive the governor cost. Every + * call-tree row model (time-order, aggregated, bottom-up) satisfies this. + */ +export interface GovernorCostRow { + dmlCount: SelfTotal; + soqlCount: SelfTotal; + soslCount: SelfTotal; + dmlRowCount: SelfTotal; + soqlRowCount: SelfTotal; + soslRowCount: SelfTotal; + heapAllocated: SelfTotal; + /** + * Average governor consumption on this path (0–100%): the mean of every + * governor's own `used/limit × 100`, across all governors that have a reported + * limit (untouched ones count as 0%). A path that uses 50% of query rows and + * 50% of DML statements — and nothing else — with five reported governors + * scores `(50 + 50 + 0 + 0 + 0) / 5 = 20%`. + */ + governorCost: number; + /** + * The single tightest governor consumed on this path (0–100+%): the max of + * each governor's `used/limit × 100`. Flags a path near/over one specific + * limit even when its average across governors ({@link governorCost}) is low. + */ + governorCostMax: number; +} + +interface CostMetric { + label: string; + /** Reads the node's cumulative usage for this metric. */ + used: (row: GovernorCostRow) => number; + /** Reads the log's maximum for this metric. */ + limit: (limits: GovernorLimits) => number; +} + +/** + * Metrics that are attributed per call-tree node (and therefore comparable to + * their limit per path). CPU time and callouts are intentionally excluded — + * they are only tracked globally, not per node. + */ +const COST_METRICS: CostMetric[] = [ + { label: 'SOQL', used: (r) => r.soqlCount.total, limit: (l) => l.soqlQueries.limit }, + { label: 'DML', used: (r) => r.dmlCount.total, limit: (l) => l.dmlStatements.limit }, + { label: 'SOSL', used: (r) => r.soslCount.total, limit: (l) => l.soslQueries.limit }, + { label: 'SOQL Rows', used: (r) => r.soqlRowCount.total, limit: (l) => l.queryRows.limit }, + { label: 'DML Rows', used: (r) => r.dmlRowCount.total, limit: (l) => l.dmlRows.limit }, + { label: 'SOSL Rows', used: (r) => r.soslRowCount.total, limit: (l) => l.queryRows.limit }, + { label: 'Heap', used: (r) => r.heapAllocated.total, limit: (l) => l.heapSize.limit }, +]; + +/** + * Average governor consumption on this path (0–100%): the mean of each + * governor's own `used/limit × 100`, over every governor with a reported limit + * (limit > 0). Governors the path didn't touch count as 0% and still divide the + * total, so this measures overall governor utilisation across all of them, not + * the single tightest one. Governors never reported in the log (limit 0) are + * excluded from both the sum and the divisor. + */ +export function governorCost(row: GovernorCostRow, limits: GovernorLimits): number { + let total = 0; + let count = 0; + for (const metric of COST_METRICS) { + const limit = metric.limit(limits); + if (limit > 0) { + total += (metric.used(row) / limit) * 100; + count++; + } + } + return count > 0 ? total / count : 0; +} + +/** + * The single tightest governor consumed on this path (0–100+%): the max of each + * governor's `used/limit × 100`, over governors with a reported limit. The + * "am I about to breach one specific limit" signal, complementing the averaged + * {@link governorCost}. + */ +export function governorCostMax(row: GovernorCostRow, limits: GovernorLimits): number { + let max = 0; + for (const metric of COST_METRICS) { + const limit = metric.limit(limits); + if (limit > 0) { + const percent = (metric.used(row) / limit) * 100; + if (percent > max) { + max = percent; + } + } + } + return max; +} + +export interface GovernorCostMetric { + label: string; + used: number; + limit: number; + /** This metric's own `used/limit × 100` contribution to the total. */ + percent: number; +} + +/** + * Per-metric contributions to a row's governor cost — every consumed metric + * (used > 0 with a known limit), highest contribution first. Used to show the + * breakdown behind the summed Gov. Cost figure in the column tooltip. + */ +export function governorCostBreakdown( + row: GovernorCostRow, + limits: GovernorLimits, +): GovernorCostMetric[] { + const metrics: GovernorCostMetric[] = []; + for (const metric of COST_METRICS) { + const limit = metric.limit(limits); + const used = metric.used(row); + if (limit > 0 && used > 0) { + metrics.push({ label: metric.label, used, limit, percent: (used / limit) * 100 }); + } + } + return metrics.sort((a, b) => b.percent - a.percent); +} + +/** + * Populates {@link GovernorCostRow.governorCost} across a call-tree row array + * and its `_children`. Runs once after the tree is built — governor cost is a + * pure function of already-aggregated totals, so it lives in the call-tree + * layer rather than the parser. + */ +export function annotateGovernorCost( + rows: T[] | null | undefined, + limits: GovernorLimits, +): void { + if (!rows) { + return; + } + for (const row of rows) { + row.governorCost = governorCost(row, limits); + row.governorCostMax = governorCostMax(row, limits); + annotateGovernorCost(row._children, limits); + } +} diff --git a/log-viewer/src/features/call-tree/utils/TimeOrderTree.ts b/log-viewer/src/features/call-tree/utils/TimeOrderTree.ts index ce755283..296fc2ef 100644 --- a/log-viewer/src/features/call-tree/utils/TimeOrderTree.ts +++ b/log-viewer/src/features/call-tree/utils/TimeOrderTree.ts @@ -19,9 +19,16 @@ export interface TimeOrderRow { duration: SelfTotal; dmlCount: SelfTotal; soqlCount: SelfTotal; + soslCount: SelfTotal; dmlRowCount: SelfTotal; soqlRowCount: SelfTotal; + soslRowCount: SelfTotal; thrownCount: SelfTotal; + heapAllocated: SelfTotal; + /** Average governor consumption across all reported governors (0–100%). */ + governorCost: number; + /** The single tightest governor consumed on this path (0–100+%). */ + governorCostMax: number; /** * True when this row is itself a "detail" (the per-row predicate matches — * non-zero `duration.total`, `isParent`, `discontinuity`, or a type in @@ -75,9 +82,14 @@ export function toTimeOrderTree(nodes: LogEvent[]): TimeOrderRow[] | undefined { duration: event.duration, dmlCount: event.dmlCount, soqlCount: event.soqlCount, + soslCount: event.soslCount, dmlRowCount: event.dmlRowCount, soqlRowCount: event.soqlRowCount, + soslRowCount: event.soslRowCount, thrownCount: event.thrownCount, + heapAllocated: event.heapAllocated, + governorCost: 0, + governorCostMax: 0, _hasDetailsDeep: selfIsDetail || childHasDetailsDeep, }; } diff --git a/log-viewer/src/features/call-tree/utils/__tests__/Aggregation.test.ts b/log-viewer/src/features/call-tree/utils/__tests__/Aggregation.test.ts index fd5b60b3..9a9f3e83 100644 --- a/log-viewer/src/features/call-tree/utils/__tests__/Aggregation.test.ts +++ b/log-viewer/src/features/call-tree/utils/__tests__/Aggregation.test.ts @@ -16,11 +16,17 @@ type EventOptions = { dmlTotal?: number; soqlSelf?: number; soqlTotal?: number; + soslSelf?: number; + soslTotal?: number; dmlRowSelf?: number; dmlRowTotal?: number; soqlRowSelf?: number; soqlRowTotal?: number; + soslRowSelf?: number; + soslRowTotal?: number; thrown?: number; + heapSelf?: number; + heapTotal?: number; }; let nextTimestamp = 1; @@ -52,11 +58,12 @@ function createEvent(options: EventOptions): LogEvent { duration: { self: options.self, total: options.total }, dmlRowCount: { self: options.dmlRowSelf ?? 0, total: options.dmlRowTotal ?? 0 }, soqlRowCount: { self: options.soqlRowSelf ?? 0, total: options.soqlRowTotal ?? 0 }, - soslRowCount: { self: 0, total: 0 }, + soslRowCount: { self: options.soslRowSelf ?? 0, total: options.soslRowTotal ?? 0 }, dmlCount: { self: options.dmlSelf ?? 0, total: options.dmlTotal ?? 0 }, soqlCount: { self: options.soqlSelf ?? 0, total: options.soqlTotal ?? 0 }, - soslCount: { self: 0, total: 0 }, + soslCount: { self: options.soslSelf ?? 0, total: options.soslTotal ?? 0 }, thrownCount: { self: options.thrown ?? 0, total: options.thrown ?? 0 }, + heapAllocated: { self: options.heapSelf ?? 0, total: options.heapTotal ?? 0 }, exitTypes: [], } as unknown as LogEvent; @@ -1106,3 +1113,55 @@ describe('_hasDetailsDeep precomputation', () => { expect(limit._hasDetailsDeep).toBe(true); }); }); + +describe('SOSL rollup', () => { + it('aggregated: sums SOSL count and rows across instances', () => { + const root = createEvent({ text: 'LOG_ROOT', self: 0, total: 0, type: 'EXECUTION_STARTED' }); + // Two calls to the same signature; their SOSL metrics are summed on the row. + createEvent({ + text: 'Search', + self: 1, + total: 1, + parent: root, + soslSelf: 1, + soslTotal: 1, + soslRowSelf: 10, + soslRowTotal: 10, + }); + createEvent({ + text: 'Search', + self: 1, + total: 1, + parent: root, + soslSelf: 1, + soslTotal: 1, + soslRowSelf: 5, + soslRowTotal: 5, + }); + + const rows = toAggregatedCallTree(root.children); + const searchRow = findRowByText(rows, 'Search'); + expect(searchRow.soslCount.total).toBe(2); + expect(searchRow.soslRowCount.total).toBe(15); + }); + + it('bottom-up: attributes SOSL self/total to the callee bucket', () => { + const root = createEvent({ text: 'LOG_ROOT', self: 0, total: 0, type: 'EXECUTION_STARTED' }); + const caller = createEvent({ text: 'Caller', self: 0, total: 2, parent: root }); + createEvent({ + text: 'Callee', + self: 2, + total: 2, + parent: caller, + soslSelf: 1, + soslTotal: 1, + soslRowSelf: 7, + soslRowTotal: 7, + }); + + const rows = toBottomUpTree(root.children); + const callee = findRowByText(rows, 'Callee'); + expect(callee.soslCount.self).toBe(1); + expect(callee.soslRowCount.total).toBe(7); + }); +}); diff --git a/log-viewer/src/features/call-tree/utils/__tests__/GovernorCost.test.ts b/log-viewer/src/features/call-tree/utils/__tests__/GovernorCost.test.ts new file mode 100644 index 00000000..193b33e5 --- /dev/null +++ b/log-viewer/src/features/call-tree/utils/__tests__/GovernorCost.test.ts @@ -0,0 +1,134 @@ +/** + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ +import { describe, expect, it } from '@jest/globals'; +import type { GovernorLimits } from 'apex-log-parser'; + +import { + annotateGovernorCost, + governorCost, + governorCostBreakdown, + governorCostMax, + type GovernorCostRow, +} from '../GovernorCost.js'; + +function limits(overrides: Record = {}): GovernorLimits { + const metric = (limit: number) => ({ used: 0, limit }); + return { + soqlQueries: metric(overrides.soqlQueries ?? 100), + dmlStatements: metric(overrides.dmlStatements ?? 150), + soslQueries: metric(overrides.soslQueries ?? 20), + queryRows: metric(overrides.queryRows ?? 50000), + dmlRows: metric(overrides.dmlRows ?? 10000), + heapSize: metric(overrides.heapSize ?? 6000000), + } as unknown as GovernorLimits; +} + +function row(overrides: Partial> = {}): GovernorCostRow { + const st = (total: number) => ({ self: 0, total }); + return { + soqlCount: st(overrides.soql ?? 0), + dmlCount: st(overrides.dml ?? 0), + soslCount: st(overrides.sosl ?? 0), + soqlRowCount: st(overrides.soqlRows ?? 0), + dmlRowCount: st(overrides.dmlRows ?? 0), + soslRowCount: st(overrides.soslRows ?? 0), + heapAllocated: st(overrides.heap ?? 0), + governorCost: 0, + governorCostMax: 0, + }; +} + +// COST_METRICS has 7 entries; limits() reports a limit for all 7 (SOQL Rows and +// SOSL Rows both use the queryRows limit), so the divisor is 7 in these tests. +const REPORTED_GOVERNORS = 7; + +describe('governorCost', () => { + it('averages each governor as a percentage of its own limit, across all reported governors', () => { + // SOQL 50% + DML 10% + Heap 50%, four others 0% → (50 + 10 + 50) / 7 + expect(governorCost(row({ soql: 50, dml: 15, heap: 3000000 }), limits())).toBeCloseTo( + 110 / REPORTED_GOVERNORS, + 5, + ); + }); + + it('reaches 100% only when every reported governor is maxed', () => { + const maxed = row({ + soql: 100, + dml: 150, + sosl: 20, + soqlRows: 50000, + dmlRows: 10000, + soslRows: 50000, + heap: 6000000, + }); + expect(governorCost(maxed, limits())).toBeCloseTo(100, 5); + }); + + it('dilutes a single maxed governor by the untouched ones', () => { + // SOQL alone at its limit → 100 / 7 + expect(governorCost(row({ soql: 100 }), limits())).toBeCloseTo(100 / REPORTED_GOVERNORS, 5); + }); + + it('divides only by governors with a known limit', () => { + // Only heap has a limit → average over a single governor = heap's own %. + expect( + governorCost( + row({ soql: 100, heap: 600000 }), + limits({ + soqlQueries: 0, + dmlStatements: 0, + soslQueries: 0, + queryRows: 0, + dmlRows: 0, + heapSize: 6000000, + }), + ), + ).toBe(10); + }); + + it('is 0 when nothing is consumed', () => { + expect(governorCost(row(), limits())).toBe(0); + }); +}); + +describe('governorCostMax', () => { + it('returns the single tightest governor, undiluted', () => { + // SOQL 90% is the peak among SOQL 90 / DML 10 / Heap 50. + expect(governorCostMax(row({ soql: 90, dml: 15, heap: 3000000 }), limits())).toBeCloseTo(90, 5); + }); + + it('can exceed 100% when one limit is breached', () => { + expect(governorCostMax(row({ soql: 150 }), limits())).toBeCloseTo(150, 5); + }); + + it('is 0 when nothing is consumed', () => { + expect(governorCostMax(row(), limits())).toBe(0); + }); +}); + +describe('governorCostBreakdown', () => { + it('lists each consumed metric, highest contribution first', () => { + const breakdown = governorCostBreakdown(row({ soql: 90, dml: 15, heap: 3000000 }), limits()); + expect(breakdown.map((m) => m.label)).toEqual(['SOQL', 'Heap', 'DML']); + expect(breakdown[0]).toEqual({ label: 'SOQL', used: 90, limit: 100, percent: 90 }); + }); + + it('omits metrics with no usage or no known limit', () => { + const breakdown = governorCostBreakdown(row({ soql: 5, dml: 2 }), limits({ dmlStatements: 0 })); + // dml has usage but no limit; sosl/rows/heap have no usage. + expect(breakdown.map((m) => m.label)).toEqual(['SOQL']); + }); +}); + +describe('annotateGovernorCost', () => { + it('populates governorCost across the row tree', () => { + type TreeRow = GovernorCostRow & { _children: TreeRow[] | null }; + const child: TreeRow = { ...row({ dml: 150 }), _children: null }; + const parent: TreeRow = { ...row({ soql: 25 }), _children: [child] }; + annotateGovernorCost([parent], limits()); + // Each averaged over the 7 reported governors: parent SOQL 25%, child DML 100%. + expect(parent.governorCost).toBeCloseTo(25 / REPORTED_GOVERNORS, 5); + expect(child.governorCost).toBeCloseTo(100 / REPORTED_GOVERNORS, 5); + }); +}); diff --git a/log-viewer/src/features/database/components/DMLView.ts b/log-viewer/src/features/database/components/DMLView.ts index c401554d..2e5ea64d 100644 --- a/log-viewer/src/features/database/components/DMLView.ts +++ b/log-viewer/src/features/database/components/DMLView.ts @@ -12,11 +12,21 @@ import type { ApexLog, DMLBeginLine } from 'apex-log-parser'; import { vscodeMessenger } from '../../../core/messaging/VSCodeExtensionMessenger.js'; import { getCallerNamespace } from '../../../core/utility/CallerNamespace.js'; import { isVisible } from '../../../core/utility/Util.js'; +import { getSettings, updateSetting } from '../../settings/Settings.js'; import { DatabaseAccess } from '../services/Database.js'; +import { + applyColumnView, + buildColumnMenuItems, + DML_VIEWS, + getColumnView, + getTableFields, + toggleField, +} from '../../../tabulator/ColumnViews.js'; // Tabulator custom modules, imports + styles import NumberAccessor from '../../../tabulator/dataaccessor/Number.js'; import Number from '../../../tabulator/format/Number.js'; +import { progressFormatter } from '../../../tabulator/format/Progress.js'; import { GroupCalcs } from '../../../tabulator/groups/GroupCalcs.js'; import { GroupChildIndent } from '../../../tabulator/groups/GroupChildIndent.js'; import { GroupSort } from '../../../tabulator/groups/GroupSort.js'; @@ -32,9 +42,14 @@ import databaseViewStyles from './DatabaseView.scss'; // web components import '../../../components/CallStack.js'; +import '../../../components/ContextMenu.js'; +import type { ContextMenu } from '../../../components/ContextMenu.js'; import '../../../components/datagrid-filter-bar.js'; import './DatabaseSection.js'; +/** The DML column is always shown in the DML table. */ +const ALWAYS_VISIBLE = ['dml']; + const groupLabelsToFields = new Map([ ['DML', 'dml'], ['Namespace', 'namespace'], @@ -68,6 +83,14 @@ export class DMLView extends LitElement { totalMatches = 0; blockClearHighlights = true; + @state() + columnView = 'General'; + + /** Per-view column overrides (view id → visible fields); empty until edited. */ + @state() + private columnOverrides: Record = {}; + private contextMenu: ContextMenu | null = null; + constructor() { super(); @@ -81,6 +104,17 @@ export class DMLView extends LitElement { document.removeEventListener('lv-find-close', this._findEvt); } + firstUpdated(): void { + this.contextMenu = this.renderRoot.querySelector('context-menu'); + void this._loadColumnSettings(); + } + + private async _loadColumnSettings(): Promise { + const settings = await getSettings(); + this.columnOverrides = settings.database?.dml?.columnOverrides ?? {}; + this._setColumnView(settings.database?.dml?.columnView ?? 'General'); + } + updated(changedProperties: PropertyValues): void { if ( this.timelineRoot && @@ -126,6 +160,22 @@ export class DMLView extends LitElement { + + ${DML_VIEWS.map( + (view) => + html`${this.columnOverrides[view.id] ? `${view.id} •` : view.id}`, + )} + +
+ `; } + private _handleColumnViewChange(event: Event) { + const id = (event.target as HTMLInputElement).value || 'General'; + this._setColumnView(id); + updateSetting('database.dml.columnView', id); + } + + /** Effective fields for a view id: the user override, else the built-in preset. */ + private _columnViewFields(id: string): string[] | null { + return this.columnOverrides[id] ?? getColumnView(DML_VIEWS, id)?.fields ?? null; + } + + private _setColumnView(id: string) { + this.columnView = id; + if (this.dmlTable) { + applyColumnView(this.dmlTable, this._columnViewFields(id), ALWAYS_VISIBLE); + } + } + + /** Applies the active view and wires the header menu once the table is built. */ + private _initTableColumns(table: Tabulator) { + applyColumnView(table, this._columnViewFields(this.columnView), ALWAYS_VISIBLE); + const header = table.element.querySelector('.tabulator-header'); + header?.addEventListener('contextmenu', (event) => { + event.preventDefault(); + if (this.contextMenu) { + const hasOverride = !!this.columnOverrides[this.columnView]; + this.contextMenu.show( + buildColumnMenuItems(table, this.columnView, DML_VIEWS, ALWAYS_VISIBLE, hasOverride), + event.clientX, + event.clientY, + ); + } + }); + } + + private _handleColumnMenuSelect(e: CustomEvent<{ itemId: string }>) { + const { itemId } = e.detail; + const table = this.dmlTable; + if (!table) { + return; + } + if (itemId.startsWith('view:')) { + const id = itemId.slice('view:'.length); + this._setColumnView(id); + updateSetting('database.dml.columnView', id); + return; + } + if (itemId.startsWith('col:')) { + const field = itemId.slice('col:'.length); + const fields = toggleField( + this._columnViewFields(this.columnView), + field, + getTableFields(table), + ); + this.columnOverrides = { ...this.columnOverrides, [this.columnView]: fields }; + applyColumnView(table, fields, ALWAYS_VISIBLE); + updateSetting('database.dml.columnOverrides', this.columnOverrides); + return; + } + if (itemId === 'reset' && this.columnOverrides[this.columnView]) { + const { [this.columnView]: _removed, ...rest } = this.columnOverrides; + this.columnOverrides = rest; + applyColumnView(table, this._columnViewFields(this.columnView), ALWAYS_VISIBLE); + updateSetting('database.dml.columnOverrides', this.columnOverrides); + } + } + _copyToClipboard() { this.dmlTable?.copyToClipboard('all'); } @@ -267,6 +385,7 @@ export class DMLView extends LitElement { _renderDMLTable(dmlTableContainer: HTMLElement, dmlLines: DMLBeginLine[]) { const dmlData: DMLRow[] = []; + const dmlRowLimit = this.timelineRoot?.governorLimits.dmlRows.limit ?? 0; let nextRowId = 0; if (dmlLines) { for (const dml of dmlLines) { @@ -373,15 +492,31 @@ export class DMLView extends LitElement { }, headerFilterLiveFilter: false, }, + { + title: 'Namespace', + field: 'namespace', + sorter: 'string', + width: 120, + visible: false, + }, { title: 'Row Count', field: 'rowCount', sorter: 'number', cssClass: 'number-cell', width: 90, - bottomCalc: 'sum', hozAlign: 'right', headerHozAlign: 'right', + formatter: progressFormatter, + formatterParams: { precision: 0, totalValue: dmlRowLimit, showPercentageText: false }, + bottomCalc: 'sum', + bottomCalcFormatter: progressFormatter, + bottomCalcFormatterParams: { + precision: 0, + totalValue: dmlRowLimit, + showPercentageText: false, + }, + tooltip: (_e, cell) => cell.getValue() + (dmlRowLimit > 0 ? '/' + dmlRowLimit : ''), }, { title: 'Time Taken (ms)', @@ -450,6 +585,9 @@ export class DMLView extends LitElement { holder.style.overflowAnchor = 'none'; //@ts-expect-error This is a custom function added in the GroupSort custom module this.dmlTable?.setSortedGroupBy('dml'); + if (this.dmlTable) { + this._initTableColumns(this.dmlTable); + } }); this.dmlTable.on('renderComplete', () => { diff --git a/log-viewer/src/features/database/components/SOQLView.ts b/log-viewer/src/features/database/components/SOQLView.ts index 49d00df9..3a49a8ee 100644 --- a/log-viewer/src/features/database/components/SOQLView.ts +++ b/log-viewer/src/features/database/components/SOQLView.ts @@ -16,13 +16,24 @@ import { import type { ApexLog, SOQLExecuteBeginLine } from 'apex-log-parser'; import { vscodeMessenger } from '../../../core/messaging/VSCodeExtensionMessenger.js'; import { isVisible } from '../../../core/utility/Util.js'; +import { getCallerNamespace } from '../../../core/utility/CallerNamespace.js'; import { soqlGroupHeader } from '../../soql/format/groupHeader.js'; import { soqlSyntaxStyles } from '../../soql/styles/soql-syntax.css.js'; +import { getSettings, updateSetting } from '../../settings/Settings.js'; import { DatabaseAccess } from '../services/Database.js'; +import { + applyColumnView, + buildColumnMenuItems, + getColumnView, + getTableFields, + SOQL_VIEWS, + toggleField, +} from '../../../tabulator/ColumnViews.js'; // Tabulator custom modules, imports + styles import NumberAccessor from '../../../tabulator/dataaccessor/Number.js'; import Number from '../../../tabulator/format/Number.js'; +import { progressFormatter } from '../../../tabulator/format/Progress.js'; import { GroupCalcs } from '../../../tabulator/groups/GroupCalcs.js'; import { GroupChildIndent } from '../../../tabulator/groups/GroupChildIndent.js'; import { GroupSort } from '../../../tabulator/groups/GroupSort.js'; @@ -38,10 +49,15 @@ import databaseViewStyles from './DatabaseView.scss'; // web components import '../../../components/CallStack.js'; +import '../../../components/ContextMenu.js'; +import type { ContextMenu } from '../../../components/ContextMenu.js'; import '../../../components/datagrid-filter-bar.js'; import './DatabaseSOQLDetailPanel.js'; import './DatabaseSection.js'; +/** The SOQL column is always shown in the SOQL table. */ +const ALWAYS_VISIBLE = ['soql']; + @customElement('soql-view') export class SOQLView extends LitElement { @property() @@ -59,6 +75,14 @@ export class SOQLView extends LitElement { soqlTable: Tabulator | null = null; holder: HTMLElement | null = null; table: HTMLElement | null = null; + + @state() + columnView = 'General'; + + /** Per-view column overrides (view id → visible fields); empty until edited. */ + @state() + private columnOverrides: Record = {}; + private contextMenu: ContextMenu | null = null; findArgs: { text: string; count: number; options: { matchCase: boolean } } = { text: '', count: 0, @@ -85,6 +109,17 @@ export class SOQLView extends LitElement { document.removeEventListener('lv-find-close', this._findEvt); } + firstUpdated(): void { + this.contextMenu = this.renderRoot.querySelector('context-menu'); + void this._loadColumnSettings(); + } + + private async _loadColumnSettings(): Promise { + const settings = await getSettings(); + this.columnOverrides = settings.database?.soql?.columnOverrides ?? {}; + this._setColumnView(settings.database?.soql?.columnView ?? 'General'); + } + updated(changedProperties: PropertyValues): void { if ( this.timelineRoot && @@ -129,6 +164,22 @@ export class SOQLView extends LitElement { + + ${SOQL_VIEWS.map( + (view) => + html`${this.columnOverrides[view.id] ? `${view.id} •` : view.id}`, + )} + + + `; } + private _handleColumnViewChange(event: Event) { + const id = (event.target as HTMLInputElement).value || 'General'; + this._setColumnView(id); + updateSetting('database.soql.columnView', id); + } + + /** Effective fields for a view id: the user override, else the built-in preset. */ + private _columnViewFields(id: string): string[] | null { + return this.columnOverrides[id] ?? getColumnView(SOQL_VIEWS, id)?.fields ?? null; + } + + private _setColumnView(id: string) { + this.columnView = id; + if (this.soqlTable) { + applyColumnView(this.soqlTable, this._columnViewFields(id), ALWAYS_VISIBLE); + } + } + + /** Applies the active view and wires the header menu once the table is built. */ + private _initTableColumns(table: Tabulator) { + applyColumnView(table, this._columnViewFields(this.columnView), ALWAYS_VISIBLE); + const header = table.element.querySelector('.tabulator-header'); + header?.addEventListener('contextmenu', (event) => { + event.preventDefault(); + if (this.contextMenu) { + const hasOverride = !!this.columnOverrides[this.columnView]; + this.contextMenu.show( + buildColumnMenuItems(table, this.columnView, SOQL_VIEWS, ALWAYS_VISIBLE, hasOverride), + event.clientX, + event.clientY, + ); + } + }); + } + + private _handleColumnMenuSelect(e: CustomEvent<{ itemId: string }>) { + const { itemId } = e.detail; + const table = this.soqlTable; + if (!table) { + return; + } + if (itemId.startsWith('view:')) { + const id = itemId.slice('view:'.length); + this._setColumnView(id); + updateSetting('database.soql.columnView', id); + return; + } + if (itemId.startsWith('col:')) { + const field = itemId.slice('col:'.length); + const fields = toggleField( + this._columnViewFields(this.columnView), + field, + getTableFields(table), + ); + this.columnOverrides = { ...this.columnOverrides, [this.columnView]: fields }; + applyColumnView(table, fields, ALWAYS_VISIBLE); + updateSetting('database.soql.columnOverrides', this.columnOverrides); + return; + } + if (itemId === 'reset' && this.columnOverrides[this.columnView]) { + const { [this.columnView]: _removed, ...rest } = this.columnOverrides; + this.columnOverrides = rest; + applyColumnView(table, this._columnViewFields(this.columnView), ALWAYS_VISIBLE); + updateSetting('database.soql.columnOverrides', this.columnOverrides); + } + } + _copyToClipboard() { this.soqlTable?.copyToClipboard('all'); } @@ -266,6 +385,7 @@ export class SOQLView extends LitElement { _renderSOQLTable(soqlTableContainer: HTMLElement, soqlLines: SOQLExecuteBeginLine[]) { const eventIndexToSOQL = new Map(); + const queryRowLimit = this.timelineRoot?.governorLimits.queryRows.limit ?? 0; let nextRowId = 0; soqlLines?.forEach((line) => { @@ -282,9 +402,15 @@ export class SOQLView extends LitElement { relativeCost: explainLine?.relativeCost, soql: soql.text, namespace: soql.namespace, + callerNamespace: getCallerNamespace(soql), rowCount: soql.soqlRowCount.self, timeTaken: soql.duration.total, aggregations: soql.aggregations, + leadingOperationType: explainLine?.leadingOperationType ?? null, + sObjectType: explainLine?.sObjectType ?? null, + cardinality: explainLine?.cardinality ?? null, + sObjectCardinality: explainLine?.sObjectCardinality ?? null, + fields: explainLine?.fields?.join(', ') ?? null, eventIndex: soql.eventIndex, _children: [ { @@ -444,6 +570,13 @@ export class SOQLView extends LitElement { }, headerFilterLiveFilter: false, }, + { + title: 'Caller Namespace', + field: 'callerNamespace', + sorter: 'string', + width: 120, + visible: false, + }, { title: 'Row Count', field: 'rowCount', @@ -452,7 +585,16 @@ export class SOQLView extends LitElement { width: 100, hozAlign: 'right', headerHozAlign: 'right', + formatter: progressFormatter, + formatterParams: { precision: 0, totalValue: queryRowLimit, showPercentageText: false }, bottomCalc: 'sum', + bottomCalcFormatter: progressFormatter, + bottomCalcFormatterParams: { + precision: 0, + totalValue: queryRowLimit, + showPercentageText: false, + }, + tooltip: (_e, cell) => cell.getValue() + (queryRowLimit > 0 ? '/' + queryRowLimit : ''), }, { title: 'Time Taken (ms)', @@ -482,6 +624,57 @@ export class SOQLView extends LitElement { headerHozAlign: 'right', bottomCalc: 'sum', }, + { + title: 'Relative Cost', + field: 'relativeCost', + sorter: 'number', + cssClass: 'number-cell', + width: 110, + hozAlign: 'right', + headerHozAlign: 'right', + visible: false, + }, + { + title: 'Leading Operation', + field: 'leadingOperationType', + sorter: 'string', + width: 140, + visible: false, + }, + { + title: 'SObject Type', + field: 'sObjectType', + sorter: 'string', + width: 130, + visible: false, + }, + { + title: 'Cardinality', + field: 'cardinality', + sorter: 'number', + cssClass: 'number-cell', + width: 110, + hozAlign: 'right', + headerHozAlign: 'right', + visible: false, + }, + { + title: 'SObject Cardinality', + field: 'sObjectCardinality', + sorter: 'number', + cssClass: 'number-cell', + width: 140, + hozAlign: 'right', + headerHozAlign: 'right', + visible: false, + }, + { + title: 'Indexed Fields', + field: 'fields', + sorter: 'string', + width: 140, + visible: false, + }, ], rowFormatter: (row) => { const data = row.getData(); @@ -531,6 +724,9 @@ export class SOQLView extends LitElement { holder.style.overflowAnchor = 'none'; //@ts-expect-error This is a custom function added in the GroupSort custom module this.soqlTable?.setSortedGroupBy('soql'); + if (this.soqlTable) { + this._initTableColumns(this.soqlTable); + } }); this.soqlTable.on('dataSorted', () => { @@ -641,9 +837,15 @@ interface GridSOQLData { relativeCost?: number | null; soql?: string; namespace?: string; + callerNamespace?: string; rowCount?: number | null; timeTaken?: number | null; aggregations?: number; + leadingOperationType?: string | null; + sObjectType?: string | null; + cardinality?: number | null; + sObjectCardinality?: number | null; + fields?: string | null; eventIndex?: number; isDetail?: boolean; _children?: GridSOQLData[]; diff --git a/log-viewer/src/features/settings/Settings.ts b/log-viewer/src/features/settings/Settings.ts index 8d49621f..97c57779 100644 --- a/log-viewer/src/features/settings/Settings.ts +++ b/log-viewer/src/features/settings/Settings.ts @@ -31,6 +31,12 @@ export type LanaSettings = { }; callTree: { categoryColorize: boolean; + columnView: string; + columnOverrides: Record; + }; + database: { + soql: { columnView: string; columnOverrides: Record }; + dml: { columnView: string; columnOverrides: Record }; }; }; @@ -39,3 +45,8 @@ export function getSettings(): Promise { return msg; }); } + +/** Persists a `lana.*` setting via the extension (fire-and-forget). */ +export function updateSetting(section: string, value: unknown): void { + vscodeMessenger.send('updateConfig', { section, value }); +} diff --git a/log-viewer/src/tabulator/ColumnViews.ts b/log-viewer/src/tabulator/ColumnViews.ts new file mode 100644 index 00000000..fb104085 --- /dev/null +++ b/log-viewer/src/tabulator/ColumnViews.ts @@ -0,0 +1,221 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ +import type { Tabulator } from 'tabulator-tables'; + +import type { ContextMenuItem } from '../components/ContextMenu.js'; + +/** + * A preconfigured set of columns tailored to a kind of investigation. Column + * membership is keyed by Tabulator `field`. Presets list every applicable field + * name; fields absent from a given table are ignored by {@link applyColumnView}, + * so one preset works across differently-shaped tables (e.g. the three call-tree + * modes name equivalent columns differently: `duration.total` vs `totalTime`). + */ +export interface ColumnView { + /** Stable identifier, persisted in settings and shown in the UI. */ + id: string; + /** The fields visible in this view, or `null` to show every column. */ + fields: string[] | null; +} + +// Count/row limit metrics as their Total-variant fields. Self variants +// (`*.self`) exist as hidden columns and are surfaced only by the Memory view +// (heap) or a user override. +const LIMIT_COUNT_TOTALS = ['dmlCount.total', 'soqlCount.total', 'soslCount.total']; +const LIMIT_ROW_TOTALS = ['dmlRowCount.total', 'soqlRowCount.total', 'soslRowCount.total']; + +/** + * Column views for the Call Tree and Analysis tables. General is an explicit + * curated set (not `null`) so the Self columns stay hidden by default. Time + * fields are listed under both name variants (`totalTime`/`totalSelfTime` for + * aggregated & bottom-up, `duration.total`/`duration.self` for time-order) so + * one list works across all three tables. + */ +export const CALL_TREE_VIEWS: ColumnView[] = [ + { + // SOSL is omitted here (near-always-zero for most orgs); it stays in the + // Governor Limits and Database views and remains toggleable anywhere. + id: 'General', + fields: [ + 'namespace', + 'callCount', + 'dmlCount.total', + 'soqlCount.total', + 'thrownCount.total', + 'dmlRowCount.total', + 'soqlRowCount.total', + 'heapAllocated.total', + 'totalTime', + 'totalSelfTime', + 'duration.total', + 'duration.self', + 'governorCost', + ], + }, + { + id: 'Time', + fields: [ + 'namespace', + 'callCount', + 'totalTime', + 'totalSelfTime', + 'avgSelfTime', + 'duration.total', + 'duration.self', + 'governorCost', + ], + }, + { + id: 'Governor Limits', + fields: [ + 'namespace', + ...LIMIT_COUNT_TOTALS, + ...LIMIT_ROW_TOTALS, + 'thrownCount.total', + 'heapAllocated.total', + 'governorCost', + 'governorCostMax', + ], + }, + { + id: 'Database', + fields: ['namespace', ...LIMIT_COUNT_TOTALS, ...LIMIT_ROW_TOTALS, 'governorCost'], + }, + { + id: 'Memory', + fields: ['namespace', 'heapAllocated.self', 'heapAllocated.total', 'governorCost'], + }, +]; + +/** Column views for the SOQL database table. */ +export const SOQL_VIEWS: ColumnView[] = [ + { id: 'General', fields: ['isSelective', 'namespace', 'rowCount', 'timeTaken', 'aggregations'] }, + { id: 'Performance', fields: ['isSelective', 'relativeCost', 'rowCount', 'timeTaken'] }, + { + id: 'Query Plan', + fields: ['relativeCost', 'leadingOperationType', 'sObjectType', 'cardinality'], + }, +]; + +/** Column views for the DML database table. */ +export const DML_VIEWS: ColumnView[] = [ + { id: 'General', fields: ['callerNamespace', 'rowCount', 'timeTaken'] }, + { id: 'Timing', fields: ['rowCount', 'timeTaken'] }, +]; + +export function getColumnView(views: ColumnView[], id: string): ColumnView | undefined { + return views.find((view) => view.id === id); +} + +/** + * Shows/hides table columns to match `fields`. `null` shows every column + * (the General view). `alwaysVisible` fields are shown regardless. Fields that + * don't exist in this table are ignored, so one preset works across tables with + * differing column sets. + */ +export function applyColumnView( + table: Tabulator, + fields: string[] | null, + alwaysVisible: string[], +): void { + const visible = fields === null ? null : new Set([...alwaysVisible, ...fields]); + for (const column of table.getColumns()) { + const field = column.getField(); + if (!field) { + continue; + } + if (visible === null || visible.has(field)) { + column.show(); + } else { + column.hide(); + } + } + // show()/hide() alone don't re-run the fitColumns width distribution, so the + // flex (Name) column wouldn't reclaim space freed by hidden columns. A light + // redraw re-lays-out columns and re-renders the visible window. + table.redraw(); +} + +/** The field names of every column in the table, in column order. */ +export function getTableFields(table: Tabulator): string[] { + return table + .getColumns() + .map((column) => column.getField()) + .filter((field): field is string => !!field); +} + +/** The fields currently visible in the table. */ +export function getVisibleFields(table: Tabulator): string[] { + return table + .getColumns() + .filter((column) => column.isVisible() && column.getField()) + .map((column) => column.getField()); +} + +/** + * Toggles `field` within a view's effective field list, returning the new + * explicit list. A `null` list (show-all) is first materialised to every table + * field so the toggle removes exactly one column. Operating on the field list + * (not a snapshot of one table's visible columns) preserves fields absent from + * the current table but present in others — vital for the shared call-tree lens, + * since Bottom-Up lacks the DML/SOQL columns. + */ +export function toggleField( + effectiveFields: string[] | null, + field: string, + tableFields: string[], +): string[] { + const base = effectiveFields === null ? [...tableFields] : [...effectiveFields]; + const index = base.indexOf(field); + if (index >= 0) { + base.splice(index, 1); + } else { + base.push(field); + } + return base; +} + +// Leading glyphs mark checked/unchecked menu rows; the em-space keeps unchecked +// labels aligned with checked ones (ContextMenuItem has no `checked` field). +const CHECKED = '✓ '; +const UNCHECKED = ' '; + +/** + * Builds the column-header context menu: the preset views (active one ticked), + * a per-column visibility toggle for every column except the always-visible + * ones, then a Reset item (enabled only when the active view is overridden). + */ +export function buildColumnMenuItems( + table: Tabulator, + activeViewId: string, + views: ColumnView[], + alwaysVisible: string[], + hasOverride: boolean, +): ContextMenuItem[] { + const items: ContextMenuItem[] = views.map((view) => ({ + id: `view:${view.id}`, + label: `${activeViewId === view.id ? CHECKED : UNCHECKED}${view.id}`, + })); + + items.push({ id: 'view-sep', label: '', separator: true }); + + for (const column of table.getColumns()) { + const field = column.getField(); + if (!field || alwaysVisible.includes(field)) { + continue; + } + const title = String(column.getDefinition().title ?? field); + items.push({ + id: `col:${field}`, + label: `${column.isVisible() ? CHECKED : UNCHECKED}${title}`, + }); + } + + items.push( + { id: 'reset-sep', label: '', separator: true }, + { id: 'reset', label: 'Reset columns', disabled: !hasOverride }, + ); + + return items; +} diff --git a/log-viewer/src/tabulator/__tests__/ColumnViews.test.ts b/log-viewer/src/tabulator/__tests__/ColumnViews.test.ts new file mode 100644 index 00000000..029d53ff --- /dev/null +++ b/log-viewer/src/tabulator/__tests__/ColumnViews.test.ts @@ -0,0 +1,228 @@ +/** + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ +import { describe, expect, it } from '@jest/globals'; +import type { Tabulator } from 'tabulator-tables'; + +import { + applyColumnView, + buildColumnMenuItems, + CALL_TREE_VIEWS, + DML_VIEWS, + getColumnView, + getTableFields, + getVisibleFields, + SOQL_VIEWS, + toggleField, +} from '../ColumnViews.js'; + +class FakeColumn { + visible = true; + field: string; + constructor(field: string) { + this.field = field; + } + getField(): string { + return this.field; + } + isVisible(): boolean { + return this.visible; + } + show(): void { + this.visible = true; + } + hide(): void { + this.visible = false; + } + getDefinition(): { title: string } { + return { title: this.field }; + } +} + +function fakeTable(fields: string[]): { table: Tabulator; columns: FakeColumn[] } { + const columns = fields.map((f) => new FakeColumn(f)); + const table = { getColumns: () => columns, redraw: () => {} } as unknown as Tabulator; + return { table, columns }; +} + +const ALWAYS_VISIBLE = ['text']; + +const CALL_TREE_FIELDS = [ + 'text', + 'namespace', + 'callCount', + 'dmlCount.total', + 'soqlCount.total', + 'thrownCount.total', + 'dmlRowCount.total', + 'soqlRowCount.total', + 'totalTime', + 'totalSelfTime', + 'heapAllocated.total', + 'governorCost', +]; + +describe('applyColumnView', () => { + it('General (null) shows every column', () => { + const { table, columns } = fakeTable(CALL_TREE_FIELDS); + columns[3]!.hide(); + applyColumnView(table, null, ALWAYS_VISIBLE); + expect(columns.every((c) => c.isVisible())).toBe(true); + }); + + it('keeps the always-visible column shown even when not listed', () => { + const { table, columns } = fakeTable(CALL_TREE_FIELDS); + applyColumnView(table, ['soqlCount.total'], ALWAYS_VISIBLE); + expect(columns.find((c) => c.getField() === 'text')!.isVisible()).toBe(true); + }); + + it('shows only listed fields (plus always-visible) and hides the rest', () => { + const { table } = fakeTable(CALL_TREE_FIELDS); + applyColumnView( + table, + ['soqlCount.total', 'soqlRowCount.total', 'governorCost'], + ALWAYS_VISIBLE, + ); + expect(getVisibleFields(table).sort()).toEqual( + ['governorCost', 'soqlCount.total', 'soqlRowCount.total', 'text'].sort(), + ); + }); + + it('ignores preset fields absent from this table', () => { + // Bottom-up lacks the count columns; the Governor Limits preset must not error. + const { table } = fakeTable([ + 'text', + 'type', + 'totalTime', + 'heapAllocated.total', + 'governorCost', + ]); + applyColumnView( + table, + getColumnView(CALL_TREE_VIEWS, 'Governor Limits')!.fields, + ALWAYS_VISIBLE, + ); + expect(getVisibleFields(table).sort()).toEqual( + ['governorCost', 'heapAllocated.total', 'text'].sort(), + ); + }); + + it('honours a non-default always-visible field (DB tables)', () => { + const { table } = fakeTable(['soql', 'isSelective', 'rowCount', 'timeTaken']); + applyColumnView(table, ['rowCount'], ['soql']); + expect(getVisibleFields(table).sort()).toEqual(['rowCount', 'soql'].sort()); + }); +}); + +describe('toggleField', () => { + it('materialises a null (show-all) view then removes the toggled field', () => { + const result = toggleField(null, 'namespace', CALL_TREE_FIELDS); + expect(result).not.toContain('namespace'); + expect(result).toContain('text'); + expect(result.length).toBe(CALL_TREE_FIELDS.length - 1); + }); + + it('removes a field already present in the list', () => { + expect(toggleField(['a', 'b', 'c'], 'b', ['a', 'b', 'c'])).toEqual(['a', 'c']); + }); + + it('adds a field not in the list', () => { + expect(toggleField(['a'], 'b', ['a', 'b'])).toEqual(['a', 'b']); + }); + + it('preserves fields absent from the current table (shared call-tree lens)', () => { + // Editing on Bottom-Up (no DML/SOQL columns) must keep those fields in the list. + const govFields = getColumnView(CALL_TREE_VIEWS, 'Governor Limits')!.fields!; + const bottomUpFields = ['text', 'type', 'totalTime', 'governorCost']; + const result = toggleField(govFields, 'governorCost', bottomUpFields); + expect(result).toContain('dmlCount.total'); + expect(result).not.toContain('governorCost'); + }); +}); + +describe('buildColumnMenuItems', () => { + it('lists views, per-column toggles, and a Reset item', () => { + const { table } = fakeTable(['text', 'namespace', 'governorCost']); + const items = buildColumnMenuItems(table, 'General', CALL_TREE_VIEWS, ALWAYS_VISIBLE, false); + expect(items.some((i) => i.id === 'view:General')).toBe(true); + expect(items.some((i) => i.id === 'col:namespace')).toBe(true); + // Always-visible column has no toggle. + expect(items.some((i) => i.id === 'col:text')).toBe(false); + expect(items.find((i) => i.id === 'reset')?.disabled).toBe(true); + }); + + it('enables Reset only when the view is overridden', () => { + const { table } = fakeTable(['text', 'namespace']); + const items = buildColumnMenuItems(table, 'Time', CALL_TREE_VIEWS, ALWAYS_VISIBLE, true); + expect(items.find((i) => i.id === 'reset')?.disabled).toBe(false); + }); +}); + +describe('view sets', () => { + it('each view set has a General view and unique ids', () => { + for (const views of [CALL_TREE_VIEWS, SOQL_VIEWS, DML_VIEWS]) { + expect(getColumnView(views, 'General')).toBeDefined(); + const ids = views.map((v) => v.id); + expect(new Set(ids).size).toBe(ids.length); + } + }); + + it('General is an explicit curated list so Self columns stay hidden', () => { + const general = getColumnView(CALL_TREE_VIEWS, 'General')!.fields!; + expect(general).not.toBeNull(); + // Totals are shown; Self variants are not part of any default view. + expect(general).toContain('dmlCount.total'); + expect(general).toContain('soqlCount.total'); + expect(general.some((f) => f.endsWith('.self') && f !== 'duration.self')).toBe(false); + }); + + it('General shows Heap but omits the near-always-zero SOSL columns', () => { + const general = getColumnView(CALL_TREE_VIEWS, 'General')!.fields!; + expect(general).toContain('heapAllocated.total'); + expect(general).not.toContain('soslCount.total'); + expect(general).not.toContain('soslRowCount.total'); + }); + + it('Database and Governor Limits merge DML, SOQL and SOSL count + row totals', () => { + const soslTotals = ['soslCount.total', 'soslRowCount.total']; + for (const viewId of ['Database', 'Governor Limits']) { + const view = getColumnView(CALL_TREE_VIEWS, viewId)!.fields!; + for (const f of [ + 'dmlCount.total', + 'soqlCount.total', + 'dmlRowCount.total', + 'soqlRowCount.total', + ...soslTotals, + ]) { + expect(view).toContain(f); + } + } + }); + + it('Memory view shows heap self and total', () => { + const memory = getColumnView(CALL_TREE_VIEWS, 'Memory')!.fields!; + expect(memory).toContain('heapAllocated.self'); + expect(memory).toContain('heapAllocated.total'); + }); + + it('SOQL Query Plan view exposes the explain-plan columns', () => { + const plan = getColumnView(SOQL_VIEWS, 'Query Plan')!.fields!; + expect(plan).toEqual( + expect.arrayContaining([ + 'relativeCost', + 'leadingOperationType', + 'sObjectType', + 'cardinality', + ]), + ); + }); + + it('SOQL views are General/Performance/Query Plan (no redundant Rows & Time)', () => { + expect(SOQL_VIEWS.map((v) => v.id)).toEqual(['General', 'Performance', 'Query Plan']); + }); + + it('getTableFields returns every column field in order', () => { + const { table } = fakeTable(['text', 'namespace', 'governorCost']); + expect(getTableFields(table)).toEqual(['text', 'namespace', 'governorCost']); + }); +}); diff --git a/log-viewer/src/tabulator/module/CommonModules.ts b/log-viewer/src/tabulator/module/CommonModules.ts index 974c1003..b935d244 100644 --- a/log-viewer/src/tabulator/module/CommonModules.ts +++ b/log-viewer/src/tabulator/module/CommonModules.ts @@ -8,6 +8,7 @@ export { ExportModule, FilterModule, FormatModule, + FrozenColumnsModule, GroupRowsModule, InteractionModule, KeybindingsModule, diff --git a/log-viewer/src/tabulator/renderer/VirtualVerticalRenderer.ts b/log-viewer/src/tabulator/renderer/VirtualVerticalRenderer.ts index 4b6a0f5e..e7d01b8b 100644 --- a/log-viewer/src/tabulator/renderer/VirtualVerticalRenderer.ts +++ b/log-viewer/src/tabulator/renderer/VirtualVerticalRenderer.ts @@ -75,6 +75,10 @@ interface RendererBase { // overwrites it with the string 'virtual' (stock bug workaround). renderMode?: string; }; + // Total width of all visible columns; used to size the table box so + // horizontally-overflowing rows span the full width (frozen sticky cells + // need a full-width containing block). + columnManager: { getWidth: () => number }; options: RendererOptions; }; elementVertical: HTMLElement; @@ -228,6 +232,10 @@ export class VirtualVerticalRenderer extends Renderer { // Pending rIC handle for the pre-warmer; cancelled on destroy/clearRows. private idlePrewarmHandle: number | null = null; + // Last column-total width written to tableElement.minWidth; skips redundant + // per-frame style writes (see _syncTableWidth). + private lastTableMinWidth = -1; + // Last scrollLeft piped to scrollHorizontal (which writes DOM + dispatches // unconditionally — pipe only on change). NaN → first frame always pipes. private lastPipedScrollLeft = NaN; @@ -369,6 +377,21 @@ export class VirtualVerticalRenderer extends Renderer { this._self().table.rowManager.scrollHorizontal(left); } + /** + * Size the table box to the full column width. Virtual rows are viewport- + * width with cells overflowing; without this the sticky containing block is + * only viewport-wide and frozen body cells scroll away. Stock renderers get + * full-width rows for free; the virtual renderer must set it explicitly. + */ + private _syncTableWidth(): void { + const self = this._self(); + const width = self.table.columnManager.getWidth(); + if (width !== this.lastTableMinWidth) { + this.lastTableMinWidth = width; + self.tableElement.style.minWidth = `${width}px`; + } + } + /** RowManager lifecycle: holder scroll event (reads live scrollTop). */ scrollRows(_top: number, _dir: boolean): void { const holder = this._self().elementVertical; @@ -786,6 +809,7 @@ export class VirtualVerticalRenderer extends Renderer { this._detachAllRendered(); self.tableElement.style.paddingTop = '0'; self.tableElement.style.paddingBottom = '0'; + this._syncTableWidth(); // Stock dispatches after every fill, including empty ones — GroupRows // relies on it to set minWidth when no data rows are visible. self.dispatch('render-virtual-fill'); @@ -887,6 +911,7 @@ export class VirtualVerticalRenderer extends Renderer { newBottom === lastIdx ? 0 : Math.max(0, this._totalHeight() - this._cumHeight(newBottom + 1)); self.tableElement.style.paddingTop = `${paddingTop}px`; self.tableElement.style.paddingBottom = `${paddingBottom}px`; + this._syncTableWidth(); this.vDomTop = newTop; this.vDomBottom = newBottom; diff --git a/log-viewer/src/tabulator/renderer/__tests__/VirtualVerticalRenderer.test.ts b/log-viewer/src/tabulator/renderer/__tests__/VirtualVerticalRenderer.test.ts index dc207889..810214bb 100644 --- a/log-viewer/src/tabulator/renderer/__tests__/VirtualVerticalRenderer.test.ts +++ b/log-viewer/src/tabulator/renderer/__tests__/VirtualVerticalRenderer.test.ts @@ -16,7 +16,7 @@ function makeMockTable(): unknown { getDisplayRows: () => [], scrollHorizontal: () => {}, }, - columnManager: { element: {} }, + columnManager: { element: {}, getWidth: () => 200 }, options: {}, eventBus: { _events: {}, dispatch: () => {} }, }; @@ -356,7 +356,7 @@ function makeRendererWithRows(rows: RowStub[]): RendererForSetAnchor { getDisplayRows: () => rows, scrollHorizontal: () => {}, }, - columnManager: { element: {} }, + columnManager: { element: {}, getWidth: () => 200 }, options: {}, eventBus: { _events: {}, dispatch: () => {} }, }; @@ -550,7 +550,7 @@ function makeRerenderRenderer(initialRows: RowStub[]): { scrollHorizontal: () => {}, tableEmpty, }, - columnManager: { element: {} }, + columnManager: { element: {}, getWidth: () => 200 }, options: {}, eventBus: { _events: {}, dispatch: () => {} }, }; @@ -759,7 +759,7 @@ describe('VirtualVerticalRenderer.initialize stock-bug workaround', () => { }; const table = { rowManager, - columnManager: { element: {} }, + columnManager: { element: {}, getWidth: () => 200 }, options: {}, eventBus: { _events: {}, dispatch: () => {} }, }; diff --git a/log-viewer/src/tabulator/renderer/__tests__/VirtualVerticalRendererAttach.test.ts b/log-viewer/src/tabulator/renderer/__tests__/VirtualVerticalRendererAttach.test.ts index 6eb32dfc..4a35421c 100644 --- a/log-viewer/src/tabulator/renderer/__tests__/VirtualVerticalRendererAttach.test.ts +++ b/log-viewer/src/tabulator/renderer/__tests__/VirtualVerticalRendererAttach.test.ts @@ -68,7 +68,7 @@ function makeAttachSetup(rowCount: number): { getDisplayRows: () => rows, scrollHorizontal: () => {}, }, - columnManager: { element: {} }, + columnManager: { element: {}, getWidth: () => 200 }, options: {}, eventBus: { _events: {}, dispatch: () => {} }, }; From d995fc950808bbf0dac78a8bb49a31f4a9d934ea Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Mon, 20 Jul 2026 16:24:43 +0100 Subject: [PATCH 2/6] feat(log-viewer): refine column views UX, persistence and grid fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move per-view column overrides to globalState (columnView stays a discoverable setting), routed through the LogView getConfig/updateConfig handler - Add inline per-view reset + live edited marker in the view dropdown and header menu, a Columns toolbar button that opens the menu, and keep the menu open for multi-toggle - Relabel governor columns Gov. Cost → Gov. Avg and Peak Limit → Gov. Peak so their avg-vs-max relationship reads clearly - Fix frozen-column bleed-through and restore the body horizontal scrollbar in DataGrid.scss; disable the sticky Name column pending a horizontal/vertical renderer fix --- CHANGELOG.md | 4 +- README.md | 7 +- lana-docs/docs/docs/features/analysis.md | 4 + lana-docs/docs/docs/features/calltree.mdx | 12 ++ lana-docs/docs/docs/features/database.md | 4 + lana/package.json | 39 ------ lana/src/commands/LogView.ts | 21 ++- lana/src/workspace/AppConfig.ts | 30 +++- .../src/workspace/__tests__/AppConfig.test.ts | 49 +++++++ log-viewer/src/components/ContextMenu.ts | 52 ++++++- log-viewer/src/components/VsSelect.ts | 96 ++++++++++++- .../src/components/datagrid-filter-bar.ts | 97 +++++++++---- .../analysis/components/AnalysisView.ts | 126 +++++++++++------ .../call-tree/components/AggregatedTable.ts | 13 +- .../call-tree/components/BottomUpTable.ts | 13 +- .../call-tree/components/CalltreeView.ts | 128 ++++++++++++------ .../call-tree/components/TableShared.ts | 14 +- .../call-tree/components/TimeOrderTable.ts | 13 +- .../features/database/components/DMLView.ts | 87 +++++++++--- .../features/database/components/SOQLView.ts | 124 ++++++++++++----- log-viewer/src/tabulator/ColumnViews.ts | 18 +-- .../tabulator/__tests__/ColumnViews.test.ts | 14 +- .../renderer/VirtualVerticalRenderer.ts | 35 ++--- log-viewer/src/tabulator/style/DataGrid.scss | 24 +++- 24 files changed, 755 insertions(+), 269 deletions(-) create mode 100644 lana/src/workspace/__tests__/AppConfig.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b474b3d..68a394fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - 🔴 **Timeline exception markers**: exceptions show as red lines, with a **Throws** count in method tooltips. ([#828]) -- 🧩 **Column views**: switch the Call Tree, Analysis and Database (SOQL/DML) tables between preset column sets (`General`, `Time`, `Governor Limits`, `Database`, `Memory`), show/hide individual columns from the header right-click menu, and **Reset** a view to its defaults. Choices persist per view. ([#298]) +- 🧩 **Column views**: switch the Call Tree, Analysis and Database (SOQL/DML) tables between preset column sets (`General`, `Time`, `Governor Limits`, `Database`, `Memory`) + - Show/hide individual columns from the **Columns** toolbar button or the header right-click menu; inline **reset** to restore defaults. Choices persist per view. ([#298]) - 📊 New Call Tree columns: **SOSL Count/Rows**, **Heap Allocated** (+ Self), **Avg Self Time**, and optional **Self** variants for every governor metric — surfaced through the `Governor Limits`, `Database` and `Memory` views. ([#298]) - 🔎 **Database tables**: Row Count renders as a **% bar** against the governor limit, and SOQL gains a **Query Plan** view (Relative Cost, Leading Operation, SObject Type, Cardinality). ([#298]) @@ -509,6 +510,7 @@ Skipped due to adopting odd numbering for pre releases and even number for relea [#848]: https://github.com/certinia/debug-log-analyzer/issues/848 [#827]: https://github.com/certinia/debug-log-analyzer/issues/827 [#298]: https://github.com/certinia/debug-log-analyzer/issues/298 +[#32]: https://github.com/certinia/debug-log-analyzer/issues/32 diff --git a/README.md b/README.md index 84a5657c..746faa9b 100644 --- a/README.md +++ b/README.md @@ -99,8 +99,9 @@ Also: Frame Selection & Navigation, Dynamic Frame Labels, Adaptive Frame Detail, Explore nested method calls with performance metrics: -- **Metrics**: Self Time, Total Time, SOQL/DML/Thrown Counts, SOQL/DML/Rows -- **Views**: Use Time Order for sequence, Aggregated for repeated hot paths, Bottom-Up for caller attribution +- **Metrics**: Self Time, Total Time, SOQL/DML/SOSL Counts + Rows, Heap, Governor Limit Avg + Peak, Thrown +- **Call Tree Views**: Use Time Order for sequence, Aggregated for repeated hot paths, Bottom-Up for caller attribution +- **Column Views** – Switch preset column sets (General, Time, Governor Limits, Database, Memory), show/hide columns from the header menu, reset to defaults - **Group Bottom-Up by Namespace or Type** - **Filter by Namespace, Type or Duration** - **Toggle Debug-Only + Detail Events** @@ -114,6 +115,7 @@ Explore nested method calls with performance metrics: See which methods are the slowest, most frequent. or expensive. - **Group by Type, Namespace, or Caller Namespace ** +- **Column Views** – Preset column sets, show/hide columns, reset to defaults - **Sort by Duration, Count, Name, Type or Namespace** - **Filter to specific event types** - **Copy or Export to CSV** @@ -126,6 +128,7 @@ Highlight slow Salesforce SOQL queries, non-selective filters, and DML issues. - **SOQL + DML Duration, Selectivity, Aggregates, Row Count** - **Group by Namespace, Caller Namespace or Query** +- **Column Views** – Preset column sets (incl. a SOQL Query Plan view), show/hide columns, reset to defaults - **View the Call Stack** - **SOQL Optimization Tips** - **Sort by SOQL or DML, Duration, Selectivity, Aggregates, Row Count** diff --git a/lana-docs/docs/docs/features/analysis.md b/lana-docs/docs/docs/features/analysis.md index 047cc842..00dd7c83 100644 --- a/lana-docs/docs/docs/features/analysis.md +++ b/lana-docs/docs/docs/features/analysis.md @@ -31,6 +31,10 @@ Each column can be sorted by clicking the column header, this will sort the rows 1. Show Log events for specific namespaces using the namespace column filter +### Column Views + +The same presets as the Call Tree (General, Time, Governor Limits, Database, Memory), from the **Columns** button in the toolbar (or the header right-click menu). Show or hide individual columns there; an edited view shows a **reset** icon. Choices persist per view. + ### Group The rows can be grouped by Type, Namespace, or Caller Namespace. diff --git a/lana-docs/docs/docs/features/calltree.mdx b/lana-docs/docs/docs/features/calltree.mdx index 98c6b511..226b2d6b 100644 --- a/lana-docs/docs/docs/features/calltree.mdx +++ b/lana-docs/docs/docs/features/calltree.mdx @@ -50,6 +50,18 @@ Clicking the link in the event column will open the corresponding file and line, Each column can be sorted by clicking the column header, this will sort the rows within the tree structure e.g sorting by self time will sort the children within a parent with the largest self time to the top but only within that parent. +### Column Views + +Switch column sets from the **Columns** button in the toolbar (or the header right-click menu): + +1. **General** – everyday overview. +1. **Time** – call count and self/total/average time. +1. **Governor Limits** – DML/SOQL/SOSL counts + rows, throws, heap, and governor limit usage (avg + peak). +1. **Database** – DML/SOQL/SOSL counts + rows. +1. **Memory** – heap self and total. + +Show or hide individual columns from the same menu; an edited view shows a **reset** icon. Choices persist per view. + ### Filtering 1. Details (events with 0 time) are hidden by default but can be shown/ hidden. diff --git a/lana-docs/docs/docs/features/database.md b/lana-docs/docs/docs/features/database.md index 0b879219..6d953bc0 100644 --- a/lana-docs/docs/docs/features/database.md +++ b/lana-docs/docs/docs/features/database.md @@ -37,6 +37,10 @@ If the grouping is removed the sorting applies the same but across all rows inst 1. In the SOQL view show Log events for specific namespaces using the namespace column filter +### Column Views + +Switch column sets from the **Columns** button in the toolbar (or the header right-click menu). SOQL offers **General**, **Performance** and **Query Plan** (Relative Cost, Leading Operation, SObject Type, Cardinality); DML offers **General** and **Timing**. Show or hide individual columns there; an edited view shows a **reset** icon. Choices persist per table. + ### Group By default rows are grouped by the SOQL/ DML text, grouping can be removed and the rows shows as a flat list using the _Group by_ item in the header menu. The groups are default sorted with the groups with the most items at the top. diff --git a/lana/package.json b/lana/package.json index 99a5444e..9a2ae2d6 100644 --- a/lana/package.json +++ b/lana/package.json @@ -152,19 +152,6 @@ ], "markdownDescription": "The column view applied to the Call Tree and Analysis tables. Default: `General`", "order": 1 - }, - "lana.callTree.columnOverrides": { - "title": "Column view overrides", - "type": "object", - "default": {}, - "additionalProperties": { - "type": "array", - "items": { - "type": "string" - } - }, - "markdownDescription": "Per-view column customisations for the Call Tree and Analysis tables (view id → visible column fields). Set automatically when you show/hide columns from a table's header menu; reset a view from that menu to clear its entry.", - "order": 2 } } }, @@ -372,19 +359,6 @@ "markdownDescription": "The column view applied to the Database SOQL table. Default: `General`", "order": 0 }, - "lana.database.soql.columnOverrides": { - "title": "SOQL column view overrides", - "type": "object", - "default": {}, - "additionalProperties": { - "type": "array", - "items": { - "type": "string" - } - }, - "markdownDescription": "Per-view column customisations for the Database SOQL table (view id → visible column fields). Set automatically when you show/hide columns from the table's header menu.", - "order": 1 - }, "lana.database.dml.columnView": { "title": "DML column view", "type": "string", @@ -395,19 +369,6 @@ ], "markdownDescription": "The column view applied to the Database DML table. Default: `General`", "order": 2 - }, - "lana.database.dml.columnOverrides": { - "title": "DML column view overrides", - "type": "object", - "default": {}, - "additionalProperties": { - "type": "array", - "items": { - "type": "string" - } - }, - "markdownDescription": "Per-view column customisations for the Database DML table (view id → visible column fields). Set automatically when you show/hide columns from the table's header menu.", - "order": 3 } } } diff --git a/lana/src/commands/LogView.ts b/lana/src/commands/LogView.ts index 44674767..0d3afc20 100644 --- a/lana/src/commands/LogView.ts +++ b/lana/src/commands/LogView.ts @@ -11,7 +11,13 @@ import type { Context } from '../Context.js'; import { OpenFileInPackage } from '../display/OpenFileInPackage.js'; import { WebView } from '../display/WebView.js'; import { RawLogNavigation } from '../log-features/RawLogNavigation.js'; -import { getConfig, updateConfig } from '../workspace/AppConfig.js'; +import { + COLUMN_OVERRIDE_SECTIONS, + getColumnOverrides, + getConfig, + updateColumnOverride, + updateConfig, +} from '../workspace/AppConfig.js'; interface WebViewLogFileRequest { requestId: string; @@ -102,10 +108,15 @@ export class LogView { } case 'getConfig': { + const config = getConfig(); + const overrides = getColumnOverrides(context.context.globalState); + config.callTree.columnOverrides = overrides['callTree.columnOverrides'] ?? {}; + config.database.soql.columnOverrides = overrides['database.soql.columnOverrides'] ?? {}; + config.database.dml.columnOverrides = overrides['database.dml.columnOverrides'] ?? {}; panel.webview.postMessage({ requestId, cmd: 'getConfig', - payload: getConfig(), + payload: config, }); break; } @@ -113,7 +124,11 @@ export class LogView { case 'updateConfig': { const { section, value } = payload as { section: string; value: unknown }; if (section) { - updateConfig(section, value); + if ((COLUMN_OVERRIDE_SECTIONS as readonly string[]).includes(section)) { + updateColumnOverride(context.context.globalState, section, value); + } else { + updateConfig(section, value); + } } break; } diff --git a/lana/src/workspace/AppConfig.ts b/lana/src/workspace/AppConfig.ts index 55eda661..1c353ff3 100644 --- a/lana/src/workspace/AppConfig.ts +++ b/lana/src/workspace/AppConfig.ts @@ -2,7 +2,7 @@ * Copyright (c) 2020 Certinia Inc. All rights reserved. */ -import { ConfigurationTarget, workspace } from 'vscode'; +import { ConfigurationTarget, workspace, type Memento } from 'vscode'; interface Config { timeline: { @@ -66,3 +66,31 @@ export function updateConfig(section: string, value: unknown): Thenable { const config = workspace.getConfiguration('lana'); return config.update(section, value, ConfigurationTarget.Global); } + +/** + * Column overrides are opaque per-view field maps — private UI state, not user + * preferences — so they persist in globalState rather than editable settings. + */ +export const COLUMN_OVERRIDE_SECTIONS = [ + 'callTree.columnOverrides', + 'database.soql.columnOverrides', + 'database.dml.columnOverrides', +] as const; + +type ColumnOverrides = Record; + +export function getColumnOverrides(globalState: Memento): Record { + const overrides: Record = {}; + for (const section of COLUMN_OVERRIDE_SECTIONS) { + overrides[section] = globalState.get(section, {}); + } + return overrides; +} + +export function updateColumnOverride( + globalState: Memento, + section: string, + value: unknown, +): Thenable { + return globalState.update(section, value); +} diff --git a/lana/src/workspace/__tests__/AppConfig.test.ts b/lana/src/workspace/__tests__/AppConfig.test.ts new file mode 100644 index 00000000..562987b6 --- /dev/null +++ b/lana/src/workspace/__tests__/AppConfig.test.ts @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ +import { describe, expect, it, jest } from '@jest/globals'; +import type { Memento } from 'vscode'; + +import { + COLUMN_OVERRIDE_SECTIONS, + getColumnOverrides, + updateColumnOverride, +} from '../AppConfig.js'; + +function mockMemento(store: Record = {}): Memento { + return { + keys: jest.fn(() => Object.keys(store)), + get: jest.fn((key: string, fallback?: unknown) => + key in store ? store[key] : fallback, + ) as Memento['get'], + update: jest.fn(() => Promise.resolve()), + } as unknown as Memento; +} + +describe('AppConfig column overrides', () => { + describe('getColumnOverrides', () => { + it('reads each override section, defaulting to {}', () => { + const globalState = mockMemento({ + 'callTree.columnOverrides': { Time: ['a', 'b'] }, + }); + + const overrides = getColumnOverrides(globalState); + + expect(overrides['callTree.columnOverrides']).toEqual({ Time: ['a', 'b'] }); + expect(overrides['database.soql.columnOverrides']).toEqual({}); + expect(overrides['database.dml.columnOverrides']).toEqual({}); + expect(globalState.get).toHaveBeenCalledTimes(COLUMN_OVERRIDE_SECTIONS.length); + }); + }); + + describe('updateColumnOverride', () => { + it('writes only to globalState', () => { + const globalState = mockMemento(); + const value = { Time: ['a'] }; + + updateColumnOverride(globalState, 'callTree.columnOverrides', value); + + expect(globalState.update).toHaveBeenCalledWith('callTree.columnOverrides', value); + }); + }); +}); diff --git a/log-viewer/src/components/ContextMenu.ts b/log-viewer/src/components/ContextMenu.ts index 24fab984..a465bab5 100644 --- a/log-viewer/src/components/ContextMenu.ts +++ b/log-viewer/src/components/ContextMenu.ts @@ -26,6 +26,8 @@ * ``` */ +import '#vscode-elements/vscode-icon.js'; + import { LitElement, css, html, nothing } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; @@ -40,6 +42,13 @@ export interface ContextMenuItem { separator?: boolean; /** If true, the item is grayed out and not clickable */ disabled?: boolean; + /** If true, selecting the item (or its action) leaves the menu open (multi-toggle). */ + keepOpen?: boolean; + /** + * Optional trailing action icon (e.g. per-row reset). Clicking it emits + * `menu-select` with `action.id` instead of the row's own id. + */ + action?: { id: string; icon: string; title: string }; } /** @@ -105,6 +114,15 @@ export class ContextMenu extends LitElement { font-size: 12px; } + .item-action { + margin-left: 12px; + opacity: 0.7; + } + + .item-action:hover { + opacity: 1; + } + .separator { height: 1px; margin: 6px 12px; @@ -206,8 +224,26 @@ export class ContextMenu extends LitElement { composed: true, }), ); - this.hide(); - this.dispatchEvent(new CustomEvent('menu-close', { bubbles: true, composed: true })); + if (!item.keepOpen) { + this.hide(); + this.dispatchEvent(new CustomEvent('menu-close', { bubbles: true, composed: true })); + } + } + + private handleActionClick(event: Event, actionId: string, keepOpen?: boolean): void { + // Keep the click from triggering the row's own select. + event.stopPropagation(); + this.dispatchEvent( + new CustomEvent('menu-select', { + detail: { itemId: actionId }, + bubbles: true, + composed: true, + }), + ); + if (!keepOpen) { + this.hide(); + this.dispatchEvent(new CustomEvent('menu-close', { bubbles: true, composed: true })); + } } private adjustPosition(): void { @@ -257,6 +293,18 @@ export class ContextMenu extends LitElement { > ${item.label} ${item.shortcut ? html`${item.shortcut}` : nothing} + ${ + item.action + ? html`` + : nothing + } `; } diff --git a/log-viewer/src/components/VsSelect.ts b/log-viewer/src/components/VsSelect.ts index 56dc5e11..18061d1e 100644 --- a/log-viewer/src/components/VsSelect.ts +++ b/log-viewer/src/components/VsSelect.ts @@ -1,11 +1,14 @@ /* * Copyright (c) 2026 Certinia Inc. All rights reserved. */ +import '#vscode-elements/vscode-icon.js'; + import { VscodeSingleSelect } from '#vscode-elements/vscode-single-select.js'; -import { css, html, type TemplateResult } from 'lit'; +import { css, html, nothing, type TemplateResult } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; import { ifDefined } from 'lit/directives/if-defined.js'; +import { repeat } from 'lit/directives/repeat.js'; import { selectFaceText } from './selectFaceText.js'; @@ -85,6 +88,16 @@ export const selectSizingStyles = css` .combobox-face .face-prefix { padding-left: 4px; } + + /* Rows carrying a reset affordance lay the icon out at the trailing edge. */ + .option.resettable-option { + display: flex; + align-items: center; + } + + .option-reset { + margin-left: auto; + } `; /** vscode-single-select where the control fits the selected value and the popup its widest option. */ @@ -100,6 +113,13 @@ export class VsSelect extends VscodeSingleSelect { @property({ type: String }) emptyValue = 'None'; + /** + * Option values shown as "edited": each gets a trailing reset icon (and a `•` + * marker on both the row and the face). Empty → default select behaviour. + */ + @property({ attribute: false }) + resettableValues: string[] = []; + private _faceContent(): TemplateResult { const { prefixText, valueText } = selectFaceText({ prefix: this.prefix, @@ -107,12 +127,84 @@ export class VsSelect extends VscodeSingleSelect { value: this.value, emptyValue: this.emptyValue, }); + const marker = this.resettableValues.includes(this.value) ? ' •' : ''; return html`${prefixText ? html`${prefixText}` : ''}${valueText}${valueText}${marker}`; } + private _onResetOption(event: Event, value: string): void { + // Keep the click from selecting/closing the row it lives in. + event.stopPropagation(); + this.dispatchEvent( + new CustomEvent('vs-reset-option', { detail: { value }, bubbles: true, composed: true }), + ); + } + + protected override _renderOptions(): TemplateResult | TemplateResult[] { + if (this.resettableValues.length === 0) { + return super._renderOptions(); + } + + return html` +
    + ${repeat( + this._opts.options, + (op) => op.index, + (op, index) => { + if (!op.visible) { + return nothing; + } + const active = op.index === this._opts.activeIndex && !op.disabled; + const selected = this._opts.getIsIndexSelected(op.index); + const edited = this.resettableValues.includes(op.value); + const optionClasses = { + active, + disabled: op.disabled, + option: true, + 'single-select': true, + 'resettable-option': edited, + selected, + }; + return html` +
  • + ${op.label}${edited ? ' •' : ''} + ${ + edited + ? html` this._onResetOption(event, op.value)} + >` + : nothing + } +
  • + `; + }, + )} +
+ `; + } + protected override _renderSelectFace(): TemplateResult { const activeDescendant = this._opts.activeIndex > -1 ? `op-${this._opts.activeIndex}` : ''; const { active } = selectFaceText({ diff --git a/log-viewer/src/components/datagrid-filter-bar.ts b/log-viewer/src/components/datagrid-filter-bar.ts index a0bf8856..9e7908d8 100644 --- a/log-viewer/src/components/datagrid-filter-bar.ts +++ b/log-viewer/src/components/datagrid-filter-bar.ts @@ -1,12 +1,22 @@ /* * Copyright (c) 2021 Certinia Inc. All rights reserved. */ -import { LitElement, css, html } from 'lit'; +import { LitElement, css, html, nothing } from 'lit'; import { customElement, state } from 'lit/decorators.js'; // styles import { globalStyles } from '../styles/global.styles.js'; +// components +import './Divider.js'; + +/** + * Shared grid toolbar. Content is grouped into sections, left→right: + * global · table-actions · filters (left cluster) + * group · actions (right cluster, right-aligned) + * A vertical divider is drawn between adjacent non-empty sections; the flexible + * gap separates the left cluster from the right-aligned grouping/actions. + */ @customElement('datagrid-filter-bar') export class DatagridFilterBar extends LitElement { static styles = [ @@ -22,54 +32,89 @@ export class DatagridFilterBar extends LitElement { .filter-bar { display: flex; - .filter-bar__filters { - display: flex; - align-items: flex-end; - } + align-items: flex-end; + gap: 8px; + width: 100%; } - .filter-bar .filter-bar__actions--right { - align-items: flex-end; + .filter-bar__left, + .filter-bar__right { display: flex; - flex: 1 1 auto; + align-items: flex-end; gap: 8px; + } + + /* Right cluster consumes the slack and right-aligns grouping + actions. */ + .filter-bar__right { + flex: 1 1 auto; justify-content: flex-end; } - .filter-bar__group { - align-items: flex-end; + .section { display: flex; + align-items: flex-end; + gap: 4px; + } + + .section[hidden] { + display: none; } - /* Divider between the grouping control and the action buttons. */ - .filter-bar__group.has-group::after { + divider-line { align-self: stretch; - border-right: 1px solid var(--vscode-widget-border, transparent); - content: ''; - margin: 2px 8px; + margin: 2px 0; } `, ]; - @state() - private _hasGroup = false; + @state() private _hasGlobal = false; + @state() private _hasTableActions = false; + @state() private _hasFilters = false; + @state() private _hasGroup = false; + @state() private _hasActions = false; + + private _slotChange( + prop: '_hasGlobal' | '_hasTableActions' | '_hasFilters' | '_hasGroup' | '_hasActions', + ) { + return (event: Event) => { + this[prop] = (event.target as HTMLSlotElement).assignedElements().length > 0; + }; + } - private _onGroupSlotChange(event: Event) { - this._hasGroup = (event.target as HTMLSlotElement).assignedElements().length > 0; + private _divider() { + return html``; } render() { + // Dividers sit between adjacent non-empty sections within each cluster. + const tableActionsDivider = this._hasGlobal && this._hasTableActions; + const filtersDivider = this._hasFilters && (this._hasGlobal || this._hasTableActions); + const actionsDivider = this._hasGroup && this._hasActions; + + // Empty sections are hidden so they don't add stray gaps. return html`
-
- - +
+
+ +
+ ${tableActionsDivider ? this._divider() : nothing} +
+ +
+ ${filtersDivider ? this._divider() : nothing} +
+ +
-
-
- +
+
+ +
+ ${actionsDivider ? this._divider() : nothing} +
+
-
`; } diff --git a/log-viewer/src/features/analysis/components/AnalysisView.ts b/log-viewer/src/features/analysis/components/AnalysisView.ts index 349d121d..cdab1b0d 100644 --- a/log-viewer/src/features/analysis/components/AnalysisView.ts +++ b/log-viewer/src/features/analysis/components/AnalysisView.ts @@ -171,7 +171,7 @@ export class AnalysisView extends LitElement { return html`
-
+
Collapse + + ${repeat( + CALL_TREE_VIEWS, + (view) => view.id, + (view) => + html`${view.id}`, + )} + +
+ +
Details
@@ -203,25 +224,13 @@ export class AnalysisView extends LitElement { Type - - ${repeat( - CALL_TREE_VIEWS, - (view) => view.id, - (view) => - html`${this.columnOverrides[view.id] ? `${view.id} •` : view.id}`, - )} - -
+ ('.tabulator-header'); header?.addEventListener('contextmenu', (event) => { event.preventDefault(); - if (this.contextMenu) { - const hasOverride = !!this.columnOverrides[this.columnView]; - this.contextMenu.show( - buildColumnMenuItems( - table, - this.columnView, - CALL_TREE_VIEWS, - ALWAYS_VISIBLE, - hasOverride, - ), - event.clientX, - event.clientY, - ); - } + this._showColumnMenu(event.clientX, event.clientY); }); } + private _showColumnMenu(x: number, y: number) { + if (!this.contextMenu || !this.analysisTable) { + return; + } + this.contextMenu.show( + buildColumnMenuItems( + this.analysisTable, + this.columnView, + CALL_TREE_VIEWS, + ALWAYS_VISIBLE, + Object.keys(this.columnOverrides), + ), + x, + y, + ); + } + + private _openColumnMenu(event: Event) { + const rect = (event.currentTarget as HTMLElement).getBoundingClientRect(); + this._showColumnMenu(rect.left, rect.bottom); + } + + /** Rebuilds the open column menu so checkmarks/reset icons reflect current state. */ + private _refreshColumnMenu() { + if (!this.contextMenu?.isVisible() || !this.analysisTable) { + return; + } + this.contextMenu.items = buildColumnMenuItems( + this.analysisTable, + this.columnView, + CALL_TREE_VIEWS, + ALWAYS_VISIBLE, + Object.keys(this.columnOverrides), + ); + } + private _handleColumnMenuSelect(e: CustomEvent<{ itemId: string }>) { const { itemId } = e.detail; const table = this.analysisTable; @@ -296,6 +328,7 @@ export class AnalysisView extends LitElement { } if (itemId.startsWith('view:')) { this._setColumnView(itemId.slice('view:'.length)); + this._refreshColumnMenu(); return; } if (itemId.startsWith('col:')) { @@ -308,14 +341,31 @@ export class AnalysisView extends LitElement { this.columnOverrides = { ...this.columnOverrides, [this.columnView]: fields }; applyColumnView(table, fields, ALWAYS_VISIBLE); updateSetting('callTree.columnOverrides', this.columnOverrides); + this._refreshColumnMenu(); return; } - if (itemId === 'reset' && this.columnOverrides[this.columnView]) { - const { [this.columnView]: _removed, ...rest } = this.columnOverrides; - this.columnOverrides = rest; - applyColumnView(table, this._columnViewFields(this.columnView), ALWAYS_VISIBLE); - updateSetting('callTree.columnOverrides', this.columnOverrides); + if (itemId.startsWith('reset:')) { + this._resetColumns(itemId.slice('reset:'.length)); + this._refreshColumnMenu(); + } + } + + private _onResetOption(event: CustomEvent<{ value: string }>) { + this._resetColumns(event.detail.value); + } + + /** Clears a view's override, restoring its built-in columns (defaults to the active view). */ + private _resetColumns(id: string = this.columnView) { + const table = this.analysisTable; + if (!table || !this.columnOverrides[id]) { + return; + } + const { [id]: _removed, ...rest } = this.columnOverrides; + this.columnOverrides = rest; + if (id === this.columnView) { + applyColumnView(table, this._columnViewFields(id), ALWAYS_VISIBLE); } + updateSetting('callTree.columnOverrides', this.columnOverrides); } _copyToClipboard() { diff --git a/log-viewer/src/features/call-tree/components/AggregatedTable.ts b/log-viewer/src/features/call-tree/components/AggregatedTable.ts index 5ba030ba..b9fc28dd 100644 --- a/log-viewer/src/features/call-tree/components/AggregatedTable.ts +++ b/log-viewer/src/features/call-tree/components/AggregatedTable.ts @@ -72,7 +72,9 @@ export function createAggregatedTable( { title: 'Name', field: 'text', - frozen: true, + // Sticky column parked: frozen layout fights the vertical virtual renderer. + // Re-add with _syncTableWidth in VirtualVerticalRenderer. + // frozen: true, minWidth: 200, headerSortTristate: true, bottomCalc: () => 'Total', @@ -229,6 +231,11 @@ export function createAggregatedTable( limit: rootMethod.governorLimits.queryRows.limit, visible: false, }), + createHeapColumn(rootMethod.governorLimits), + createHeapColumn(rootMethod.governorLimits, 'heapAllocated.self', 'Heap (self)', false), + createGovernorCostColumn(rootMethod.governorLimits), + createGovernorPeakColumn(rootMethod.governorLimits), + // Time columns sit at the far right of every call-tree table. { title: 'Total Time (ms)', field: 'totalTime', @@ -288,10 +295,6 @@ export function createAggregatedTable( formatterParams: { precision: 2, totalValue: rootMethod.duration.total }, tooltip: (_event, cell) => formatDuration(cell.getValue()), }, - createHeapColumn(rootMethod.governorLimits), - createHeapColumn(rootMethod.governorLimits, 'heapAllocated.self', 'Heap (self)', false), - createGovernorCostColumn(rootMethod.governorLimits), - createGovernorPeakColumn(rootMethod.governorLimits), ], }); tableRef.current = table; diff --git a/log-viewer/src/features/call-tree/components/BottomUpTable.ts b/log-viewer/src/features/call-tree/components/BottomUpTable.ts index e0813f3d..77f96434 100644 --- a/log-viewer/src/features/call-tree/components/BottomUpTable.ts +++ b/log-viewer/src/features/call-tree/components/BottomUpTable.ts @@ -138,7 +138,9 @@ export function createBottomUpTable( { title: 'Name', field: 'text', - frozen: true, + // Sticky column parked: frozen layout fights the vertical virtual renderer. + // Re-add with _syncTableWidth in VirtualVerticalRenderer. + // frozen: true, minWidth: 200, headerSortTristate: true, bottomCalc: () => 'Total', @@ -281,6 +283,11 @@ export function createBottomUpTable( limit: rootMethod.governorLimits.queryRows.limit, visible: false, }), + createHeapColumn(rootMethod.governorLimits), + createHeapColumn(rootMethod.governorLimits, 'heapAllocated.self', 'Heap (self)', false), + createGovernorCostColumn(rootMethod.governorLimits), + createGovernorPeakColumn(rootMethod.governorLimits), + // Time columns sit at the far right of every call-tree table. { title: 'Total Time (ms)', field: 'totalTime', @@ -339,10 +346,6 @@ export function createBottomUpTable( formatterParams: { precision: 2, totalValue: rootMethod.duration.total }, tooltip: (_event, cell) => formatDuration(cell.getValue()), }, - createHeapColumn(rootMethod.governorLimits), - createHeapColumn(rootMethod.governorLimits, 'heapAllocated.self', 'Heap (self)', false), - createGovernorCostColumn(rootMethod.governorLimits), - createGovernorPeakColumn(rootMethod.governorLimits), ], ...tabulatorOptionOverrides, }); diff --git a/log-viewer/src/features/call-tree/components/CalltreeView.ts b/log-viewer/src/features/call-tree/components/CalltreeView.ts index 1373f5f8..3c409942 100644 --- a/log-viewer/src/features/call-tree/components/CalltreeView.ts +++ b/log-viewer/src/features/call-tree/components/CalltreeView.ts @@ -4,6 +4,7 @@ import '#vscode-elements/vscode-button.js'; import '#vscode-elements/vscode-checkbox.js'; import '#vscode-elements/vscode-option.js'; +import '#vscode-elements/vscode-toolbar-button.js'; import '../../../components/VsSelect.js'; import { css, html, LitElement, unsafeCSS, type PropertyValues } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; @@ -36,6 +37,7 @@ import { soqlSyntaxStyles } from '../../soql/styles/soql-syntax.css.js'; import '../../../components/ContextMenu.js'; import type { ContextMenu } from '../../../components/ContextMenu.js'; import '../../../components/GridSkeleton.js'; +import '../../../components/datagrid-filter-bar.js'; // Table creation functions import { createAggregatedTable } from './AggregatedTable.js'; @@ -197,27 +199,12 @@ export class CalltreeView extends LitElement { position: relative; } - .header-bar { - display: flex; - gap: 10px; - align-items: flex-end; - } - .filter-container { display: flex; gap: 4px; align-items: flex-end; } - .filter-section { - display: block; - } - - /* push the grouping control to the right edge of the header bar */ - .group-end { - margin-left: auto; - } - .view-mode-buttons { display: flex; gap: 0; @@ -277,8 +264,8 @@ export class CalltreeView extends LitElement { return html`
-
-
+ +
-
+
+ Expand + Collapse + ${repeat( CALL_TREE_VIEWS, @@ -311,20 +305,13 @@ export class CalltreeView extends LitElement { html`${this.columnOverrides[view.id] ? `${view.id} •` : view.id}${view.id}`, )}
-
- Expand - Collapse -
- -
+
Details ${ @@ -364,7 +351,7 @@ export class CalltreeView extends LitElement { this.viewMode === 'bottom-up' ? html` + +
+ +
+
@@ -394,7 +390,10 @@ export class CalltreeView extends LitElement {
- +
`; } @@ -559,18 +558,50 @@ export class CalltreeView extends LitElement { } this.contextMenuRow = null; this.contextMenuTable = table; - const hasOverride = !!this.columnOverrides[this.columnView]; this.contextMenu.show( - buildColumnMenuItems(table, this.columnView, CALL_TREE_VIEWS, ALWAYS_VISIBLE, hasOverride), + buildColumnMenuItems( + table, + this.columnView, + CALL_TREE_VIEWS, + ALWAYS_VISIBLE, + Object.keys(this.columnOverrides), + ), clientX, clientY, ); } + private _openColumnMenu(event: Event) { + const table = this._getActiveTable(); + if (!table) { + return; + } + const rect = (event.currentTarget as HTMLElement).getBoundingClientRect(); + this._showHeaderContextMenu(table, rect.left, rect.bottom); + } + + /** Rebuilds the open column menu so checkmarks/reset icons reflect current state. */ + private _refreshColumnMenu() { + if (!this.contextMenu?.isVisible() || !this.contextMenuTable) { + return; + } + this.contextMenu.items = buildColumnMenuItems( + this.contextMenuTable, + this.columnView, + CALL_TREE_VIEWS, + ALWAYS_VISIBLE, + Object.keys(this.columnOverrides), + ); + } + + private _onColumnMenuClose() { + this.contextMenuTable = null; + this.contextMenuRow = null; + } + /** Toggles a column in the active view's override, shared across all tables. */ private _toggleColumn(field: string) { const table = this.contextMenuTable; - this.contextMenuTable = null; if (!table) { return; } @@ -586,15 +617,21 @@ export class CalltreeView extends LitElement { updateSetting('callTree.columnOverrides', this.columnOverrides); } - /** Clears the active view's override, restoring its built-in columns. */ - private _resetColumns() { - if (!this.columnOverrides[this.columnView]) { + private _onResetOption(event: CustomEvent<{ value: string }>) { + this._resetColumns(event.detail.value); + } + + /** Clears a view's override, restoring its built-in columns (defaults to the active view). */ + private _resetColumns(id: string = this.columnView) { + if (!this.columnOverrides[id]) { return; } - const { [this.columnView]: _removed, ...rest } = this.columnOverrides; + const { [id]: _removed, ...rest } = this.columnOverrides; this.columnOverrides = rest; - for (const table of this._tables) { - applyColumnView(table, this._columnViewFields(this.columnView), ALWAYS_VISIBLE); + if (id === this.columnView) { + for (const table of this._tables) { + applyColumnView(table, this._columnViewFields(id), ALWAYS_VISIBLE); + } } updateSetting('callTree.columnOverrides', this.columnOverrides); } @@ -980,19 +1017,22 @@ export class CalltreeView extends LitElement { private _handleContextMenuSelect(e: CustomEvent<{ itemId: string }>): void { const { itemId } = e.detail; - // Column-header menu actions (see _showHeaderContextMenu). + // Column-header menu actions (see _showHeaderContextMenu). These keep the menu + // open (keepOpen), so refresh its items live and leave contextMenuTable set — + // it's cleared on menu-close. if (itemId.startsWith('view:')) { this._setColumnView(itemId.slice('view:'.length)); - this.contextMenuTable = null; + this._refreshColumnMenu(); return; } if (itemId.startsWith('col:')) { this._toggleColumn(itemId.slice('col:'.length)); + this._refreshColumnMenu(); return; } - if (itemId === 'reset') { - this._resetColumns(); - this.contextMenuTable = null; + if (itemId.startsWith('reset:')) { + this._resetColumns(itemId.slice('reset:'.length)); + this._refreshColumnMenu(); return; } diff --git a/log-viewer/src/features/call-tree/components/TableShared.ts b/log-viewer/src/features/call-tree/components/TableShared.ts index 9399ba41..bc8356d1 100644 --- a/log-viewer/src/features/call-tree/components/TableShared.ts +++ b/log-viewer/src/features/call-tree/components/TableShared.ts @@ -70,7 +70,7 @@ export function formatBytes(bytes: number): string { } /** - * The shared "Gov. Cost (%)" column — the average governor consumption across all + * The shared "Gov. Avg (%)" column — the average governor consumption across all * governors on a call path (see {@link governorCost}), rendered as a progress * bar. Reused across all call-tree/analysis tables. `governorCost` is populated * by {@link annotateGovernorCost}; the tooltip breaks the average down per metric. @@ -78,7 +78,7 @@ export function formatBytes(bytes: number): string { export function createGovernorCostColumn(governorLimits: GovernorLimits): ColumnDefinition { const formatterParams = { precision: 0, totalValue: 100, showPercentageText: false }; return { - title: 'Gov. Cost (%)', + title: 'Gov. Avg (%)', field: 'governorCost', sorter: 'number', cssClass: 'number-cell', @@ -102,21 +102,21 @@ export function createGovernorCostColumn(governorLimits: GovernorLimits): Column const limit = m.label === 'Heap' ? formatBytes(m.limit) : `${m.limit}`; return `${m.label} ${used}/${limit} (${m.percent.toFixed(1)}%)`; }); - return `${total.toFixed(1)}% avg across all governors
${rows.join('
')}`; + return `${total.toFixed(1)}% — average utilisation across all governors
${rows.join('
')}`; }, }; } /** - * The "Peak Limit (%)" column — the single tightest governor consumed on a path + * The "Gov. Peak (%)" column — the single tightest governor consumed on a path * (see {@link governorCostMax}), rendered as a bar. Complements the averaged - * Gov. Cost column; hidden by default (surfaced by the Governor Limits view or a + * Gov. Avg column; hidden by default (surfaced by the Governor Limits view or a * user toggle). The tooltip names which governor is the peak. */ export function createGovernorPeakColumn(governorLimits: GovernorLimits): ColumnDefinition { const formatterParams = { precision: 0, totalValue: 100, showPercentageText: false }; return { - title: 'Peak Limit (%)', + title: 'Gov. Peak (%)', field: 'governorCostMax', visible: false, sorter: 'number', @@ -138,7 +138,7 @@ export function createGovernorPeakColumn(governorLimits: GovernorLimits): Column } const used = top.label === 'Heap' ? formatBytes(top.used) : `${top.used}`; const limit = top.label === 'Heap' ? formatBytes(top.limit) : `${top.limit}`; - return `Peak ${top.label} ${used}/${limit} (${peak.toFixed(1)}%)`; + return `Tightest single governor: ${top.label} ${used}/${limit} (${peak.toFixed(1)}%)`; }, }; } diff --git a/log-viewer/src/features/call-tree/components/TimeOrderTable.ts b/log-viewer/src/features/call-tree/components/TimeOrderTable.ts index ce6e550a..33eb76f0 100644 --- a/log-viewer/src/features/call-tree/components/TimeOrderTable.ts +++ b/log-viewer/src/features/call-tree/components/TimeOrderTable.ts @@ -78,7 +78,9 @@ export function createTimeOrderTable( { title: 'Name', field: 'text', - frozen: true, + // Sticky column parked: frozen layout fights the vertical virtual renderer. + // Re-add with _syncTableWidth in VirtualVerticalRenderer. + // frozen: true, minWidth: 200, headerSortTristate: true, bottomCalc: () => 'Total', @@ -201,6 +203,11 @@ export function createTimeOrderTable( limit: governorLimits.queryRows.limit, visible: false, }), + createHeapColumn(governorLimits), + createHeapColumn(governorLimits, 'heapAllocated.self', 'Heap (self)', false), + createGovernorCostColumn(governorLimits), + createGovernorPeakColumn(governorLimits), + // Time columns sit at the far right of every call-tree table. { title: 'Total Time (ms)', field: 'duration.total', @@ -252,10 +259,6 @@ export function createTimeOrderTable( return formatDuration(cell.getValue()); }, }, - createHeapColumn(governorLimits), - createHeapColumn(governorLimits, 'heapAllocated.self', 'Heap (self)', false), - createGovernorCostColumn(governorLimits), - createGovernorPeakColumn(governorLimits), ], }); tableRef.current = table; diff --git a/log-viewer/src/features/database/components/DMLView.ts b/log-viewer/src/features/database/components/DMLView.ts index 2e5ea64d..278cedbf 100644 --- a/log-viewer/src/features/database/components/DMLView.ts +++ b/log-viewer/src/features/database/components/DMLView.ts @@ -161,23 +161,25 @@ export class DMLView extends LitElement { ${DML_VIEWS.map( (view) => html`${this.columnOverrides[view.id] ? `${view.id} •` : view.id}${view.id}`, )}
+ ('.tabulator-header'); header?.addEventListener('contextmenu', (event) => { event.preventDefault(); - if (this.contextMenu) { - const hasOverride = !!this.columnOverrides[this.columnView]; - this.contextMenu.show( - buildColumnMenuItems(table, this.columnView, DML_VIEWS, ALWAYS_VISIBLE, hasOverride), - event.clientX, - event.clientY, - ); - } + this._showColumnMenu(event.clientX, event.clientY); }); } + private _showColumnMenu(x: number, y: number) { + if (!this.contextMenu || !this.dmlTable) { + return; + } + this.contextMenu.show( + buildColumnMenuItems( + this.dmlTable, + this.columnView, + DML_VIEWS, + ALWAYS_VISIBLE, + Object.keys(this.columnOverrides), + ), + x, + y, + ); + } + + private _openColumnMenu(event: Event) { + const rect = (event.currentTarget as HTMLElement).getBoundingClientRect(); + this._showColumnMenu(rect.left, rect.bottom); + } + + /** Rebuilds the open column menu so checkmarks/reset icons reflect current state. */ + private _refreshColumnMenu() { + if (!this.contextMenu?.isVisible() || !this.dmlTable) { + return; + } + this.contextMenu.items = buildColumnMenuItems( + this.dmlTable, + this.columnView, + DML_VIEWS, + ALWAYS_VISIBLE, + Object.keys(this.columnOverrides), + ); + } + private _handleColumnMenuSelect(e: CustomEvent<{ itemId: string }>) { const { itemId } = e.detail; const table = this.dmlTable; @@ -257,6 +294,7 @@ export class DMLView extends LitElement { const id = itemId.slice('view:'.length); this._setColumnView(id); updateSetting('database.dml.columnView', id); + this._refreshColumnMenu(); return; } if (itemId.startsWith('col:')) { @@ -269,14 +307,31 @@ export class DMLView extends LitElement { this.columnOverrides = { ...this.columnOverrides, [this.columnView]: fields }; applyColumnView(table, fields, ALWAYS_VISIBLE); updateSetting('database.dml.columnOverrides', this.columnOverrides); + this._refreshColumnMenu(); return; } - if (itemId === 'reset' && this.columnOverrides[this.columnView]) { - const { [this.columnView]: _removed, ...rest } = this.columnOverrides; - this.columnOverrides = rest; - applyColumnView(table, this._columnViewFields(this.columnView), ALWAYS_VISIBLE); - updateSetting('database.dml.columnOverrides', this.columnOverrides); + if (itemId.startsWith('reset:')) { + this._resetColumns(itemId.slice('reset:'.length)); + this._refreshColumnMenu(); + } + } + + private _onResetOption(event: CustomEvent<{ value: string }>) { + this._resetColumns(event.detail.value); + } + + /** Clears a view's override, restoring its built-in columns (defaults to the active view). */ + private _resetColumns(id: string = this.columnView) { + const table = this.dmlTable; + if (!table || !this.columnOverrides[id]) { + return; + } + const { [id]: _removed, ...rest } = this.columnOverrides; + this.columnOverrides = rest; + if (id === this.columnView) { + applyColumnView(table, this._columnViewFields(id), ALWAYS_VISIBLE); } + updateSetting('database.dml.columnOverrides', this.columnOverrides); } _copyToClipboard() { diff --git a/log-viewer/src/features/database/components/SOQLView.ts b/log-viewer/src/features/database/components/SOQLView.ts index 3a49a8ee..35eff5d4 100644 --- a/log-viewer/src/features/database/components/SOQLView.ts +++ b/log-viewer/src/features/database/components/SOQLView.ts @@ -165,23 +165,25 @@ export class SOQLView extends LitElement { ${SOQL_VIEWS.map( (view) => html`${this.columnOverrides[view.id] ? `${view.id} •` : view.id}${view.id}`, )}
+ ('.tabulator-header'); header?.addEventListener('contextmenu', (event) => { event.preventDefault(); - if (this.contextMenu) { - const hasOverride = !!this.columnOverrides[this.columnView]; - this.contextMenu.show( - buildColumnMenuItems(table, this.columnView, SOQL_VIEWS, ALWAYS_VISIBLE, hasOverride), - event.clientX, - event.clientY, - ); - } + this._showColumnMenu(event.clientX, event.clientY); }); } + private _showColumnMenu(x: number, y: number) { + if (!this.contextMenu || !this.soqlTable) { + return; + } + this.contextMenu.show( + buildColumnMenuItems( + this.soqlTable, + this.columnView, + SOQL_VIEWS, + ALWAYS_VISIBLE, + Object.keys(this.columnOverrides), + ), + x, + y, + ); + } + + private _openColumnMenu(event: Event) { + const rect = (event.currentTarget as HTMLElement).getBoundingClientRect(); + this._showColumnMenu(rect.left, rect.bottom); + } + + /** Rebuilds the open column menu so checkmarks/reset icons reflect current state. */ + private _refreshColumnMenu() { + if (!this.contextMenu?.isVisible() || !this.soqlTable) { + return; + } + this.contextMenu.items = buildColumnMenuItems( + this.soqlTable, + this.columnView, + SOQL_VIEWS, + ALWAYS_VISIBLE, + Object.keys(this.columnOverrides), + ); + } + private _handleColumnMenuSelect(e: CustomEvent<{ itemId: string }>) { const { itemId } = e.detail; const table = this.soqlTable; @@ -261,6 +298,7 @@ export class SOQLView extends LitElement { const id = itemId.slice('view:'.length); this._setColumnView(id); updateSetting('database.soql.columnView', id); + this._refreshColumnMenu(); return; } if (itemId.startsWith('col:')) { @@ -273,16 +311,33 @@ export class SOQLView extends LitElement { this.columnOverrides = { ...this.columnOverrides, [this.columnView]: fields }; applyColumnView(table, fields, ALWAYS_VISIBLE); updateSetting('database.soql.columnOverrides', this.columnOverrides); + this._refreshColumnMenu(); return; } - if (itemId === 'reset' && this.columnOverrides[this.columnView]) { - const { [this.columnView]: _removed, ...rest } = this.columnOverrides; - this.columnOverrides = rest; - applyColumnView(table, this._columnViewFields(this.columnView), ALWAYS_VISIBLE); - updateSetting('database.soql.columnOverrides', this.columnOverrides); + if (itemId.startsWith('reset:')) { + this._resetColumns(itemId.slice('reset:'.length)); + this._refreshColumnMenu(); } } + private _onResetOption(event: CustomEvent<{ value: string }>) { + this._resetColumns(event.detail.value); + } + + /** Clears a view's override, restoring its built-in columns (defaults to the active view). */ + private _resetColumns(id: string = this.columnView) { + const table = this.soqlTable; + if (!table || !this.columnOverrides[id]) { + return; + } + const { [id]: _removed, ...rest } = this.columnOverrides; + this.columnOverrides = rest; + if (id === this.columnView) { + applyColumnView(table, this._columnViewFields(id), ALWAYS_VISIBLE); + } + updateSetting('database.soql.columnOverrides', this.columnOverrides); + } + _copyToClipboard() { this.soqlTable?.copyToClipboard('all'); } @@ -596,24 +651,6 @@ export class SOQLView extends LitElement { }, tooltip: (_e, cell) => cell.getValue() + (queryRowLimit > 0 ? '/' + queryRowLimit : ''), }, - { - title: 'Time Taken (ms)', - field: 'timeTaken', - sorter: 'number', - cssClass: 'number-cell', - width: 120, - hozAlign: 'right', - headerHozAlign: 'right', - formatter: Number, - formatterParams: { - thousand: false, - precision: 2, - }, - accessorDownload: NumberAccessor, - bottomCalcFormatter: Number, - bottomCalc: 'sum', - bottomCalcFormatterParams: { precision: 2 }, - }, { title: 'Aggregations', field: 'aggregations', @@ -675,6 +712,25 @@ export class SOQLView extends LitElement { width: 140, visible: false, }, + // Time column sits at the far right. + { + title: 'Time Taken (ms)', + field: 'timeTaken', + sorter: 'number', + cssClass: 'number-cell', + width: 120, + hozAlign: 'right', + headerHozAlign: 'right', + formatter: Number, + formatterParams: { + thousand: false, + precision: 2, + }, + accessorDownload: NumberAccessor, + bottomCalcFormatter: Number, + bottomCalc: 'sum', + bottomCalcFormatterParams: { precision: 2 }, + }, ], rowFormatter: (row) => { const data = row.getData(); diff --git a/log-viewer/src/tabulator/ColumnViews.ts b/log-viewer/src/tabulator/ColumnViews.ts index fb104085..b3dad635 100644 --- a/log-viewer/src/tabulator/ColumnViews.ts +++ b/log-viewer/src/tabulator/ColumnViews.ts @@ -182,20 +182,24 @@ const CHECKED = '✓ '; const UNCHECKED = ' '; /** - * Builds the column-header context menu: the preset views (active one ticked), - * a per-column visibility toggle for every column except the always-visible - * ones, then a Reset item (enabled only when the active view is overridden). + * Builds the column-header context menu: the preset views (active one ticked, + * edited ones carrying an inline reset icon), then a per-column visibility + * toggle for every column except the always-visible ones. */ export function buildColumnMenuItems( table: Tabulator, activeViewId: string, views: ColumnView[], alwaysVisible: string[], - hasOverride: boolean, + editedViewIds: string[], ): ContextMenuItem[] { const items: ContextMenuItem[] = views.map((view) => ({ id: `view:${view.id}`, label: `${activeViewId === view.id ? CHECKED : UNCHECKED}${view.id}`, + keepOpen: true, + ...(editedViewIds.includes(view.id) + ? { action: { id: `reset:${view.id}`, icon: 'discard', title: `Reset ${view.id} columns` } } + : {}), })); items.push({ id: 'view-sep', label: '', separator: true }); @@ -209,13 +213,9 @@ export function buildColumnMenuItems( items.push({ id: `col:${field}`, label: `${column.isVisible() ? CHECKED : UNCHECKED}${title}`, + keepOpen: true, }); } - items.push( - { id: 'reset-sep', label: '', separator: true }, - { id: 'reset', label: 'Reset columns', disabled: !hasOverride }, - ); - return items; } diff --git a/log-viewer/src/tabulator/__tests__/ColumnViews.test.ts b/log-viewer/src/tabulator/__tests__/ColumnViews.test.ts index 029d53ff..e14aea1b 100644 --- a/log-viewer/src/tabulator/__tests__/ColumnViews.test.ts +++ b/log-viewer/src/tabulator/__tests__/ColumnViews.test.ts @@ -141,20 +141,22 @@ describe('toggleField', () => { }); describe('buildColumnMenuItems', () => { - it('lists views, per-column toggles, and a Reset item', () => { + it('lists views and per-column toggles, with no standalone Reset item', () => { const { table } = fakeTable(['text', 'namespace', 'governorCost']); - const items = buildColumnMenuItems(table, 'General', CALL_TREE_VIEWS, ALWAYS_VISIBLE, false); + const items = buildColumnMenuItems(table, 'General', CALL_TREE_VIEWS, ALWAYS_VISIBLE, []); expect(items.some((i) => i.id === 'view:General')).toBe(true); expect(items.some((i) => i.id === 'col:namespace')).toBe(true); // Always-visible column has no toggle. expect(items.some((i) => i.id === 'col:text')).toBe(false); - expect(items.find((i) => i.id === 'reset')?.disabled).toBe(true); + // Reset is now an inline per-view action, not a standalone item. + expect(items.some((i) => i.id === 'reset')).toBe(false); }); - it('enables Reset only when the view is overridden', () => { + it('adds an inline reset action only to edited view rows', () => { const { table } = fakeTable(['text', 'namespace']); - const items = buildColumnMenuItems(table, 'Time', CALL_TREE_VIEWS, ALWAYS_VISIBLE, true); - expect(items.find((i) => i.id === 'reset')?.disabled).toBe(false); + const items = buildColumnMenuItems(table, 'Time', CALL_TREE_VIEWS, ALWAYS_VISIBLE, ['Time']); + expect(items.find((i) => i.id === 'view:Time')?.action?.id).toBe('reset:Time'); + expect(items.find((i) => i.id === 'view:General')?.action).toBeUndefined(); }); }); diff --git a/log-viewer/src/tabulator/renderer/VirtualVerticalRenderer.ts b/log-viewer/src/tabulator/renderer/VirtualVerticalRenderer.ts index e7d01b8b..13df25ee 100644 --- a/log-viewer/src/tabulator/renderer/VirtualVerticalRenderer.ts +++ b/log-viewer/src/tabulator/renderer/VirtualVerticalRenderer.ts @@ -232,9 +232,9 @@ export class VirtualVerticalRenderer extends Renderer { // Pending rIC handle for the pre-warmer; cancelled on destroy/clearRows. private idlePrewarmHandle: number | null = null; - // Last column-total width written to tableElement.minWidth; skips redundant - // per-frame style writes (see _syncTableWidth). - private lastTableMinWidth = -1; + // Parked with the sticky Name column — re-add alongside _syncTableWidth(). + // Dedupes per-frame tableElement.minWidth writes. + // private lastTableMinWidth = -1; // Last scrollLeft piped to scrollHorizontal (which writes DOM + dispatches // unconditionally — pipe only on change). NaN → first frame always pipes. @@ -378,19 +378,20 @@ export class VirtualVerticalRenderer extends Renderer { } /** - * Size the table box to the full column width. Virtual rows are viewport- - * width with cells overflowing; without this the sticky containing block is - * only viewport-wide and frozen body cells scroll away. Stock renderers get - * full-width rows for free; the virtual renderer must set it explicitly. + * Sizes the table box to full column width so the sticky frozen column's + * containing block spans all columns (virtual rows are only viewport-wide). + * + * Parked with the sticky Name column: the full-width box fights the vertical + * renderer. Re-add with `lastTableMinWidth` (above) and the two call sites. */ - private _syncTableWidth(): void { - const self = this._self(); - const width = self.table.columnManager.getWidth(); - if (width !== this.lastTableMinWidth) { - this.lastTableMinWidth = width; - self.tableElement.style.minWidth = `${width}px`; - } - } + // private _syncTableWidth(): void { + // const self = this._self(); + // const width = self.table.columnManager.getWidth(); + // if (width !== this.lastTableMinWidth) { + // this.lastTableMinWidth = width; + // self.tableElement.style.minWidth = `${width}px`; + // } + // } /** RowManager lifecycle: holder scroll event (reads live scrollTop). */ scrollRows(_top: number, _dir: boolean): void { @@ -809,7 +810,7 @@ export class VirtualVerticalRenderer extends Renderer { this._detachAllRendered(); self.tableElement.style.paddingTop = '0'; self.tableElement.style.paddingBottom = '0'; - this._syncTableWidth(); + // this._syncTableWidth(); // disabled with the sticky Name column (see below) // Stock dispatches after every fill, including empty ones — GroupRows // relies on it to set minWidth when no data rows are visible. self.dispatch('render-virtual-fill'); @@ -911,7 +912,7 @@ export class VirtualVerticalRenderer extends Renderer { newBottom === lastIdx ? 0 : Math.max(0, this._totalHeight() - this._cumHeight(newBottom + 1)); self.tableElement.style.paddingTop = `${paddingTop}px`; self.tableElement.style.paddingBottom = `${paddingBottom}px`; - this._syncTableWidth(); + // this._syncTableWidth(); // disabled with the sticky Name column (see below) this.vDomTop = newTop; this.vDomBottom = newBottom; diff --git a/log-viewer/src/tabulator/style/DataGrid.scss b/log-viewer/src/tabulator/style/DataGrid.scss index a432ce71..78f99423 100644 --- a/log-viewer/src/tabulator/style/DataGrid.scss +++ b/log-viewer/src/tabulator/style/DataGrid.scss @@ -48,6 +48,8 @@ $codicon-chevron-down: '\eab4'; sortArrowInactive: #bbb, // rows rowBackgroundColor: var(--vscode-editor-background), + // Must stay a Sass colour literal — tabulator.scss runs color.adjust() on it + // for calc rows (so it can't be a CSS var). rowAltBackgroundColor: transparent, rowBorderColor: transparent, rowTextColor: var(--vscode-editor-foreground), @@ -69,14 +71,27 @@ $codicon-chevron-down: '\eab4'; .tabulator { .tabulator-tableholder { - overflow-x: hidden; + overflow-x: auto; overflow-anchor: none; .tabulator-table { display: block; - background-color: default; } } + // Parked with the sticky Name column (see the commented `frozen: true` in the + // call-tree tables). Frozen cells use `background-color: inherit`, so they are only + // opaque where the row/section is opaque; even rows, calc/total rows and the header + // are transparent and would let scrolling columns show through. Re-add with the + // frozen column. (Selected/hovered rows are excluded so the Name cell keeps its + // inherited state colour; the last selector covers the sortable-header :hover state.) + // .tabulator-row.tabulator-row-even:not(.tabulator-selected):not(:hover) + // .tabulator-cell.tabulator-frozen, + // .tabulator-row.tabulator-calcs .tabulator-cell.tabulator-frozen, + // .tabulator-header .tabulator-col.tabulator-frozen, + // .tabulator-header .tabulator-col.tabulator-frozen.tabulator-col-sorter-element:hover { + // background-color: var(--vscode-editor-background); + // } + .tabulator-header-filter { input[type='search'] { color: var(--vscode-editor-foreground); @@ -96,11 +111,6 @@ $codicon-chevron-down: '\eab4'; } .tabulator-row { - background-color: default; - .tabulator-row-even { - background-color: default; - } - &.tabulator-group { font-family: monospace; background: unset; From 31116e366b28d5b1d1014dd7e158185a45f76ad9 Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Mon, 20 Jul 2026 16:48:21 +0100 Subject: [PATCH 3/6] feat(SOQLView): change vertical alignment of table cells from middle to top --- log-viewer/src/features/database/components/SOQLView.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/log-viewer/src/features/database/components/SOQLView.ts b/log-viewer/src/features/database/components/SOQLView.ts index 35eff5d4..321b1ff3 100644 --- a/log-viewer/src/features/database/components/SOQLView.ts +++ b/log-viewer/src/features/database/components/SOQLView.ts @@ -560,7 +560,7 @@ export class SOQLView extends LitElement { }, width: 40, hozAlign: 'center', - vertAlign: 'middle', + vertAlign: 'top', sorter: function (a, b, aRow, bRow, _column, dir, _sorterParams) { // Always Sort null values to the bottom (when we do not have selectivity) if (a === null) { From 1887e560c7a5d953cf1107cb86460ec70783723a Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Tue, 21 Jul 2026 10:10:05 +0100 Subject: [PATCH 4/6] feat(call-tree): refactor governor columns into shared metric function --- .../analysis/components/AnalysisView.ts | 5 +- .../call-tree/components/AggregatedTable.ts | 86 +---------------- .../call-tree/components/BottomUpTable.ts | 86 +---------------- .../call-tree/components/CalltreeView.ts | 4 +- .../call-tree/components/TableShared.ts | 93 +++++++++++++++++++ .../call-tree/components/TimeOrderTable.ts | 86 +---------------- 6 files changed, 105 insertions(+), 255 deletions(-) diff --git a/log-viewer/src/features/analysis/components/AnalysisView.ts b/log-viewer/src/features/analysis/components/AnalysisView.ts index cdab1b0d..f8a23e17 100644 --- a/log-viewer/src/features/analysis/components/AnalysisView.ts +++ b/log-viewer/src/features/analysis/components/AnalysisView.ts @@ -12,7 +12,6 @@ import { repeat } from 'lit/directives/repeat.js'; import type { RowComponent, Tabulator } from 'tabulator-tables'; import type { ApexLog } from 'apex-log-parser'; -import '#vscode-elements/vscode-option.js'; import '../../../components/ContextMenu.js'; import type { ContextMenu } from '../../../components/ContextMenu.js'; import { isVisible } from '../../../core/utility/Util.js'; @@ -327,7 +326,9 @@ export class AnalysisView extends LitElement { return; } if (itemId.startsWith('view:')) { - this._setColumnView(itemId.slice('view:'.length)); + const id = itemId.slice('view:'.length); + this._setColumnView(id); + updateSetting('callTree.columnView', id); this._refreshColumnMenu(); return; } diff --git a/log-viewer/src/features/call-tree/components/AggregatedTable.ts b/log-viewer/src/features/call-tree/components/AggregatedTable.ts index b9fc28dd..2959c50d 100644 --- a/log-viewer/src/features/call-tree/components/AggregatedTable.ts +++ b/log-viewer/src/features/call-tree/components/AggregatedTable.ts @@ -15,10 +15,7 @@ import { makeSumSelfTimeAllVisible } from '../utils/BottomCalcs.js'; import { annotateGovernorCost } from '../utils/GovernorCost.js'; import { commonColumnDefaults, - createGovernorColumn, - createGovernorCostColumn, - createGovernorPeakColumn, - createHeapColumn, + createGovernorMetricColumns, headerSortElement, registerTableModules, type TableCallbacks, @@ -155,86 +152,7 @@ export function createAggregatedTable( headerHozAlign: 'right', bottomCalc: 'sum', }, - createGovernorColumn({ - title: 'DML Count', - field: 'dmlCount.total', - limit: rootMethod.governorLimits.dmlStatements.limit, - }), - createGovernorColumn({ - title: 'DML Count (self)', - field: 'dmlCount.self', - limit: rootMethod.governorLimits.dmlStatements.limit, - visible: false, - }), - createGovernorColumn({ - title: 'SOQL Count', - field: 'soqlCount.total', - limit: rootMethod.governorLimits.soqlQueries.limit, - }), - createGovernorColumn({ - title: 'SOQL Count (self)', - field: 'soqlCount.self', - limit: rootMethod.governorLimits.soqlQueries.limit, - visible: false, - }), - createGovernorColumn({ - title: 'SOSL Count', - field: 'soslCount.total', - limit: rootMethod.governorLimits.soslQueries.limit, - }), - createGovernorColumn({ - title: 'SOSL Count (self)', - field: 'soslCount.self', - limit: rootMethod.governorLimits.soslQueries.limit, - visible: false, - }), - { - title: 'Throws Count', - field: 'thrownCount.total', - sorter: 'number', - cssClass: 'number-cell', - width: 60, - hozAlign: 'right', - headerHozAlign: 'right', - bottomCalc: 'sum', - }, - createGovernorColumn({ - title: 'DML Rows', - field: 'dmlRowCount.total', - limit: rootMethod.governorLimits.dmlRows.limit, - }), - createGovernorColumn({ - title: 'DML Rows (self)', - field: 'dmlRowCount.self', - limit: rootMethod.governorLimits.dmlRows.limit, - visible: false, - }), - createGovernorColumn({ - title: 'SOQL Rows', - field: 'soqlRowCount.total', - limit: rootMethod.governorLimits.queryRows.limit, - }), - createGovernorColumn({ - title: 'SOQL Rows (self)', - field: 'soqlRowCount.self', - limit: rootMethod.governorLimits.queryRows.limit, - visible: false, - }), - createGovernorColumn({ - title: 'SOSL Rows', - field: 'soslRowCount.total', - limit: rootMethod.governorLimits.queryRows.limit, - }), - createGovernorColumn({ - title: 'SOSL Rows (self)', - field: 'soslRowCount.self', - limit: rootMethod.governorLimits.queryRows.limit, - visible: false, - }), - createHeapColumn(rootMethod.governorLimits), - createHeapColumn(rootMethod.governorLimits, 'heapAllocated.self', 'Heap (self)', false), - createGovernorCostColumn(rootMethod.governorLimits), - createGovernorPeakColumn(rootMethod.governorLimits), + ...createGovernorMetricColumns(rootMethod.governorLimits), // Time columns sit at the far right of every call-tree table. { title: 'Total Time (ms)', diff --git a/log-viewer/src/features/call-tree/components/BottomUpTable.ts b/log-viewer/src/features/call-tree/components/BottomUpTable.ts index 77f96434..49ab7d98 100644 --- a/log-viewer/src/features/call-tree/components/BottomUpTable.ts +++ b/log-viewer/src/features/call-tree/components/BottomUpTable.ts @@ -19,10 +19,7 @@ import { toBottomUpTree, type BottomUpRow } from '../utils/Aggregation.js'; import { annotateGovernorCost } from '../utils/GovernorCost.js'; import { commonColumnDefaults, - createGovernorColumn, - createGovernorCostColumn, - createGovernorPeakColumn, - createHeapColumn, + createGovernorMetricColumns, headerSortElement, registerTableModules, type TableCallbacks, @@ -207,86 +204,7 @@ export function createBottomUpTable( headerHozAlign: 'right', bottomCalc: 'sum', }, - createGovernorColumn({ - title: 'DML Count', - field: 'dmlCount.total', - limit: rootMethod.governorLimits.dmlStatements.limit, - }), - createGovernorColumn({ - title: 'DML Count (self)', - field: 'dmlCount.self', - limit: rootMethod.governorLimits.dmlStatements.limit, - visible: false, - }), - createGovernorColumn({ - title: 'SOQL Count', - field: 'soqlCount.total', - limit: rootMethod.governorLimits.soqlQueries.limit, - }), - createGovernorColumn({ - title: 'SOQL Count (self)', - field: 'soqlCount.self', - limit: rootMethod.governorLimits.soqlQueries.limit, - visible: false, - }), - createGovernorColumn({ - title: 'SOSL Count', - field: 'soslCount.total', - limit: rootMethod.governorLimits.soslQueries.limit, - }), - createGovernorColumn({ - title: 'SOSL Count (self)', - field: 'soslCount.self', - limit: rootMethod.governorLimits.soslQueries.limit, - visible: false, - }), - { - title: 'Throws Count', - field: 'thrownCount.total', - sorter: 'number', - cssClass: 'number-cell', - width: 60, - hozAlign: 'right', - headerHozAlign: 'right', - bottomCalc: 'sum', - }, - createGovernorColumn({ - title: 'DML Rows', - field: 'dmlRowCount.total', - limit: rootMethod.governorLimits.dmlRows.limit, - }), - createGovernorColumn({ - title: 'DML Rows (self)', - field: 'dmlRowCount.self', - limit: rootMethod.governorLimits.dmlRows.limit, - visible: false, - }), - createGovernorColumn({ - title: 'SOQL Rows', - field: 'soqlRowCount.total', - limit: rootMethod.governorLimits.queryRows.limit, - }), - createGovernorColumn({ - title: 'SOQL Rows (self)', - field: 'soqlRowCount.self', - limit: rootMethod.governorLimits.queryRows.limit, - visible: false, - }), - createGovernorColumn({ - title: 'SOSL Rows', - field: 'soslRowCount.total', - limit: rootMethod.governorLimits.queryRows.limit, - }), - createGovernorColumn({ - title: 'SOSL Rows (self)', - field: 'soslRowCount.self', - limit: rootMethod.governorLimits.queryRows.limit, - visible: false, - }), - createHeapColumn(rootMethod.governorLimits), - createHeapColumn(rootMethod.governorLimits, 'heapAllocated.self', 'Heap (self)', false), - createGovernorCostColumn(rootMethod.governorLimits), - createGovernorPeakColumn(rootMethod.governorLimits), + ...createGovernorMetricColumns(rootMethod.governorLimits), // Time columns sit at the far right of every call-tree table. { title: 'Total Time (ms)', diff --git a/log-viewer/src/features/call-tree/components/CalltreeView.ts b/log-viewer/src/features/call-tree/components/CalltreeView.ts index 3c409942..826aea2a 100644 --- a/log-viewer/src/features/call-tree/components/CalltreeView.ts +++ b/log-viewer/src/features/call-tree/components/CalltreeView.ts @@ -1021,7 +1021,9 @@ export class CalltreeView extends LitElement { // open (keepOpen), so refresh its items live and leave contextMenuTable set — // it's cleared on menu-close. if (itemId.startsWith('view:')) { - this._setColumnView(itemId.slice('view:'.length)); + const id = itemId.slice('view:'.length); + this._setColumnView(id); + updateSetting('callTree.columnView', id); this._refreshColumnMenu(); return; } diff --git a/log-viewer/src/features/call-tree/components/TableShared.ts b/log-viewer/src/features/call-tree/components/TableShared.ts index bc8356d1..dfc3d577 100644 --- a/log-viewer/src/features/call-tree/components/TableShared.ts +++ b/log-viewer/src/features/call-tree/components/TableShared.ts @@ -181,6 +181,99 @@ export function createGovernorColumn(opts: { }; } +/** + * The shared governor-metric column block common to every call-tree table + * (aggregated, bottom-up, time-order): the DML/SOQL/SOSL count & row columns + * (Total + hidden Self variants), the Throws Count column, the two Heap columns, + * and the Gov. Avg/Peak columns — in display order. Extracted so the block stays + * identical across all three tables; spread into each table's `columns` after + * its view-specific leading columns (Name, Namespace, …). + */ +export function createGovernorMetricColumns(governorLimits: GovernorLimits): ColumnDefinition[] { + return [ + createGovernorColumn({ + title: 'DML Count', + field: 'dmlCount.total', + limit: governorLimits.dmlStatements.limit, + }), + createGovernorColumn({ + title: 'DML Count (self)', + field: 'dmlCount.self', + limit: governorLimits.dmlStatements.limit, + visible: false, + }), + createGovernorColumn({ + title: 'SOQL Count', + field: 'soqlCount.total', + limit: governorLimits.soqlQueries.limit, + }), + createGovernorColumn({ + title: 'SOQL Count (self)', + field: 'soqlCount.self', + limit: governorLimits.soqlQueries.limit, + visible: false, + }), + createGovernorColumn({ + title: 'SOSL Count', + field: 'soslCount.total', + limit: governorLimits.soslQueries.limit, + }), + createGovernorColumn({ + title: 'SOSL Count (self)', + field: 'soslCount.self', + limit: governorLimits.soslQueries.limit, + visible: false, + }), + { + title: 'Throws Count', + field: 'thrownCount.total', + sorter: 'number', + cssClass: 'number-cell', + width: 60, + hozAlign: 'right', + headerHozAlign: 'right', + bottomCalc: 'sum', + }, + createGovernorColumn({ + title: 'DML Rows', + field: 'dmlRowCount.total', + limit: governorLimits.dmlRows.limit, + }), + createGovernorColumn({ + title: 'DML Rows (self)', + field: 'dmlRowCount.self', + limit: governorLimits.dmlRows.limit, + visible: false, + }), + createGovernorColumn({ + title: 'SOQL Rows', + field: 'soqlRowCount.total', + limit: governorLimits.queryRows.limit, + }), + createGovernorColumn({ + title: 'SOQL Rows (self)', + field: 'soqlRowCount.self', + limit: governorLimits.queryRows.limit, + visible: false, + }), + createGovernorColumn({ + title: 'SOSL Rows', + field: 'soslRowCount.total', + limit: governorLimits.queryRows.limit, + }), + createGovernorColumn({ + title: 'SOSL Rows (self)', + field: 'soslRowCount.self', + limit: governorLimits.queryRows.limit, + visible: false, + }), + createHeapColumn(governorLimits), + createHeapColumn(governorLimits, 'heapAllocated.self', 'Heap (self)', false), + createGovernorCostColumn(governorLimits), + createGovernorPeakColumn(governorLimits), + ]; +} + /** * The shared "Heap Allocated" column — bytes allocated on a call path, rendered * as a bar relative to the heap governor limit. Reused across all diff --git a/log-viewer/src/features/call-tree/components/TimeOrderTable.ts b/log-viewer/src/features/call-tree/components/TimeOrderTable.ts index 33eb76f0..e24bb644 100644 --- a/log-viewer/src/features/call-tree/components/TimeOrderTable.ts +++ b/log-viewer/src/features/call-tree/components/TimeOrderTable.ts @@ -16,10 +16,7 @@ import { toTimeOrderTree, type TimeOrderRow } from '../utils/TimeOrderTree.js'; import { createCalltreeNameFormatter } from './CalltreeNameFormatter.js'; import { commonColumnDefaults, - createGovernorColumn, - createGovernorCostColumn, - createGovernorPeakColumn, - createHeapColumn, + createGovernorMetricColumns, headerSortElement, registerTableModules, type TableCallbacks, @@ -127,86 +124,7 @@ export function createTimeOrderTable( width: 120, visible: false, }, - createGovernorColumn({ - title: 'DML Count', - field: 'dmlCount.total', - limit: governorLimits.dmlStatements.limit, - }), - createGovernorColumn({ - title: 'DML Count (self)', - field: 'dmlCount.self', - limit: governorLimits.dmlStatements.limit, - visible: false, - }), - createGovernorColumn({ - title: 'SOQL Count', - field: 'soqlCount.total', - limit: governorLimits.soqlQueries.limit, - }), - createGovernorColumn({ - title: 'SOQL Count (self)', - field: 'soqlCount.self', - limit: governorLimits.soqlQueries.limit, - visible: false, - }), - createGovernorColumn({ - title: 'SOSL Count', - field: 'soslCount.total', - limit: governorLimits.soslQueries.limit, - }), - createGovernorColumn({ - title: 'SOSL Count (self)', - field: 'soslCount.self', - limit: governorLimits.soslQueries.limit, - visible: false, - }), - { - title: 'Throws Count', - field: 'thrownCount.total', - sorter: 'number', - cssClass: 'number-cell', - width: 60, - hozAlign: 'right', - headerHozAlign: 'right', - bottomCalc: 'sum', - }, - createGovernorColumn({ - title: 'DML Rows', - field: 'dmlRowCount.total', - limit: governorLimits.dmlRows.limit, - }), - createGovernorColumn({ - title: 'DML Rows (self)', - field: 'dmlRowCount.self', - limit: governorLimits.dmlRows.limit, - visible: false, - }), - createGovernorColumn({ - title: 'SOQL Rows', - field: 'soqlRowCount.total', - limit: governorLimits.queryRows.limit, - }), - createGovernorColumn({ - title: 'SOQL Rows (self)', - field: 'soqlRowCount.self', - limit: governorLimits.queryRows.limit, - visible: false, - }), - createGovernorColumn({ - title: 'SOSL Rows', - field: 'soslRowCount.total', - limit: governorLimits.queryRows.limit, - }), - createGovernorColumn({ - title: 'SOSL Rows (self)', - field: 'soslRowCount.self', - limit: governorLimits.queryRows.limit, - visible: false, - }), - createHeapColumn(governorLimits), - createHeapColumn(governorLimits, 'heapAllocated.self', 'Heap (self)', false), - createGovernorCostColumn(governorLimits), - createGovernorPeakColumn(governorLimits), + ...createGovernorMetricColumns(governorLimits), // Time columns sit at the far right of every call-tree table. { title: 'Total Time (ms)', From be878333050f00a92ec5ec9400dde88de57c6769 Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Tue, 21 Jul 2026 14:13:39 +0100 Subject: [PATCH 5/6] fix(log-viewer): correct governor cost and column-view persistence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - exclude SOSL rows from governor cost — verified in-org they have no limit and don't count against the 50k query-rows limit; render them as a plain count instead of a bar against the wrong limit - compute governor cost during tree build instead of a second full-tree walk (annotateGovernorCost → per-row setGovernorCost) - persist the view chosen from the header context menu in Call Tree and Analysis, and fall back to the default view for an unknown persisted id - restore table.redraw() after show/hide so columns resize — stock tabulator does not re-run fitColumns on column visibility change --- CHANGELOG.md | 9 +++-- .../analysis/components/AnalysisView.ts | 3 +- .../call-tree/components/AggregatedTable.ts | 4 +- .../call-tree/components/BottomUpTable.ts | 4 +- .../call-tree/components/CalltreeView.ts | 7 +++- .../call-tree/components/TableShared.ts | 28 +++++++++---- .../call-tree/components/TimeOrderTable.ts | 4 +- .../features/call-tree/utils/Aggregation.ts | 40 ++++++++++++++----- .../features/call-tree/utils/GovernorCost.ts | 28 +++++-------- .../features/call-tree/utils/TimeOrderTree.ts | 14 +++++-- .../utils/__tests__/GovernorCost.test.ts | 28 ++++++------- .../features/database/components/DMLView.ts | 3 +- .../features/database/components/SOQLView.ts | 3 +- log-viewer/src/tabulator/ColumnViews.ts | 17 ++++++-- .../tabulator/__tests__/ColumnViews.test.ts | 12 ++++++ 15 files changed, 130 insertions(+), 74 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 68a394fa..8723600e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,10 +10,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - 🔴 **Timeline exception markers**: exceptions show as red lines, with a **Throws** count in method tooltips. ([#828]) -- 🧩 **Column views**: switch the Call Tree, Analysis and Database (SOQL/DML) tables between preset column sets (`General`, `Time`, `Governor Limits`, `Database`, `Memory`) - - Show/hide individual columns from the **Columns** toolbar button or the header right-click menu; inline **reset** to restore defaults. Choices persist per view. ([#298]) -- 📊 New Call Tree columns: **SOSL Count/Rows**, **Heap Allocated** (+ Self), **Avg Self Time**, and optional **Self** variants for every governor metric — surfaced through the `Governor Limits`, `Database` and `Memory` views. ([#298]) -- 🔎 **Database tables**: Row Count renders as a **% bar** against the governor limit, and SOQL gains a **Query Plan** view (Relative Cost, Leading Operation, SObject Type, Cardinality). ([#298]) +- 🧩 **Call Tree, Analysis and Database tables** gain configurable columns and views. ([#298]) + - 🗂️ **Column views**: switch each table between column sets (`General`, `Time`, `Governor Limits`, `Database`, `Memory`); edit a view by showing/hiding columns from the **Columns** toolbar button or the header right-click menu, with inline **reset** to restore defaults; choices persist per view. + - 📊 **New columns**: **SOSL Count/Rows**, **Avg Self Time**, and optional **Self** variants for every governor metric. + - 🧠 **Heap / memory analysis**: **Heap Allocated** (+ Self) columns, surfaced through the `Memory` view and the Timeline governor limit strip. + - 🔎 **SOQL Query Plan** view: Relative Cost, Leading Operation, SObject Type and Cardinality. ### Changed diff --git a/log-viewer/src/features/analysis/components/AnalysisView.ts b/log-viewer/src/features/analysis/components/AnalysisView.ts index f8a23e17..18b1f108 100644 --- a/log-viewer/src/features/analysis/components/AnalysisView.ts +++ b/log-viewer/src/features/analysis/components/AnalysisView.ts @@ -23,6 +23,7 @@ import { CALL_TREE_VIEWS, getColumnView, getTableFields, + resolveColumnView, toggleField, } from '../../../tabulator/ColumnViews.js'; import type { BottomUpRow } from '../../call-tree/utils/Aggregation.js'; @@ -151,7 +152,7 @@ export class AnalysisView extends LitElement { private async _loadColumnSettings(): Promise { const settings = await getSettings(); this.columnOverrides = settings.callTree?.columnOverrides ?? {}; - this._setColumnView(settings.callTree?.columnView ?? 'General'); + this._setColumnView(resolveColumnView(CALL_TREE_VIEWS, settings.callTree?.columnView)); } updated(changedProperties: PropertyValues): void { diff --git a/log-viewer/src/features/call-tree/components/AggregatedTable.ts b/log-viewer/src/features/call-tree/components/AggregatedTable.ts index 2959c50d..c3005925 100644 --- a/log-viewer/src/features/call-tree/components/AggregatedTable.ts +++ b/log-viewer/src/features/call-tree/components/AggregatedTable.ts @@ -12,7 +12,6 @@ import { progressFormatterMS } from '../../../tabulator/format/ProgressMS.js'; import { VirtualVerticalRenderer } from '../../../tabulator/renderer/VirtualVerticalRenderer.js'; import { toAggregatedCallTree, type AggregatedRow } from '../utils/Aggregation.js'; import { makeSumSelfTimeAllVisible } from '../utils/BottomCalcs.js'; -import { annotateGovernorCost } from '../utils/GovernorCost.js'; import { commonColumnDefaults, createGovernorMetricColumns, @@ -41,8 +40,7 @@ export function createAggregatedTable( const tableRef: { current: Tabulator | undefined } = { current: undefined }; const selfTimeBottomCalc = makeSumSelfTimeAllVisible(() => tableRef.current); - const tableData = toAggregatedCallTree(rootMethod.children); - annotateGovernorCost(tableData, rootMethod.governorLimits); + const tableData = toAggregatedCallTree(rootMethod.children, rootMethod.governorLimits); const table = new Tabulator(container, { data: tableData, diff --git a/log-viewer/src/features/call-tree/components/BottomUpTable.ts b/log-viewer/src/features/call-tree/components/BottomUpTable.ts index 49ab7d98..1587237e 100644 --- a/log-viewer/src/features/call-tree/components/BottomUpTable.ts +++ b/log-viewer/src/features/call-tree/components/BottomUpTable.ts @@ -16,7 +16,6 @@ import { VirtualVerticalRenderer } from '../../../tabulator/renderer/VirtualVert import { sumDurationTotalForRootEvents } from '../../analysis/services/CallStackSum.js'; import { soqlGroupHeader } from '../../soql/format/groupHeader.js'; import { toBottomUpTree, type BottomUpRow } from '../utils/Aggregation.js'; -import { annotateGovernorCost } from '../utils/GovernorCost.js'; import { commonColumnDefaults, createGovernorMetricColumns, @@ -96,8 +95,7 @@ export function createBottomUpTable( } : {}; - const tableData = toBottomUpTree(rootMethod.children); - annotateGovernorCost(tableData, rootMethod.governorLimits); + const tableData = toBottomUpTree(rootMethod.children, rootMethod.governorLimits); const tabulatorOptions = { data: tableData, diff --git a/log-viewer/src/features/call-tree/components/CalltreeView.ts b/log-viewer/src/features/call-tree/components/CalltreeView.ts index 826aea2a..e09731a9 100644 --- a/log-viewer/src/features/call-tree/components/CalltreeView.ts +++ b/log-viewer/src/features/call-tree/components/CalltreeView.ts @@ -48,6 +48,7 @@ import { CALL_TREE_VIEWS, getColumnView, getTableFields, + resolveColumnView, toggleField, } from '../../../tabulator/ColumnViews.js'; import { createTimeOrderTable } from './TimeOrderTable.js'; @@ -170,7 +171,7 @@ export class CalltreeView extends LitElement { private async _loadColumnSettings(): Promise { const settings = await getSettings(); this.columnOverrides = settings.callTree?.columnOverrides ?? {}; - this._setColumnView(settings.callTree?.columnView ?? 'General'); + this._setColumnView(resolveColumnView(CALL_TREE_VIEWS, settings.callTree?.columnView)); } static styles = [ @@ -629,8 +630,10 @@ export class CalltreeView extends LitElement { const { [id]: _removed, ...rest } = this.columnOverrides; this.columnOverrides = rest; if (id === this.columnView) { + // Resolve the restored fields once (identical for every table). + const fields = this._columnViewFields(id); for (const table of this._tables) { - applyColumnView(table, this._columnViewFields(id), ALWAYS_VISIBLE); + applyColumnView(table, fields, ALWAYS_VISIBLE); } } updateSetting('callTree.columnOverrides', this.columnOverrides); diff --git a/log-viewer/src/features/call-tree/components/TableShared.ts b/log-viewer/src/features/call-tree/components/TableShared.ts index dfc3d577..49d26d90 100644 --- a/log-viewer/src/features/call-tree/components/TableShared.ts +++ b/log-viewer/src/features/call-tree/components/TableShared.ts @@ -73,7 +73,7 @@ export function formatBytes(bytes: number): string { * The shared "Gov. Avg (%)" column — the average governor consumption across all * governors on a call path (see {@link governorCost}), rendered as a progress * bar. Reused across all call-tree/analysis tables. `governorCost` is populated - * by {@link annotateGovernorCost}; the tooltip breaks the average down per metric. + * during tree build; the tooltip breaks the average down per metric. */ export function createGovernorCostColumn(governorLimits: GovernorLimits): ColumnDefinition { const formatterParams = { precision: 0, totalValue: 100, showPercentageText: false }; @@ -256,17 +256,31 @@ export function createGovernorMetricColumns(governorLimits: GovernorLimits): Col limit: governorLimits.queryRows.limit, visible: false, }), - createGovernorColumn({ + // SOSL rows have no governor limit (only SOSL queries is limited, to 20), + // so these are plain counts rather than progress bars against a limit. + { title: 'SOSL Rows', field: 'soslRowCount.total', - limit: governorLimits.queryRows.limit, - }), - createGovernorColumn({ + sorter: 'number', + cssClass: 'number-cell', + width: 70, + minWidth: 60, + hozAlign: 'right', + headerHozAlign: 'right', + bottomCalc: 'sum', + }, + { title: 'SOSL Rows (self)', field: 'soslRowCount.self', - limit: governorLimits.queryRows.limit, visible: false, - }), + sorter: 'number', + cssClass: 'number-cell', + width: 70, + minWidth: 60, + hozAlign: 'right', + headerHozAlign: 'right', + bottomCalc: 'sum', + }, createHeapColumn(governorLimits), createHeapColumn(governorLimits, 'heapAllocated.self', 'Heap (self)', false), createGovernorCostColumn(governorLimits), diff --git a/log-viewer/src/features/call-tree/components/TimeOrderTable.ts b/log-viewer/src/features/call-tree/components/TimeOrderTable.ts index e24bb644..4a08a49c 100644 --- a/log-viewer/src/features/call-tree/components/TimeOrderTable.ts +++ b/log-viewer/src/features/call-tree/components/TimeOrderTable.ts @@ -11,7 +11,6 @@ import { minMaxTreeFilter } from '../../../tabulator/filters/MinMax.js'; import { progressFormatterMS } from '../../../tabulator/format/ProgressMS.js'; import { VirtualVerticalRenderer } from '../../../tabulator/renderer/VirtualVerticalRenderer.js'; import { makeSumSelfTimeAllVisible } from '../utils/BottomCalcs.js'; -import { annotateGovernorCost } from '../utils/GovernorCost.js'; import { toTimeOrderTree, type TimeOrderRow } from '../utils/TimeOrderTree.js'; import { createCalltreeNameFormatter } from './CalltreeNameFormatter.js'; import { @@ -41,8 +40,7 @@ export function createTimeOrderTable( const excludedTypes = new Set(['SOQL_EXECUTE_BEGIN', 'DML_BEGIN']); const governorLimits = rootMethod.governorLimits; - const tableData = toTimeOrderTree(rootMethod.children); - annotateGovernorCost(tableData, governorLimits); + const tableData = toTimeOrderTree(rootMethod.children, governorLimits); const nameFormatter = createCalltreeNameFormatter(excludedTypes); const tableRef: { current: Tabulator | undefined } = { current: undefined }; diff --git a/log-viewer/src/features/call-tree/utils/Aggregation.ts b/log-viewer/src/features/call-tree/utils/Aggregation.ts index 2a20e60c..81999e4f 100644 --- a/log-viewer/src/features/call-tree/utils/Aggregation.ts +++ b/log-viewer/src/features/call-tree/utils/Aggregation.ts @@ -2,10 +2,11 @@ * Copyright (c) 2026 Certinia Inc. All rights reserved. */ -import type { LogEvent, SelfTotal } from 'apex-log-parser'; +import type { GovernorLimits, LogEvent, SelfTotal } from 'apex-log-parser'; import { getCallerNamespace } from '../../../core/utility/CallerNamespace.js'; import { Multiset } from '../../../core/utility/Multiset.js'; import { EXCLUDED_DETAIL_TYPES } from './DetailsFilter.js'; +import { setGovernorCost } from './GovernorCost.js'; /** * Represents a row in the aggregated call tree view. @@ -147,7 +148,10 @@ function getStackKey(event: LogEvent): string { * are merged together, with aggregated metrics. * Uses Multiset call-stack tracking to prevent double-counting of recursive calls. */ -export function toAggregatedCallTree(rootChildren: LogEvent[]): AggregatedRow[] { +export function toAggregatedCallTree( + rootChildren: LogEvent[], + governorLimits?: GovernorLimits, +): AggregatedRow[] { if (rootChildren.length === 0) { return []; } @@ -180,8 +184,11 @@ export function toAggregatedCallTree(rootChildren: LogEvent[]): AggregatedRow[] for (const row of rootMap.values()) { const firstInstance = row.instances[0]; const stackKey = firstInstance ? getStackKey(firstInstance) : row.key; - row._children = aggregateChildrenRecursive(row.instances, stackKey, idFor); + row._children = aggregateChildrenRecursive(row.instances, stackKey, idFor, governorLimits); calculateAverages(row); + if (governorLimits) { + setGovernorCost(row, governorLimits); + } row._hasDetailsDeep = computeHasDetailsDeep(row, row.totalTime, row.originalData.type); } @@ -197,6 +204,7 @@ function aggregateChildrenRecursive( instances: LogEvent[], parentStackKey: string, idFor: () => number, + governorLimits?: GovernorLimits, ): AggregatedRow[] | null { const childMap = new Map(); // Create a new stack for each aggregation level, starting with the parent stack key @@ -226,8 +234,11 @@ function aggregateChildrenRecursive( for (const row of childMap.values()) { const firstInstance = row.instances[0]; const stackKey = firstInstance ? getStackKey(firstInstance) : row.key; - row._children = aggregateChildrenRecursive(row.instances, stackKey, idFor); + row._children = aggregateChildrenRecursive(row.instances, stackKey, idFor, governorLimits); calculateAverages(row); + if (governorLimits) { + setGovernorCost(row, governorLimits); + } row._hasDetailsDeep = computeHasDetailsDeep(row, row.totalTime, row.originalData.type); } @@ -351,7 +362,10 @@ type DfsEntry = { * Zero-delta guards on the DML/SOQL/row/thrown accumulators avoid the no-op * `bucket.x += 0` writes that dominate logs without heavy DB work. */ -export function toBottomUpTree(rootChildren: LogEvent[]): BottomUpRow[] { +export function toBottomUpTree( + rootChildren: LogEvent[], + governorLimits?: GovernorLimits, +): BottomUpRow[] { if (rootChildren.length === 0) { return []; } @@ -541,7 +555,7 @@ export function toBottomUpTree(rootChildren: LogEvent[]): BottomUpRow[] { } } - return finalizeBuckets(rootBuckets); + return finalizeBuckets(rootBuckets, governorLimits); } /** @@ -549,20 +563,26 @@ export function toBottomUpTree(rootChildren: LogEvent[]): BottomUpRow[] { * (primary metric total-self desc, then name asc) at every level. Empty child * arrays are collapsed to null so Tabulator's dataTree renders a leaf indicator. */ -function finalizeBuckets(rootBuckets: Map): BottomUpRow[] { +function finalizeBuckets( + rootBuckets: Map, + governorLimits?: GovernorLimits, +): BottomUpRow[] { const roots = Array.from(rootBuckets.values()); for (const row of roots) { - finalizeBucketRecursive(row); + finalizeBucketRecursive(row, governorLimits); } sortBuckets(roots); return roots; } -function finalizeBucketRecursive(row: BottomUpRow): void { +function finalizeBucketRecursive(row: BottomUpRow, governorLimits?: GovernorLimits): void { calculateBottomUpAverages(row); + if (governorLimits) { + setGovernorCost(row, governorLimits); + } if (row._children && row._children.length > 0) { for (const child of row._children) { - finalizeBucketRecursive(child); + finalizeBucketRecursive(child, governorLimits); } sortBuckets(row._children); } diff --git a/log-viewer/src/features/call-tree/utils/GovernorCost.ts b/log-viewer/src/features/call-tree/utils/GovernorCost.ts index f7f3a301..23426c9f 100644 --- a/log-viewer/src/features/call-tree/utils/GovernorCost.ts +++ b/log-viewer/src/features/call-tree/utils/GovernorCost.ts @@ -42,7 +42,9 @@ interface CostMetric { /** * Metrics that are attributed per call-tree node (and therefore comparable to * their limit per path). CPU time and callouts are intentionally excluded — - * they are only tracked globally, not per node. + * they are only tracked globally, not per node. SOSL rows are also excluded: + * they have no governor limit (only SOSL *queries* is limited, to 20) and do + * not count against the SOQL query-rows limit. */ const COST_METRICS: CostMetric[] = [ { label: 'SOQL', used: (r) => r.soqlCount.total, limit: (l) => l.soqlQueries.limit }, @@ -50,7 +52,6 @@ const COST_METRICS: CostMetric[] = [ { label: 'SOSL', used: (r) => r.soslCount.total, limit: (l) => l.soslQueries.limit }, { label: 'SOQL Rows', used: (r) => r.soqlRowCount.total, limit: (l) => l.queryRows.limit }, { label: 'DML Rows', used: (r) => r.dmlRowCount.total, limit: (l) => l.dmlRows.limit }, - { label: 'SOSL Rows', used: (r) => r.soslRowCount.total, limit: (l) => l.queryRows.limit }, { label: 'Heap', used: (r) => r.heapAllocated.total, limit: (l) => l.heapSize.limit }, ]; @@ -124,21 +125,12 @@ export function governorCostBreakdown( } /** - * Populates {@link GovernorCostRow.governorCost} across a call-tree row array - * and its `_children`. Runs once after the tree is built — governor cost is a - * pure function of already-aggregated totals, so it lives in the call-tree - * layer rather than the parser. + * Sets {@link GovernorCostRow.governorCost} and {@link GovernorCostRow.governorCostMax} + * on a single row from its already-aggregated totals. Called from each tree + * builder at the point a row is finalized, so governor cost is computed in the + * same pass that builds the tree (no separate traversal). */ -export function annotateGovernorCost( - rows: T[] | null | undefined, - limits: GovernorLimits, -): void { - if (!rows) { - return; - } - for (const row of rows) { - row.governorCost = governorCost(row, limits); - row.governorCostMax = governorCostMax(row, limits); - annotateGovernorCost(row._children, limits); - } +export function setGovernorCost(row: GovernorCostRow, limits: GovernorLimits): void { + row.governorCost = governorCost(row, limits); + row.governorCostMax = governorCostMax(row, limits); } diff --git a/log-viewer/src/features/call-tree/utils/TimeOrderTree.ts b/log-viewer/src/features/call-tree/utils/TimeOrderTree.ts index 296fc2ef..6df91415 100644 --- a/log-viewer/src/features/call-tree/utils/TimeOrderTree.ts +++ b/log-viewer/src/features/call-tree/utils/TimeOrderTree.ts @@ -1,10 +1,11 @@ /* * Copyright (c) 2026 Certinia Inc. All rights reserved. */ -import type { LogEvent, SelfTotal } from 'apex-log-parser'; +import type { GovernorLimits, LogEvent, SelfTotal } from 'apex-log-parser'; import { getCallerNamespace } from '../../../core/utility/CallerNamespace.js'; import { EXCLUDED_DETAIL_TYPES } from './DetailsFilter.js'; +import { setGovernorCost } from './GovernorCost.js'; /** * One row per LogEvent for the time-order view; no merging at any level. @@ -44,7 +45,10 @@ export interface TimeOrderRow { * use parser-assigned eventIndex values, which are globally unique within * the parse and safe for deepFilter cache keys. */ -export function toTimeOrderTree(nodes: LogEvent[]): TimeOrderRow[] | undefined { +export function toTimeOrderTree( + nodes: LogEvent[], + governorLimits?: GovernorLimits, +): TimeOrderRow[] | undefined { const len = nodes.length; if (!len) { return undefined; @@ -72,7 +76,7 @@ export function toTimeOrderTree(nodes: LogEvent[]): TimeOrderRow[] | undefined { duration.total > 0 || discontinuity || !!(type && EXCLUDED_DETAIL_TYPES.has(type)); - return { + const row: TimeOrderRow = { id, originalData: event, _children: mappedChildren, @@ -92,6 +96,10 @@ export function toTimeOrderTree(nodes: LogEvent[]): TimeOrderRow[] | undefined { governorCostMax: 0, _hasDetailsDeep: selfIsDetail || childHasDetailsDeep, }; + if (governorLimits) { + setGovernorCost(row, governorLimits); + } + return row; } const results = new Array(len); diff --git a/log-viewer/src/features/call-tree/utils/__tests__/GovernorCost.test.ts b/log-viewer/src/features/call-tree/utils/__tests__/GovernorCost.test.ts index 193b33e5..e1c73190 100644 --- a/log-viewer/src/features/call-tree/utils/__tests__/GovernorCost.test.ts +++ b/log-viewer/src/features/call-tree/utils/__tests__/GovernorCost.test.ts @@ -5,7 +5,6 @@ import { describe, expect, it } from '@jest/globals'; import type { GovernorLimits } from 'apex-log-parser'; import { - annotateGovernorCost, governorCost, governorCostBreakdown, governorCostMax, @@ -39,13 +38,14 @@ function row(overrides: Partial> = {}): GovernorCostRow { }; } -// COST_METRICS has 7 entries; limits() reports a limit for all 7 (SOQL Rows and -// SOSL Rows both use the queryRows limit), so the divisor is 7 in these tests. -const REPORTED_GOVERNORS = 7; +// COST_METRICS has 6 entries (SOQL, DML, SOSL, SOQL Rows, DML Rows, Heap); +// limits() reports a limit for all 6, so the divisor is 6 in these tests. SOSL +// rows are excluded — they have no governor limit. +const REPORTED_GOVERNORS = 6; describe('governorCost', () => { it('averages each governor as a percentage of its own limit, across all reported governors', () => { - // SOQL 50% + DML 10% + Heap 50%, four others 0% → (50 + 10 + 50) / 7 + // SOQL 50% + DML 10% + Heap 50%, three others 0% → (50 + 10 + 50) / 6 expect(governorCost(row({ soql: 50, dml: 15, heap: 3000000 }), limits())).toBeCloseTo( 110 / REPORTED_GOVERNORS, 5, @@ -59,7 +59,6 @@ describe('governorCost', () => { sosl: 20, soqlRows: 50000, dmlRows: 10000, - soslRows: 50000, heap: 6000000, }); expect(governorCost(maxed, limits())).toBeCloseTo(100, 5); @@ -121,14 +120,13 @@ describe('governorCostBreakdown', () => { }); }); -describe('annotateGovernorCost', () => { - it('populates governorCost across the row tree', () => { - type TreeRow = GovernorCostRow & { _children: TreeRow[] | null }; - const child: TreeRow = { ...row({ dml: 150 }), _children: null }; - const parent: TreeRow = { ...row({ soql: 25 }), _children: [child] }; - annotateGovernorCost([parent], limits()); - // Each averaged over the 7 reported governors: parent SOQL 25%, child DML 100%. - expect(parent.governorCost).toBeCloseTo(25 / REPORTED_GOVERNORS, 5); - expect(child.governorCost).toBeCloseTo(100 / REPORTED_GOVERNORS, 5); +describe('SOSL rows', () => { + it('do not contribute to governor cost — they have no governor limit', () => { + // SOSL rows are not governed (only SOSL queries, to 20) and do not count + // against the SOQL query-rows limit, so a path consuming only SOSL rows + // scores 0 across every measure. + expect(governorCost(row({ soslRows: 50000 }), limits())).toBe(0); + expect(governorCostMax(row({ soslRows: 50000 }), limits())).toBe(0); + expect(governorCostBreakdown(row({ soslRows: 50000 }), limits())).toEqual([]); }); }); diff --git a/log-viewer/src/features/database/components/DMLView.ts b/log-viewer/src/features/database/components/DMLView.ts index 278cedbf..fb83a727 100644 --- a/log-viewer/src/features/database/components/DMLView.ts +++ b/log-viewer/src/features/database/components/DMLView.ts @@ -20,6 +20,7 @@ import { DML_VIEWS, getColumnView, getTableFields, + resolveColumnView, toggleField, } from '../../../tabulator/ColumnViews.js'; @@ -112,7 +113,7 @@ export class DMLView extends LitElement { private async _loadColumnSettings(): Promise { const settings = await getSettings(); this.columnOverrides = settings.database?.dml?.columnOverrides ?? {}; - this._setColumnView(settings.database?.dml?.columnView ?? 'General'); + this._setColumnView(resolveColumnView(DML_VIEWS, settings.database?.dml?.columnView)); } updated(changedProperties: PropertyValues): void { diff --git a/log-viewer/src/features/database/components/SOQLView.ts b/log-viewer/src/features/database/components/SOQLView.ts index 321b1ff3..ee661f2d 100644 --- a/log-viewer/src/features/database/components/SOQLView.ts +++ b/log-viewer/src/features/database/components/SOQLView.ts @@ -26,6 +26,7 @@ import { buildColumnMenuItems, getColumnView, getTableFields, + resolveColumnView, SOQL_VIEWS, toggleField, } from '../../../tabulator/ColumnViews.js'; @@ -117,7 +118,7 @@ export class SOQLView extends LitElement { private async _loadColumnSettings(): Promise { const settings = await getSettings(); this.columnOverrides = settings.database?.soql?.columnOverrides ?? {}; - this._setColumnView(settings.database?.soql?.columnView ?? 'General'); + this._setColumnView(resolveColumnView(SOQL_VIEWS, settings.database?.soql?.columnView)); } updated(changedProperties: PropertyValues): void { diff --git a/log-viewer/src/tabulator/ColumnViews.ts b/log-viewer/src/tabulator/ColumnViews.ts index b3dad635..9f902c00 100644 --- a/log-viewer/src/tabulator/ColumnViews.ts +++ b/log-viewer/src/tabulator/ColumnViews.ts @@ -108,6 +108,15 @@ export function getColumnView(views: ColumnView[], id: string): ColumnView | und return views.find((view) => view.id === id); } +/** + * The persisted view id if it still matches a known view, else the first + * (default) view. Guards against a stale setting after a preset is renamed or + * removed — falling back to a curated view rather than showing every column. + */ +export function resolveColumnView(views: ColumnView[], id: string | undefined): string { + return views.find((view) => view.id === id)?.id ?? views[0]!.id; +} + /** * Shows/hides table columns to match `fields`. `null` shows every column * (the General view). `alwaysVisible` fields are shown regardless. Fields that @@ -131,9 +140,11 @@ export function applyColumnView( column.hide(); } } - // show()/hide() alone don't re-run the fitColumns width distribution, so the - // flex (Name) column wouldn't reclaim space freed by hidden columns. A light - // redraw re-lays-out columns and re-renders the visible window. + // Stock tabulator: column.show()/hide() flip cell visibility but do NOT re-run + // the fitColumns width distribution or normalizeHeight — the flex (Name) column + // wouldn't reclaim space freed by hidden columns and wrapped-row heights would + // go stale. redraw() runs layoutRefresh (widths + heights) and re-renders the + // visible window. Required — do not remove. table.redraw(); } diff --git a/log-viewer/src/tabulator/__tests__/ColumnViews.test.ts b/log-viewer/src/tabulator/__tests__/ColumnViews.test.ts index e14aea1b..2e3de171 100644 --- a/log-viewer/src/tabulator/__tests__/ColumnViews.test.ts +++ b/log-viewer/src/tabulator/__tests__/ColumnViews.test.ts @@ -12,6 +12,7 @@ import { getColumnView, getTableFields, getVisibleFields, + resolveColumnView, SOQL_VIEWS, toggleField, } from '../ColumnViews.js'; @@ -160,6 +161,17 @@ describe('buildColumnMenuItems', () => { }); }); +describe('resolveColumnView', () => { + it('returns the id when it matches a known view', () => { + expect(resolveColumnView(CALL_TREE_VIEWS, 'Governor Limits')).toBe('Governor Limits'); + }); + + it('falls back to the first view for an unknown or missing id', () => { + expect(resolveColumnView(CALL_TREE_VIEWS, 'Renamed')).toBe(CALL_TREE_VIEWS[0]!.id); + expect(resolveColumnView(SOQL_VIEWS, undefined)).toBe(SOQL_VIEWS[0]!.id); + }); +}); + describe('view sets', () => { it('each view set has a General view and unique ids', () => { for (const views of [CALL_TREE_VIEWS, SOQL_VIEWS, DML_VIEWS]) { From 5d7b5fd50e1c0bbc074eddd70e352d0077f18ebc Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Tue, 21 Jul 2026 15:42:01 +0100 Subject: [PATCH 6/6] test(timeline): cover heap reconciliation in the governor strip - add heapSize cases to governor-timeline.test: negative deltas (dealloc via negative HEAP_ALLOCATE) lower the line, absolute snapshots re-baseline to the cumulative peak, and a delta-less FINE log steps to cumulative with no grey divergence - clarify heap comments in apex-limit-series/governor-timeline: negative HEAP_ALLOCATE is the deallocation, HEAP_DEALLOCATE is distinct (no double-subtract), and cumulative 'Maximum heap size' is the authoritative peak --- .../timeline/optimised/apex-limit-series.ts | 5 ++- .../metric-strip/governor-timeline.test.ts | 44 +++++++++++++++++++ .../metric-strip/governor-timeline.ts | 5 ++- 3 files changed, 51 insertions(+), 3 deletions(-) diff --git a/log-viewer/src/features/timeline/optimised/apex-limit-series.ts b/log-viewer/src/features/timeline/optimised/apex-limit-series.ts index 35d117ce..b2cc5d80 100644 --- a/log-viewer/src/features/timeline/optimised/apex-limit-series.ts +++ b/log-viewer/src/features/timeline/optimised/apex-limit-series.ts @@ -160,13 +160,14 @@ export function buildApexLimitTimeSeries( case 'CALLOUT_REQUEST': pushDelta(timestamp, namespace, 'callouts', 1); break; + // A deallocation is normally a negative HEAP_ALLOCATE (|Bytes:-N|), so add as-is; + // HEAP_DEALLOCATE is a rarer distinct form. Each is one event type, so applying + // both cases never double-subtracts a free. case 'HEAP_ALLOCATE': case 'BULK_HEAP_ALLOCATE': - // Allocation bytes can be negative in the log, so add as-is (a negative lowers heap). pushDelta(timestamp, namespace, 'heapSize', (event as HeapAllocateLine).bytes); break; case 'HEAP_DEALLOCATE': - // Deallocation always takes away. pushDelta(timestamp, namespace, 'heapSize', -Math.abs((event as HeapAllocateLine).bytes)); break; case 'LIMIT_USAGE': diff --git a/log-viewer/src/features/timeline/optimised/metric-strip/governor-timeline.test.ts b/log-viewer/src/features/timeline/optimised/metric-strip/governor-timeline.test.ts index 69a7d1bf..6260d179 100644 --- a/log-viewer/src/features/timeline/optimised/metric-strip/governor-timeline.test.ts +++ b/log-viewer/src/features/timeline/optimised/metric-strip/governor-timeline.test.ts @@ -9,13 +9,16 @@ const METRICS = new Map([ ['soqlQueries', { id: 'soqlQueries', displayName: 'SOQL Queries', unit: '', priority: 1 }], ['queryRows', { id: 'queryRows', displayName: 'Query Rows', unit: '', priority: 2 }], ['cpuTime', { id: 'cpuTime', displayName: 'CPU Time', unit: 'ms', priority: 0 }], + ['heapSize', { id: 'heapSize', displayName: 'Heap Size', unit: 'bytes', priority: 3 }], ]); // Authoritative fixed limits. soqlQueries → threshold 1 (per-event); queryRows → threshold 100. +// heapSize → threshold floor(6000000/500) = 12000, so heap deltas below use ≥12000 magnitudes. const LIMITS = new Map([ ['soqlQueries', 100], ['queryRows', 50000], ['cpuTime', 10000], + ['heapSize', 6000000], ]); const delta = ( @@ -118,3 +121,44 @@ describe('buildGovernorTimeSeries', () => { expect(series.events.length).toBe(5); }); }); + +// Heap deltas can go negative (a deallocation is a negative HEAP_ALLOCATE), and its +// cumulative "Maximum heap size" correction is often the only source (FINE logs emit +// no HEAP_ALLOCATE events). +describe('buildGovernorTimeSeries — heap reconciliation', () => { + const heapUsed = (obs: LimitObservation[]) => + build(obs).events.map((e) => e.values.get('heapSize')?.used); + const heapTracked = (obs: LimitObservation[]) => + build(obs).events.map((e) => e.values.get('heapSize')?.tracked); + + it('falls on a negative delta (deallocation via negative HEAP_ALLOCATE)', () => { + const obs = [ + delta(10, 'heapSize', 100000), + delta(20, 'heapSize', 50000), + delta(30, 'heapSize', -40000), + ]; + expect(heapUsed(obs)).toEqual([100000, 150000, 110000]); + expect(heapTracked(obs)).toEqual([undefined, undefined, undefined]); + }); + + it('re-baselines to the authoritative cumulative, then keeps tracking, flagging divergence', () => { + const obs = [ + delta(10, 'heapSize', 50000), + absolute(20, 'heapSize', 200000), // peak > tracked 50000: snaps up, then +30000 + delta(30, 'heapSize', 30000), + ]; + expect(heapUsed(obs)).toEqual([50000, 200000, 230000]); + // Our lower observed count is surfaced (grey) once cumulative-anchored. + expect(heapTracked(obs)).toEqual([undefined, 50000, 80000]); + }); + + it('steps to the cumulative with no divergence when there are no HEAP_ALLOCATE events (FINE log)', () => { + const obs = [ + absolute(10, 'heapSize', 0), + absolute(20, 'heapSize', 219591), + absolute(30, 'heapSize', 219591), + ]; + expect(heapUsed(obs)).toEqual([0, 219591, 219591]); + expect(heapTracked(obs)).toEqual([undefined, undefined, undefined]); + }); +}); diff --git a/log-viewer/src/features/timeline/optimised/metric-strip/governor-timeline.ts b/log-viewer/src/features/timeline/optimised/metric-strip/governor-timeline.ts index 7cedb45d..e006248f 100644 --- a/log-viewer/src/features/timeline/optimised/metric-strip/governor-timeline.ts +++ b/log-viewer/src/features/timeline/optimised/metric-strip/governor-timeline.ts @@ -9,13 +9,16 @@ * time series for the metric strip. Boundary-safe: no parser imports. * * Model (per namespace, per metric): - * - `tracked` — increment-only running total from detailed events we count. Never corrected. + * - `tracked` — running total of counted deltas. Counters only increment; heap deltas may go + * negative (a deallocation is a negative HEAP_ALLOCATE), so the heap line can fall. * - `displayed` = baseline + (tracked − trackedAtBaseline), the value drawn on the line. * * Two observation kinds: * - `delta` — adds to `tracked` (and therefore to `displayed`); marks the metric delta-tracked. * - `absolute` — corrective. Sets the baseline to `max(reported, displayed)`. The `max` floor keeps * monotonic counters from dipping when a subset-scoped report or fluctuating heap comes in low. + * For heap, `reported` is the authoritative cumulative "Maximum heap size" (a peak, often our only + * source since FINE logs emit no HEAP_ALLOCATE events). * * At each observation timestamp a single combined point is emitted, summing the last-known * per-namespace values (carry-forward step merge). `tracked` is surfaced on the emitted value only