diff --git a/README.md b/README.md index 01309b7..87f8315 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ Light Query Profiler works with [Extended Events](https://docs.microsoft.com/en- - Sortable, resizable event columns - Detailed event inspection with tabbed view - Cross-platform support: Windows, Linux, and macOS +- Export and import captured events as JSON files for offline analysis and sharing --- @@ -63,6 +64,15 @@ Light Query Profiler is available on the **Visual Studio Code Marketplace**: - Authentication mode and credentials 5. Click **Start** to begin profiling +### Export & Import Events + +Captured events can be **exported to a JSON file** for offline analysis, sharing, or archiving — and **imported back** at any time without needing an active SQL Server connection. + +- Click **⬆ Export...** in the toolbar, or use **Light Query Profiler: Export Events...** from the Command Palette +- Click **⬇ Import...** in the toolbar, or use **Light Query Profiler: Import Events...** from the Command Palette + +The exported JSON format is compatible between the VS Code extension and the desktop application. + --- ## Requirements diff --git a/vscode-extension/CHANGELOG.md b/vscode-extension/CHANGELOG.md index 06c4da9..5a2746c 100644 --- a/vscode-extension/CHANGELOG.md +++ b/vscode-extension/CHANGELOG.md @@ -5,6 +5,20 @@ All notable changes to the Light Query Profiler extension will be documented in The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.1.0] - 2026-03-27 + +### Added +- Export profiling events to a JSON file via the toolbar **Export...** button or the `Light Query Profiler: Export Events` palette command +- Import profiling events from a JSON file via the toolbar **Import...** button or the `Light Query Profiler: Import Events` palette command +- New `EventExportImportService` responsible for serializing/deserializing events, preserving row order (`__RowIndex`) and timestamps (`__Timestamp`) +- Confirmation dialog when importing events over an existing session (replace or cancel) +- Pending-import handshake: events imported while the profiler panel is closed are automatically loaded once the panel is opened +- Host-side `capturedEvents` mirror (up to 10,000 events) used as the source of truth for exports, keeping the extension host and webview in sync + +### Changed +- **Export** and **Import** toolbar buttons are enabled only when the profiler is in the `stopped` state, preventing data corruption during live or paused sessions +- `README.md` and root `README.md` updated with an "Export & Import Events" section describing usage and the JSON format + ## [1.0.1] - 2026-03-24 ### Changed diff --git a/vscode-extension/README.md b/vscode-extension/README.md index d3addeb..7d9a7bd 100644 --- a/vscode-extension/README.md +++ b/vscode-extension/README.md @@ -10,6 +10,8 @@ A SQL Server and Azure SQL Database query profiler for Visual Studio Code, power - Event filtering and full-text search - Sortable, resizable event columns - Detailed event inspection with tabbed view +- Export captured events to a JSON file for offline analysis or sharing +- Import previously exported events without needing an active SQL Server connection ## Requirements @@ -28,6 +30,35 @@ A SQL Server and Azure SQL Database query profiler for Visual Studio Code, power - Authentication mode and credentials 5. Click **Start** to begin profiling +## Export & Import Events + +Light Query Profiler lets you save captured events to a JSON file and reload them later — no active SQL Server connection required. + +### Exporting Events + +1. Capture events by starting a profiling session +2. Click **⬆ Export...** in the toolbar, or run **Light Query Profiler: Export Events...** from the Command Palette (`Ctrl+Shift+P`) +3. Choose a destination and file name — the default is `ProfilerEvents_yyyyMMdd_HHmmss.json` +4. A confirmation shows the number of events exported + +> **Note:** Up to 10,000 events are kept in memory per session. If more events are captured, the oldest ones are automatically removed. + +### Importing Events + +1. Click **⬇ Import...** in the toolbar, or run **Light Query Profiler: Import Events...** from the Command Palette +2. Select a previously exported JSON file +3. If events are already loaded, you will be asked to confirm the replacement +4. The imported events appear in the table immediately, with full search, sort, and filter support + +### JSON File Format + +The exported JSON is a plain array where each entry contains the event fields (EventClass, TextData, ApplicationName, Duration, CPU, Reads, Writes, etc.) plus two metadata fields: + +- `__RowIndex` — preserves the original capture order +- `__Timestamp` — copy of the event start time for alternative sorting + +The format is compatible with events exported from the **Light Query Profiler desktop application**. + ## Authentication Modes | Mode | Description | diff --git a/vscode-extension/package.json b/vscode-extension/package.json index 1159371..f73a4c8 100644 --- a/vscode-extension/package.json +++ b/vscode-extension/package.json @@ -2,7 +2,7 @@ "name": "light-query-profiler", "displayName": "Light Query Profiler", "description": "SQL Server and Azure SQL Database query profiler for VS Code", - "version": "1.0.1", + "version": "1.1.0", "publisher": "brandochn", "author": { "name": "Hildebrando Chávez", @@ -40,6 +40,18 @@ "light": "media/icon-small.svg", "dark": "media/icon-small.svg" } + }, + { + "command": "lightQueryProfiler.exportEvents", + "title": "Export Events...", + "category": "Light Query Profiler", + "icon": "$(arrow-up)" + }, + { + "command": "lightQueryProfiler.importEvents", + "title": "Import Events...", + "category": "Light Query Profiler", + "icon": "$(arrow-down)" } ] }, diff --git a/vscode-extension/src/extension.ts b/vscode-extension/src/extension.ts index 33d52b0..ca2bda2 100644 --- a/vscode-extension/src/extension.ts +++ b/vscode-extension/src/extension.ts @@ -56,6 +56,36 @@ export async function activate( // The handler checks whether the provider is ready and either shows the // panel or queues a retry once initialization completes. let activationReady = false; + const exportEventsCommand = vscode.commands.registerCommand( + 'lightQueryProfiler.exportEvents', + () => { + log.info('Export Events command executed'); + if (state.profilerPanelProvider) { + void state.profilerPanelProvider.exportEvents(); + } else { + void vscode.window.showErrorMessage( + 'Light Query Profiler: Extension is not initialized.', + ); + } + }, + ); + context.subscriptions.push(exportEventsCommand); + + const importEventsCommand = vscode.commands.registerCommand( + 'lightQueryProfiler.importEvents', + () => { + log.info('Import Events command executed'); + if (state.profilerPanelProvider) { + void state.profilerPanelProvider.importEvents(); + } else { + void vscode.window.showErrorMessage( + 'Light Query Profiler: Extension is not initialized.', + ); + } + }, + ); + context.subscriptions.push(importEventsCommand); + const showProfilerCommand = vscode.commands.registerCommand( 'lightQueryProfiler.showProfiler', () => { @@ -75,7 +105,10 @@ export async function activate( }, 50); // Safety: stop polling after 10 s regardless // eslint-disable-next-line prefer-const - const deferredTimeout = setTimeout(() => clearInterval(deferredInterval), 10_000); + const deferredTimeout = setTimeout( + () => clearInterval(deferredInterval), + 10_000, + ); // Register both handles so they are cancelled if the extension is // deactivated within the 10-second initialization window. context.subscriptions.push({ @@ -150,14 +183,19 @@ export async function activate( log.info('Light Query Profiler extension activated successfully'); // Show welcome message only on first activation - const hasShownWelcomeMessage = context.globalState.get('hasShownWelcomeMessage', false); + const hasShownWelcomeMessage = context.globalState.get( + 'hasShownWelcomeMessage', + false, + ); if (!hasShownWelcomeMessage) { - void vscode.window.showInformationMessage( - "Light Query Profiler is ready! Run 'Show SQL Profiler' command to open the profiler.", - ).then(() => { - // Mark as shown after user dismisses or acknowledges the message - void context.globalState.update('hasShownWelcomeMessage', true); - }); + void vscode.window + .showInformationMessage( + "Light Query Profiler is ready! Run 'Show SQL Profiler' command to open the profiler.", + ) + .then(() => { + // Mark as shown after user dismisses or acknowledges the message + void context.globalState.update('hasShownWelcomeMessage', true); + }); } } catch (error) { activationReady = true; // Stop the deferred-panel polling diff --git a/vscode-extension/src/services/event-export-import.service.ts b/vscode-extension/src/services/event-export-import.service.ts new file mode 100644 index 0000000..01b5937 --- /dev/null +++ b/vscode-extension/src/services/event-export-import.service.ts @@ -0,0 +1,280 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +/** + * Represents a single profiler event row as displayed in the events table. + * All fields are strings to match the webview's flat display format. + */ +export interface DisplayEvent { + /** SQL event type (e.g. 'sql_batch_completed', 'rpc_completed') */ + eventClass: string; + /** SQL query text */ + textData: string; + /** Application name that issued the query */ + applicationName: string; + /** Client host name */ + hostName: string; + /** Windows NT user name */ + ntUserName: string; + /** SQL login name */ + loginName: string; + /** Client process ID */ + clientProcessId: string; + /** Session/SPID identifier */ + spid: string; + /** Event start timestamp (ISO 8601) */ + startTime: string; + /** CPU time in microseconds */ + cpu: string; + /** Logical reads count */ + reads: string; + /** Writes count */ + writes: string; + /** Duration in microseconds */ + duration: string; + /** Database ID */ + databaseId: string; + /** Database name */ + databaseName: string; +} + +/** + * Result returned by a successful import operation. + */ +export interface ImportResult { + /** + * Imported events in display format, ordered by `__RowIndex` when available, + * otherwise in the original JSON array order. + */ + events: DisplayEvent[]; +} + +/** + * Internal wire format written to JSON — extends DisplayEvent with row-order metadata. + * Fields prefixed with `__` are stripped on import and never shown in the UI. + */ +/* eslint-disable @typescript-eslint/naming-convention */ +interface SerializedEvent extends DisplayEvent { + /** Zero-based position of this row at export time — used to restore order on import */ + __RowIndex: number; + /** Copy of startTime kept at the top level for easy sorting without parsing */ + __Timestamp: string; +} +/* eslint-enable @typescript-eslint/naming-convention */ + +/** + * Service for exporting and importing profiler events to and from JSON files. + * + * @remarks + * All methods are static. File I/O uses the Node `fs.promises` API so the + * extension host event loop is never blocked. The JSON format is compatible + * with files produced by the Light Query Profiler desktop (WinForms) application. + * + * @example + * ```typescript + * // Export + * await EventExportImportService.exportEvents(events, '/tmp/session.json'); + * + * // Import + * const { events } = await EventExportImportService.importEvents('/tmp/session.json'); + * ``` + */ +export class EventExportImportService { + /** + * Maximum number of events stored in one export file. + * Matches the `MAX_EVENTS` cap used by the webview's `allEvents` array. + */ + static readonly maxExportEvents = 10_000; + + // ── Export ──────────────────────────────────────────────────────────────── + + /** + * Serialises `events` as a JSON array and writes the result to `filePath`. + * + * Each element in the output array contains all `DisplayEvent` fields plus: + * - `__RowIndex` — the element's position in the original array (0-based) + * - `__Timestamp` — a copy of `startTime` for convenient sorting + * + * @param events - Snapshot of captured events to persist. + * @param filePath - Absolute path of the destination `.json` file. + * + * @throws {Error} When `filePath` is empty. + * @throws {Error} When `events` is empty. + * @throws {Error} When the file cannot be written (permission denied, disk full, …). + */ + public static async exportEvents( + events: ReadonlyArray, + filePath: string, + ): Promise { + if (!filePath || filePath.trim().length === 0) { + throw new Error('File path is required'); + } + + if (!events || events.length === 0) { + throw new Error('No events to export'); + } + + const serialized: SerializedEvent[] = events.map((event, index) => { + /* eslint-disable @typescript-eslint/naming-convention */ + const entry: SerializedEvent = { + __RowIndex: index, + __Timestamp: event.startTime, + ...event, + }; + /* eslint-enable @typescript-eslint/naming-convention */ + return entry; + }); + + const json = JSON.stringify(serialized, null, 2); + + try { + await fs.promises.writeFile(filePath, json, { + encoding: 'utf8', + flag: 'w', + }); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === 'EACCES' || code === 'EPERM') { + throw new Error( + `Permission denied writing to: ${path.basename(filePath)}`, + ); + } + throw new Error(`Failed to write file: ${(error as Error).message}`); + } + } + + // ── Import ──────────────────────────────────────────────────────────────── + + /** + * Reads a JSON file produced by `exportEvents` (or the WinForms app) and + * returns the events sorted by `__RowIndex` when that metadata is present. + * + * Field mapping supports both camelCase (VS Code extension) and PascalCase + * (WinForms desktop) field names so files can be shared across platforms. + * Metadata fields (`__RowIndex`, `__Timestamp`) are excluded from the result. + * + * @param filePath - Absolute path of the source `.json` file. + * @returns `ImportResult` containing the ordered `DisplayEvent` array. + * + * @throws {Error} When `filePath` is empty. + * @throws {Error} When the file does not exist. + * @throws {Error} When the file cannot be read. + * @throws {Error} When the file content is not valid JSON. + * @throws {Error} When the JSON root is not an array. + * @throws {Error} When the JSON array is empty. + */ + public static async importEvents(filePath: string): Promise { + if (!filePath || filePath.trim().length === 0) { + throw new Error('File path is required'); + } + + // ── Read file ────────────────────────────────────────────────────────── + let content: string; + try { + content = await fs.promises.readFile(filePath, 'utf8'); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === 'ENOENT') { + throw new Error(`File not found: ${path.basename(filePath)}`); + } + throw new Error(`Cannot read file: ${(error as Error).message}`); + } + + // ── Parse JSON ───────────────────────────────────────────────────────── + let parsed: unknown; + try { + parsed = JSON.parse(content); + } catch { + throw new Error( + 'Invalid JSON file. Please select a valid profiler events file.', + ); + } + + if (!Array.isArray(parsed)) { + throw new Error('Invalid format: expected a JSON array of events.'); + } + + if (parsed.length === 0) { + throw new Error('The selected file contains no events.'); + } + + // ── Sort by __RowIndex (stable; falls back to JSON array order) ──────── + // Cast to unknown[] explicitly so the spread below is type-safe and does + // not trigger @typescript-eslint/no-unsafe-assignment (Array.isArray() + // narrows `unknown` to `any[]` in TypeScript, not `unknown[]`). + const items = parsed as unknown[]; + const sorted = items.slice().sort((a, b) => { + const ra = a as Record; + const rb = b as Record; + const aIdx = + typeof ra['__RowIndex'] === 'number' + ? ra['__RowIndex'] + : Number.MAX_SAFE_INTEGER; + const bIdx = + typeof rb['__RowIndex'] === 'number' + ? rb['__RowIndex'] + : Number.MAX_SAFE_INTEGER; + return aIdx - bIdx; + }); + + // ── Map to DisplayEvent ──────────────────────────────────────────────── + // Supports camelCase (VS Code) and PascalCase / legacy (WinForms) keys. + const events: DisplayEvent[] = sorted.map((item) => { + const r = item as Record; + + /** + * Returns the first non-empty string value found among the given keys, + * or an empty string when none match. + */ + const str = (...keys: string[]): string => { + for (const k of keys) { + const v = r[k]; + if (v !== undefined && v !== null && String(v).trim().length > 0) { + return String(v); + } + } + return ''; + }; + + return { + eventClass: str('eventClass', 'EventClass', 'EventName'), + textData: str('textData', 'TextData'), + applicationName: str('applicationName', 'ApplicationName'), + hostName: str('hostName', 'HostName'), + ntUserName: str('ntUserName', 'NTUserName'), + loginName: str('loginName', 'LoginName'), + clientProcessId: str( + 'clientProcessId', + 'ClientProcessId', + 'ClientProcessID', + ), + spid: str('spid', 'Spid', 'SPID'), + startTime: str('startTime', '__Timestamp', 'StartTime'), + cpu: str('cpu', 'CPU'), + reads: str('reads', 'Reads'), + writes: str('writes', 'Writes'), + duration: str('duration', 'Duration'), + databaseId: str('databaseId', 'DatabaseId', 'DatabaseID'), + databaseName: str('databaseName', 'DatabaseName'), + }; + }); + + return { events }; + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + /** + * Generates a timestamped default filename for an export operation. + * + * @returns A string in the format `ProfilerEvents_yyyyMMdd_HHmmss.json`, + * e.g. `ProfilerEvents_20250115_143022.json`. + */ + public static generateDefaultFilename(): string { + const now = new Date(); + const pad = (n: number): string => String(n).padStart(2, '0'); + const datePart = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}`; + const timePart = `${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`; + return `ProfilerEvents_${datePart}_${timePart}.json`; + } +} diff --git a/vscode-extension/src/views/profiler-panel-provider.ts b/vscode-extension/src/views/profiler-panel-provider.ts index 0bd49c6..cdbf103 100644 --- a/vscode-extension/src/views/profiler-panel-provider.ts +++ b/vscode-extension/src/views/profiler-panel-provider.ts @@ -1,5 +1,10 @@ import * as vscode from 'vscode'; +import * as path from 'path'; import { ProfilerClient } from '../services/profiler-client'; +import { + EventExportImportService, + DisplayEvent, +} from '../services/event-export-import.service'; import { AuthenticationMode, getAllAuthenticationModes, @@ -44,7 +49,17 @@ interface EventFilter { * Message types sent from webview to extension */ interface WebviewIncomingMessage { - command: 'start' | 'stop' | 'pause' | 'resume' | 'clear' | 'applyFilters' | 'clearFilters'; + command: + | 'start' + | 'stop' + | 'pause' + | 'resume' + | 'clear' + | 'applyFilters' + | 'clearFilters' + | 'exportEvents' + | 'importEvents' + | 'webviewReady'; data?: ConnectionSettings | EventFilter; } @@ -59,7 +74,8 @@ interface WebviewOutgoingMessage { | 'clearEvents' | 'updateFilter' | 'error' - | 'setConnectionFieldsEnabled'; + | 'setConnectionFieldsEnabled' + | 'loadImportedEvents'; data?: unknown; } @@ -92,6 +108,27 @@ export class ProfilerPanelProvider { databaseName: '', }; + /** + * Host-side mirror of the webview's `allEvents` array. + * Populated in `pollEvents()` (post-filter) and replaced on import. + * Cleared in `handleStart()` and `handleClear()` to stay in sync with the webview. + * Capped at `maxCapturedEvents` to prevent unbounded memory growth in the host. + */ + private capturedEvents: DisplayEvent[] = []; + + /** + * Maximum number of events kept in `capturedEvents`. + * Matches the `MAX_EVENTS` cap used by the webview's `allEvents` array. + */ + private static readonly maxCapturedEvents = 10_000; + + /** + * Events waiting to be sent to the webview after it signals readiness via `webviewReady`. + * Set by `importEvents()` when the panel is not yet open; cleared by the + * `webviewReady` handler once the data has been forwarded. + */ + private pendingImportEvents: DisplayEvent[] | null = null; + constructor( extensionUri: vscode.Uri, profilerClient: ProfilerClient, @@ -163,7 +200,9 @@ export class ProfilerPanelProvider { // Stop polling and terminate the XEvent session on SQL Server so it // is not orphaned when the user closes the panel tab. void this.handleStop().catch((err) => { - this.logError(`Error stopping profiler on panel dispose: ${String(err)}`); + this.logError( + `Error stopping profiler on panel dispose: ${String(err)}`, + ); }); } else { this.stopPolling(); @@ -210,6 +249,24 @@ export class ProfilerPanelProvider { case 'clearFilters': await this.handleClearFilters(); break; + case 'exportEvents': + await this.exportEvents(); + break; + case 'importEvents': + await this.importEvents(); + break; + case 'webviewReady': + // If importEvents() stored pending data while the panel was opening, + // forward it now that the webview has signalled it is ready. + if (this.pendingImportEvents) { + const pending = this.pendingImportEvents; + this.pendingImportEvents = null; + await this.postMessage({ + command: 'loadImportedEvents', + data: pending, + }); + } + break; default: this.logError(`Unknown command: ${String(message.command)}`); } @@ -253,6 +310,7 @@ export class ProfilerPanelProvider { // Clear previous events before showing new session results this.eventCount = 0; this.sessionEventKeys.clear(); + this.capturedEvents = []; await this.postMessage({ command: 'clearEvents' }); // Update state and disable connection fields while profiling is active @@ -369,6 +427,7 @@ export class ProfilerPanelProvider { private async handleClear(): Promise { this.log('Clearing events'); this.eventCount = 0; + this.capturedEvents = []; // sessionEventKeys intentionally NOT cleared — session cache must survive Clear // so that already-seen ring_buffer events cannot re-appear after a clear. await this.postMessage({ @@ -383,9 +442,7 @@ export class ProfilerPanelProvider { */ private async handleApplyFilters(filter: EventFilter): Promise { this.eventFilter = filter; - this.log( - `Filters applied: ${JSON.stringify(filter)}`, - ); + this.log(`Filters applied: ${JSON.stringify(filter)}`); await this.postMessage({ command: 'updateFilter', data: filter }); } @@ -487,30 +544,21 @@ export class ProfilerPanelProvider { return; } - const newEvents: Array<{ - eventClass: string; - textData: string; - applicationName: string; - hostName: string; - ntUserName: string; - loginName: string; - clientProcessId: string; - spid: string; - startTime: string; - cpu: string; - reads: string; - writes: string; - duration: string; - databaseId: string; - databaseName: string; - }> = []; + const newEvents: DisplayEvent[] = []; // Helper to get a string value from fields or actions (all values come as strings from the XML parser) - const str = (obj: Record | undefined, ...keys: string[]): string => { - if (!obj) { return ''; } + const str = ( + obj: Record | undefined, + ...keys: string[] + ): string => { + if (!obj) { + return ''; + } for (const k of keys) { const v = obj[k]; - if (v !== undefined && v !== null && String(v).length > 0) { return String(v); } + if (v !== undefined && v !== null && String(v).length > 0) { + return String(v); + } } return ''; }; @@ -523,30 +571,30 @@ export class ProfilerPanelProvider { const textData = str(f, 'options_text', 'batch_text', 'statement'); const displayEvent = { - eventClass: event.name ?? 'Unknown', + eventClass: event.name ?? 'Unknown', textData, applicationName: str(a, 'client_app_name'), - hostName: str(a, 'client_hostname'), - ntUserName: str(a, 'nt_username'), - loginName: str(a, 'server_principal_name', 'username'), + hostName: str(a, 'client_hostname'), + ntUserName: str(a, 'nt_username'), + loginName: str(a, 'server_principal_name', 'username'), clientProcessId: str(a, 'client_pid'), - spid: str(a, 'session_id'), - startTime: event.timestamp ?? '', - cpu: str(f, 'cpu_time'), - reads: str(f, 'logical_reads'), - writes: str(f, 'writes'), - duration: str(f, 'duration'), - databaseId: str(f, 'database_id'), - databaseName: str(a, 'database_name'), + spid: str(a, 'session_id'), + startTime: event.timestamp ?? '', + cpu: str(f, 'cpu_time'), + reads: str(f, 'logical_reads'), + writes: str(f, 'writes'), + duration: str(f, 'duration'), + databaseId: str(f, 'database_id'), + databaseName: str(a, 'database_name'), }; // Dedup key — mirrors ProfilerEvent.GetEventKey() priority exactly: // 1. event_sequence (unique counter per session, most reliable) // 2. attach_activity_id (GUID, unique per activity) // 3. timestamp|name|session_id (weakest, same format as C# fallback) - const seqKey = str(a, 'event_sequence'); + const seqKey = str(a, 'event_sequence'); const activityKey = str(a, 'attach_activity_id'); - const sessionId = str(a, 'session_id'); + const sessionId = str(a, 'session_id'); const eventKey = seqKey ? `seq:${seqKey}` : activityKey @@ -565,12 +613,12 @@ export class ProfilerPanelProvider { const contains = (value: string, term: string): boolean => !term || value.toLowerCase().includes(term.toLowerCase()); if ( - !contains(displayEvent.eventClass, fil.eventClass) || - !contains(displayEvent.textData, fil.textData) || + !contains(displayEvent.eventClass, fil.eventClass) || + !contains(displayEvent.textData, fil.textData) || !contains(displayEvent.applicationName, fil.applicationName) || - !contains(displayEvent.ntUserName, fil.ntUserName) || - !contains(displayEvent.loginName, fil.loginName) || - !contains(displayEvent.databaseName, fil.databaseName) + !contains(displayEvent.ntUserName, fil.ntUserName) || + !contains(displayEvent.loginName, fil.loginName) || + !contains(displayEvent.databaseName, fil.databaseName) ) { continue; } @@ -590,6 +638,19 @@ export class ProfilerPanelProvider { command: 'updateEventCount', data: this.eventCount, }); + + // Sync host-side captured events — only post-filter events that were + // actually sent to the webview are stored here. Capped at + // maxCapturedEvents to match the webview's allEvents cap. + this.capturedEvents.push(...newEvents); + if ( + this.capturedEvents.length > ProfilerPanelProvider.maxCapturedEvents + ) { + this.capturedEvents = this.capturedEvents.slice( + this.capturedEvents.length - + ProfilerPanelProvider.maxCapturedEvents, + ); + } } } catch (error) { this.logError(`Error polling events: ${String(error)}`); @@ -610,6 +671,136 @@ export class ProfilerPanelProvider { await vscode.window.showErrorMessage(`Light Query Profiler: ${message}`); } + /** + * Exports captured events to a JSON file chosen via a VS Code save dialog. + * + * @remarks + * Uses `capturedEvents` (host-side mirror) so the panel does not need to be + * open. Shows an informational message when there are no events to export. + * Called both from the webview toolbar button (`exportEvents` message) and + * from the `lightQueryProfiler.exportEvents` palette command. + */ + public async exportEvents(): Promise { + if (this.capturedEvents.length === 0) { + await vscode.window.showInformationMessage( + 'Light Query Profiler: No events to export.', + ); + return; + } + + const defaultUri = vscode.Uri.file( + EventExportImportService.generateDefaultFilename(), + ); + + const uri = await vscode.window.showSaveDialog({ + defaultUri, + // eslint-disable-next-line @typescript-eslint/naming-convention + filters: { 'JSON Files': ['json'], 'All Files': ['*'] }, + title: 'Export Profiler Events', + saveLabel: 'Export', + }); + + if (!uri) { + return; // User cancelled + } + + try { + await EventExportImportService.exportEvents( + this.capturedEvents, + uri.fsPath, + ); + const count = this.capturedEvents.length; + this.log(`Exported ${count} events to ${uri.fsPath}`); + await vscode.window.showInformationMessage( + `Light Query Profiler: Exported ${count} event(s) to ${path.basename(uri.fsPath)}`, + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.logError(`Export failed: ${message}`); + await vscode.window.showErrorMessage( + `Light Query Profiler: Export failed — ${message}`, + ); + } + } + + /** + * Imports events from a JSON file chosen via a VS Code open dialog. + * + * @remarks + * If the panel is already open the imported events are sent directly via + * `loadImportedEvents`. If not, the panel is opened and the events are + * stored in `pendingImportEvents`; the `webviewReady` handshake then + * forwards them once the webview has finished initialising. + * Called both from the webview toolbar button (`importEvents` message) and + * from the `lightQueryProfiler.importEvents` palette command. + */ + public async importEvents(): Promise { + const uris = await vscode.window.showOpenDialog({ + canSelectFiles: true, + canSelectFolders: false, + canSelectMany: false, + // eslint-disable-next-line @typescript-eslint/naming-convention + filters: { 'JSON Files': ['json'], 'All Files': ['*'] }, + title: 'Import Profiler Events', + openLabel: 'Import', + }); + + if (!uris || uris.length === 0) { + return; // User cancelled + } + + const selectedUri = uris[0]; + if (!selectedUri) { + return; // Should never happen given the length check above + } + + // Confirm replacement when events are already loaded + if (this.capturedEvents.length > 0) { + const answer = await vscode.window.showWarningMessage( + `This will replace ${this.capturedEvents.length} existing event(s). Continue?`, + { modal: true }, + 'Replace', + ); + if (answer !== 'Replace') { + return; + } + } + + try { + const result = await EventExportImportService.importEvents( + selectedUri.fsPath, + ); + const imported = result.events; + + // Update host-side state so subsequent exports reflect the imported data + this.capturedEvents = [...imported]; + this.eventCount = imported.length; + + if (this.panel) { + // Panel is already open — send directly + await this.postMessage({ + command: 'loadImportedEvents', + data: imported, + }); + } else { + // Panel not yet open — store as pending; webviewReady will forward + this.pendingImportEvents = imported; + this.showPanel(); + } + + this.log(`Imported ${imported.length} events from ${selectedUri.fsPath}`); + await vscode.window.showInformationMessage( + `Light Query Profiler: Imported ${imported.length} event(s) from ${path.basename(selectedUri.fsPath)}`, + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.logError(`Import failed: ${message}`); + await vscode.window.showErrorMessage( + `Light Query Profiler: Import failed — ${message}`, + ); + } + } + /** * Posts a message to the webview * @param message - Message to send to the webview @@ -680,15 +871,25 @@ export class ProfilerPanelProvider { private getHtmlContent(webview: vscode.Webview): string { const authModes = getAllAuthenticationModes(); - const hlJsUri = webview.asWebviewUri( - vscode.Uri.joinPath(this.extensionUri, 'media', 'highlight.min.js'), - ).toString(); - const hlSqlUri = webview.asWebviewUri( - vscode.Uri.joinPath(this.extensionUri, 'media', 'highlight-sql.min.js'), - ).toString(); - const hlCssUri = webview.asWebviewUri( - vscode.Uri.joinPath(this.extensionUri, 'media', 'highlight-vs2015.min.css'), - ).toString(); + const hlJsUri = webview + .asWebviewUri( + vscode.Uri.joinPath(this.extensionUri, 'media', 'highlight.min.js'), + ) + .toString(); + const hlSqlUri = webview + .asWebviewUri( + vscode.Uri.joinPath(this.extensionUri, 'media', 'highlight-sql.min.js'), + ) + .toString(); + const hlCssUri = webview + .asWebviewUri( + vscode.Uri.joinPath( + this.extensionUri, + 'media', + 'highlight-vs2015.min.css', + ), + ) + .toString(); return ` @@ -1614,6 +1815,13 @@ export class ProfilerPanelProvider { +
+ + @@ -1812,6 +2020,8 @@ export class ProfilerPanelProvider { // Filter controls const filterBtn = document.getElementById('filterBtn'); const clearFilterBtn = document.getElementById('clearFilterBtn'); + const exportBtn = document.getElementById('exportBtn'); + const importBtn = document.getElementById('importBtn'); const filterModalOverlay = document.getElementById('filterModalOverlay'); const filterCloseBtn = document.getElementById('filterCloseBtn'); const filterApplyBtn = document.getElementById('filterApplyBtn'); @@ -1956,6 +2166,8 @@ export class ProfilerPanelProvider { resumeBtn.addEventListener('click', () => vscode.postMessage({ command: 'resume' })); stopBtn.addEventListener('click', () => vscode.postMessage({ command: 'stop' })); clearBtn.addEventListener('click', () => vscode.postMessage({ command: 'clear' })); + exportBtn.addEventListener('click', () => vscode.postMessage({ command: 'exportEvents' })); + importBtn.addEventListener('click', () => vscode.postMessage({ command: 'importEvents' })); errorClose.addEventListener('click', () => errorContainer.classList.add('hidden')); queryPanelClose.addEventListener('click', () => { @@ -2201,6 +2413,14 @@ export class ProfilerPanelProvider { updateState(currentState); showError(msg.data); break; + case 'loadImportedEvents': + // Replace all events with the imported set. + // clearEventsUI() resets DOM, allEvents, accumulators and search. + clearEventsUI(); + if (msg.data && msg.data.length > 0) { + addEvents(msg.data); + } + break; case 'setConnectionFieldsEnabled': { const enabled = /** @type {boolean} */ (msg.data); authMode.disabled = !enabled; @@ -2235,6 +2455,10 @@ export class ProfilerPanelProvider { resumeBtn.classList.toggle('hidden', !isPaused); resumeBtn.disabled = !isPaused; stopBtn.disabled = isStopped; + // Export and Import are only available when stopped — running would + // mix live events with exported/imported data, producing a confusing result. + exportBtn.disabled = !isStopped; + importBtn.disabled = !isStopped; // Timer const timerEl = document.getElementById('sessionTimer'); @@ -2373,7 +2597,8 @@ export class ProfilerPanelProvider { // ── Stats ─────────────────────────────────────────────────────── // Uses incremental accumulators updated in addEvents() — O(1) per call. function updateStats() { - statTotal.textContent = statsTotal; + statTotal.textContent = statsTotal; + eventCount.textContent = String(statsTotal); if (statsDurCount > 0) { const avg = statsDurSum / statsDurCount; @@ -2631,6 +2856,12 @@ export class ProfilerPanelProvider { return String(timestamp).replace('T', ' '); } + // ── Webview ready handshake ────────────────────────────────────── + // Notify the extension host that the webview JS has fully initialised. + // If importEvents() was called while the panel was closed, the host + // stored the data in pendingImportEvents and will forward it now. + vscode.postMessage({ command: 'webviewReady' }); + })();