diff --git a/src/panels/minecraft-diagnostics.ts b/src/panels/minecraft-diagnostics.ts index 6fd43f7..64d704c 100644 --- a/src/panels/minecraft-diagnostics.ts +++ b/src/panels/minecraft-diagnostics.ts @@ -1,6 +1,6 @@ // Copyright (C) Microsoft Corporation. All rights reserved. -import { Disposable, Webview, WebviewPanel, window, Uri, ViewColumn } from 'vscode'; +import { Disposable, Webview, WebviewPanel, window, workspace, Uri, ViewColumn } from 'vscode'; import { EventEmitter } from 'stream'; import { getUri } from '../utilities/getUri'; import { getNonce } from '../utilities/getNonce'; @@ -37,7 +37,7 @@ export class MinecraftDiagnosticsPanel { this._panel.webview.html = this._getWebviewContent( this._panel.webview, extensionUri, - statsTracker.manualControl() + statsTracker.manualControl(), ); // Handle events from the webview panel @@ -48,7 +48,7 @@ export class MinecraftDiagnosticsPanel { this._panel.webview.html = this._getWebviewContent( this._panel.webview, extensionUri, - statsTracker.manualControl() + statsTracker.manualControl(), ); break; case 'pause': @@ -74,6 +74,9 @@ export class MinecraftDiagnosticsPanel { case 'debugger-request': this._debuggerRequestHandler.handleDebuggerRequest(message.request, message.args); break; + case 'export-data': + void this.handleExportDataMessage(message); + break; default: console.error('Unknown message type:', message.type); break; @@ -122,16 +125,45 @@ export class MinecraftDiagnosticsPanel { schema: schema, }; this._panel.webview.postMessage(message); - } + }, }; this._statsTracker.addStatListener(this._statsCallback); } + private async handleExportDataMessage(message: any): Promise { + if (typeof message.content !== 'string') { + console.error('Received export-data message without a valid content string.'); + return; + } + + const suggestedFileName = + typeof message.suggestedFileName === 'string' && message.suggestedFileName.trim() !== '' + ? message.suggestedFileName + : 'diagnostics-export.csv'; + const workspaceFolderUri = workspace.workspaceFolders?.[0]?.uri; + + const outputUri = await window.showSaveDialog({ + title: 'Export Diagnostics Data', + saveLabel: 'Export', + defaultUri: workspaceFolderUri ? Uri.joinPath(workspaceFolderUri, suggestedFileName) : undefined, + filters: { + 'CSV Files': ['csv'], + }, + }); + + if (!outputUri) { + return; + } + + await workspace.fs.writeFile(outputUri, Buffer.from(message.content, 'utf8')); + window.showInformationMessage(`Exported diagnostics data to ${outputUri.fsPath}.`); + } + public static render(extensionUri: Uri, statsTracker: StatsProvider, eventEmitter: EventEmitter): void { const statsTrackerId = statsTracker.uniqueId; const existingPanel = MinecraftDiagnosticsPanel.activeDiagnosticsPanels.find( - panel => panel._statsTracker.uniqueId === statsTrackerId + panel => panel._statsTracker.uniqueId === statsTrackerId, ); if (existingPanel) { existingPanel._panel.reveal(ViewColumn.One); @@ -147,10 +179,16 @@ export class MinecraftDiagnosticsPanel { Uri.joinPath(extensionUri, 'out'), Uri.joinPath(extensionUri, 'webview-ui/build'), ], - } + }, ); MinecraftDiagnosticsPanel.activeDiagnosticsPanels.push( - new MinecraftDiagnosticsPanel(panel, extensionUri, statsTracker, eventEmitter, new DebuggerRequestHandler(panel.webview)), + new MinecraftDiagnosticsPanel( + panel, + extensionUri, + statsTracker, + eventEmitter, + new DebuggerRequestHandler(panel.webview), + ), ); } } @@ -163,7 +201,7 @@ export class MinecraftDiagnosticsPanel { // Remove the current panel from the active panel list MinecraftDiagnosticsPanel.activeDiagnosticsPanels = MinecraftDiagnosticsPanel.activeDiagnosticsPanels.filter( - panel => panel !== this + panel => panel !== this, ); // Dispose of the current webview panel diff --git a/webview-ui/src/diagnostics_panel/App.css b/webview-ui/src/diagnostics_panel/App.css index 7c66dc7..4804f79 100644 --- a/webview-ui/src/diagnostics_panel/App.css +++ b/webview-ui/src/diagnostics_panel/App.css @@ -385,6 +385,18 @@ main { background: color-mix(in srgb, var(--vscode-list-activeSelectionBackground) 33%, var(--vscode-editor-background)); } +.minecraft-entity-system-general-controls { + padding-top: 16px; +} + +.minecraft-entity-system-export-actions { + display: flex; + align-items: flex-end; + gap: 8px; + flex-wrap: wrap; + padding-top: 16px; +} + .minecraft-grouped-statistic-table-root { width: 100%; } diff --git a/webview-ui/src/diagnostics_panel/controls/MinecraftGroupedStatisticTable.tsx b/webview-ui/src/diagnostics_panel/controls/MinecraftGroupedStatisticTable.tsx index 6684014..0e2f262 100644 --- a/webview-ui/src/diagnostics_panel/controls/MinecraftGroupedStatisticTable.tsx +++ b/webview-ui/src/diagnostics_panel/controls/MinecraftGroupedStatisticTable.tsx @@ -68,9 +68,18 @@ export type MinecraftGroupedStatisticTableSelectionSnapshot = { resolvedSelectedRows: GroupedStatisticTableRow[]; }; +export type MinecraftGroupedStatisticTableExportRow = { + rowKey: string; + groupKey: string; + row: GroupedStatisticTableRow; + trendValues: number[]; +}; + export type MinecraftGroupedStatisticTableHandle = { getSelectionSnapshot: () => MinecraftGroupedStatisticTableSelectionSnapshot; + getRowsForExport: () => MinecraftGroupedStatisticTableExportRow[]; clearSelection: () => void; + clearTrendData: () => void; }; type MinecraftGroupedStatisticTableProps = { @@ -120,6 +129,8 @@ const SPARKLINE_HISTORY_RETENTION_TICKS = 8; const TICKS_PER_SECOND = 20; const TICKS_PER_SPARKLINE_UPDATE = 12; // TODO I don't know if this is accurate or not, but it gives us the right values for now +export const SPARKLINE_SAMPLE_INTERVAL_SECONDS = TICKS_PER_SPARKLINE_UPDATE / TICKS_PER_SECOND; + function sparklineTickRangeToSeconds(updatePoints: number): string { const totalTicks = updatePoints * TICKS_PER_SPARKLINE_UPDATE; const seconds = totalTicks / TICKS_PER_SECOND; @@ -733,17 +744,49 @@ const MinecraftGroupedStatisticTable = forwardRef< }; }, [deselectedRowsInSelectedGroups, groupedRowsByKey, rowGroupLookup, rowLookup, selectedGroups, selectedRows]); + const createExportRow = useCallback( + (rowKey: string): MinecraftGroupedStatisticTableExportRow | undefined => { + const row = rowLookup.get(rowKey); + const groupKey = rowGroupLookup.get(rowKey); + + if (!row || !groupKey) { + return undefined; + } + + return { + rowKey, + groupKey, + row, + trendValues: [...(rowHistoryRef.current.get(row.category) ?? [])], + }; + }, + [rowGroupLookup, rowLookup], + ); + + const rowsForExport = useMemo((): MinecraftGroupedStatisticTableExportRow[] => { + return Array.from(rowLookup.keys()) + .sort((left, right) => left.localeCompare(right)) + .map(rowKey => createExportRow(rowKey)) + .filter((row): row is MinecraftGroupedStatisticTableExportRow => row !== undefined); + }, [createExportRow, rowLookup]); + useImperativeHandle( ref, () => ({ getSelectionSnapshot: () => selectionSnapshot, + getRowsForExport: () => rowsForExport, clearSelection: () => { setSelectedGroups(new Set()); setSelectedRows(new Set()); setDeselectedRowsInSelectedGroups(new Set()); }, + clearTrendData: () => { + rowHistoryRef.current.clear(); + rowHistoryMissingTickCountRef.current.clear(); + sparklineBoundsRef.current.clear(); + }, }), - [selectionSnapshot], + [selectionSnapshot, rowsForExport], ); const prevSelectionRowKeysRef = useRef([]); @@ -1037,6 +1080,24 @@ const MinecraftGroupedStatisticTable = forwardRef< sparklineBoundsRef.current.clear(); }, [sparklineColumnIndex]); + useEffect(() => { + if (sparklineTickRange <= 0) { + rowHistoryRef.current.clear(); + rowHistoryMissingTickCountRef.current.clear(); + sparklineBoundsRef.current.clear(); + return; + } + + rowHistoryRef.current.forEach((history, category) => { + if (history.length <= sparklineTickRange) { + return; + } + + const trimmedHistory = history.slice(history.length - sparklineTickRange); + rowHistoryRef.current.set(category, trimmedHistory); + }); + }, [sparklineTickRange]); + useEffect(() => { if (!selectionEnabled || !defaultSelectAllGroups || hasInitializedDefaultSelection) { return; diff --git a/webview-ui/src/diagnostics_panel/exporters/TableCsvExporter.ts b/webview-ui/src/diagnostics_panel/exporters/TableCsvExporter.ts new file mode 100644 index 0000000..8015401 --- /dev/null +++ b/webview-ui/src/diagnostics_panel/exporters/TableCsvExporter.ts @@ -0,0 +1,50 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. + +export type CsvCellValue = string | number; + +export type CsvExportRow = Record; + +export type DiagnosticsExportFormat = 'csv'; + +export interface CsvExporter { + readonly format: 'csv'; + readonly fileExtension: string; + readonly mimeType: string; + exportRows(headers: readonly THeader[], rows: Array>): string; +} + +function escapeCsvValue(value: string | number): string { + const serialized = String(value); + + if (!/[",\n\r]/.test(serialized)) { + return serialized; + } + + return `"${serialized.replace(/"/g, '""')}"`; +} + +function toCsvRow(values: readonly (string | number)[]): string { + return values.map(escapeCsvValue).join(','); +} + +export class TableCsvExporter implements CsvExporter { + public readonly format = 'csv' as const; + + public readonly fileExtension = 'csv'; + + public readonly mimeType = 'text/csv'; + + public exportRows( + headers: readonly THeader[], + rows: Array>, + ): string { + const csvLines: string[] = [toCsvRow(headers)]; + + rows.forEach(row => { + const values = headers.map(header => row[header] ?? ''); + csvLines.push(toCsvRow(values)); + }); + + return csvLines.join('\n'); + } +} diff --git a/webview-ui/src/diagnostics_panel/prefabs/tabs/ClientEntitySystems.tsx b/webview-ui/src/diagnostics_panel/prefabs/tabs/ClientEntitySystems.tsx index a6809fc..8a6b998 100644 --- a/webview-ui/src/diagnostics_panel/prefabs/tabs/ClientEntitySystems.tsx +++ b/webview-ui/src/diagnostics_panel/prefabs/tabs/ClientEntitySystems.tsx @@ -3,12 +3,14 @@ import { ParentNameStatResolver, StatisticType, YAxisType, createStatResolver } from '../../StatisticResolver'; import { TabPrefab, TabPrefabDataSource, TabPrefabParams } from '../TabPrefab'; import MinecraftGroupedStatisticTable, { + MinecraftGroupedStatisticTableExportRow, MinecraftGroupedStatisticTableColumnAggregation, MinecraftGroupedStatisticTableDisplayMode, MinecraftGroupedStatisticTableHandle, MinecraftGroupedStatisticTableSelectionSnapshot, MinecraftGroupedStatisticTableSortOrder, MinecraftGroupedStatisticTableSortType, + SPARKLINE_SAMPLE_INTERVAL_SECONDS, } from '../../controls/MinecraftGroupedStatisticTable'; import { DebuggerRequestResultMessage, @@ -21,6 +23,10 @@ import { DebuggerRequestResultBanner } from '../../controls/DebuggerRequestResul import { MultipleStatisticProvider, StatisticUpdatedMessage } from '../../StatisticProvider'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { VSCodeButton, VSCodeDropdown, VSCodeOption } from '@vscode/webview-ui-toolkit/react'; +import { CsvCellValue, TableCsvExporter } from '../../exporters/TableCsvExporter'; +import { sendExportDataRequest } from '../../utilities/exportData'; + +type DiagnosticsExportTableType = 'entity' | 'system'; const START_ENTITY_SYSTEM_PROFILER_REQUEST = 'Start Entity System Profiler'; const FILTER_MULTI_ENTITY_REQUEST = 'Filter Multi Entity System Profiler'; @@ -37,8 +43,96 @@ type TimingUnit = 'ns' | 'us' | 'ms'; type EntityViewMode = 'flat' | 'grouped'; type SystemViewMode = 'flat' | 'grouped'; type FilterSelectionMode = 'no-filter' | 'filter-by-entity' | 'filter-by-single-entity' | 'filter-by-system'; +type TrendDurationOption = { label: string; points: number }; const UNCATEGORIZED_SYSTEM_GROUP = 'INVALID CATEGORY'; +const DEFAULT_TREND_DURATION_POINTS = 100; +const TREND_DURATION_OPTIONS: ReadonlyArray = [ + { label: '15 seconds', points: 25 }, + { label: '30 seconds', points: 50 }, + { label: '1 minute', points: 100 }, + { label: '2 minutes', points: 200 }, + { label: '5 minutes', points: 500 }, + { label: '10 minutes', points: 1000 }, +]; + +const EXPORT_CSV_HEADERS = [ + 'TimingUnit', + 'GroupKey', + 'Category', + 'SampleIndex', + 'ElapsedSeconds', + 'TrendValue', +] as const; + +type ExportCsvHeader = (typeof EXPORT_CSV_HEADERS)[number]; +type ExportCsvRow = Record; + +function convertTimingValueFromNs(value: number, unit: TimingUnit): number { + if (!Number.isFinite(value)) { + return 0; + } + + if (unit === 'ms') { + return Number((value / 1_000_000).toFixed(3)); + } + + if (unit === 'us') { + return Number((value / 1_000).toFixed(1)); + } + + return value; +} + +function buildExportRows( + tableRows: MinecraftGroupedStatisticTableExportRow[], + timingUnit: TimingUnit, + sampleIntervalSeconds: number, +): ExportCsvRow[] { + const rows: ExportCsvRow[] = []; + + tableRows.forEach(tableRow => { + if (tableRow.trendValues.length === 0) { + rows.push({ + TimingUnit: timingUnit, + GroupKey: tableRow.groupKey, + Category: tableRow.row.category, + SampleIndex: '', + ElapsedSeconds: '', + TrendValue: '', + }); + return; + } + + tableRow.trendValues.forEach((trendValue, sampleIndex) => { + rows.push({ + TimingUnit: timingUnit, + GroupKey: tableRow.groupKey, + Category: tableRow.row.category, + SampleIndex: sampleIndex, + ElapsedSeconds: Number((sampleIndex * sampleIntervalSeconds).toFixed(3)), + TrendValue: convertTimingValueFromNs(trendValue, timingUnit), + }); + }); + }); + + return rows; +} + +function formatExportTimestamp(now: Date): string { + const year = now.getUTCFullYear(); + const month = String(now.getUTCMonth() + 1).padStart(2, '0'); + const day = String(now.getUTCDate()).padStart(2, '0'); + const hours = String(now.getUTCHours()).padStart(2, '0'); + const minutes = String(now.getUTCMinutes()).padStart(2, '0'); + const seconds = String(now.getUTCSeconds()).padStart(2, '0'); + + return `${year}${month}${day}-${hours}${minutes}${seconds}`; +} + +function buildExportFileName(tableType: DiagnosticsExportTableType, now: Date): string { + return `${tableType}-trends-${formatExportTimestamp(now)}.csv`; +} function getFilterSelectionModeTooltip(ecsVersion: number): string { if (ecsVersion === 2) { @@ -174,8 +268,11 @@ const StatsTab: TabPrefab = { const [filteredSystemCount, setFilteredSystemCount] = useState(undefined); const [ecsVersion, setECSVersion] = useState(1); const [isStarted, setIsStarted] = useState(false); + const [trendDurationPoints, setTrendDurationPoints] = useState(DEFAULT_TREND_DURATION_POINTS); const entityTimingsTableRef = useRef(null); + const systemTimingsTableRef = useRef(null); const isMountRef = useRef(true); + const csvExporter = useMemo(() => new TableCsvExporter(), []); const categoriesProvider = useMemo(() => { if (!selectedClient) { return undefined; @@ -304,6 +401,20 @@ const StatsTab: TabPrefab = { ); const entityValueLabels = [getTimingColumnLabel(entityTimingUnit), '% Of Total']; + const systemValueLabels = [getTimingColumnLabel(systemTimingUnit), '% Of Total']; + const selectedTrendDurationLabel = + TREND_DURATION_OPTIONS.find(option => option.points === trendDurationPoints)?.label ?? 'Custom'; + + const clearProfilerData = useCallback((): void => { + entityTimingsTableRef.current?.clearSelection(); + entityTimingsTableRef.current?.clearTrendData(); + systemTimingsTableRef.current?.clearSelection(); + systemTimingsTableRef.current?.clearTrendData(); + + setSelectedSingleEntityId(undefined); + setFilteredEntityCount(undefined); + setFilteredSystemCount(undefined); + }, []); const filteredEntityLabel = (() => { if (filterSelectionMode === 'filter-by-single-entity') { @@ -348,10 +459,39 @@ const StatsTab: TabPrefab = { console.log(`Entity System Profiler is started: ${isStarted}`); }, [lastResult, lastRequestedCommand]); + const exportTableData = useCallback( + (tableType: DiagnosticsExportTableType): void => { + const tableHandle = + tableType === 'entity' ? entityTimingsTableRef.current : systemTimingsTableRef.current; + if (!tableHandle) { + console.warn(`No table handle available for ${tableType} export.`); + return; + } + + const tableRows = tableHandle.getRowsForExport(); + const timingUnit = tableType === 'entity' ? entityTimingUnit : systemTimingUnit; + const exportRows = buildExportRows(tableRows, timingUnit, SPARKLINE_SAMPLE_INTERVAL_SECONDS); + + const now = new Date(); + const csvContent = csvExporter.exportRows(EXPORT_CSV_HEADERS, exportRows); + + sendExportDataRequest({ + format: csvExporter.format, + mimeType: csvExporter.mimeType, + suggestedFileName: buildExportFileName(tableType, now), + content: csvContent, + }); + }, + [csvExporter, entityTimingUnit, systemTimingUnit], + ); + return (
+
+ +

Entity System Profiler Controls

{DEBUGGER_REQUEST_COMMANDS.map(command => { const inFlight = isDebuggerRequestInFlight(command.command); @@ -361,6 +501,11 @@ const StatsTab: TabPrefab = { disabled={inFlight} onClick={() => { setLastRequestedCommand(command.command); + + if (command.command === 'Clear Entity System Profiler') { + clearProfilerData(); + } + sendDebuggerRequest(command.command); }} style={{ margin: '5px' }} @@ -369,16 +514,8 @@ const StatsTab: TabPrefab = { ); })} -
- -
-
-
- {isStarted && ( -
-
-
-

Selection Controls

+ {isStarted && ( +
)} +
+ + ) => { + const target = event.target as HTMLSelectElement; + const selectedPoints = Number.parseInt(target.value, 10); + + if (!Number.isFinite(selectedPoints)) { + return; + } + + setTrendDurationPoints(selectedPoints); + }} + > + {TREND_DURATION_OPTIONS.map(option => ( + + {option.label} + + ))} + + + Showing latest {selectedTrendDurationLabel} in chart and export data. + +
+ +
+ exportTableData('entity')} disabled={!isStarted}> + Export Entity CSV + + exportTableData('system')} disabled={!isStarted}> + Export System CSV + +
-
+ )} +
+
+ {isStarted && ( +
@@ -534,7 +713,7 @@ const StatsTab: TabPrefab = { )} nonConsolidatedColumnResolver={event => resolveEcsColumn(event.id)} sparklineColumnIndex={0} - sparklineTickRange={100} + sparklineTickRange={trendDurationPoints} sparklineValueFormatter={value => formatTimingValue(value, entityTimingUnit)} valueFormatter={(value, columnIndex) => { // Timing column @@ -550,7 +729,7 @@ const StatsTab: TabPrefab = { }} />
-
+

System Timings

@@ -597,6 +776,7 @@ const StatsTab: TabPrefab = {
resolveEcsColumn(event.id)} sparklineColumnIndex={0} - sparklineTickRange={100} + sparklineTickRange={trendDurationPoints} sparklineValueFormatter={value => formatTimingValue(value, systemTimingUnit)} keyFormatter={formatStatKey} valueFormatter={(value, columnIndex) => { diff --git a/webview-ui/src/diagnostics_panel/utilities/exportData.ts b/webview-ui/src/diagnostics_panel/utilities/exportData.ts new file mode 100644 index 0000000..bf8f764 --- /dev/null +++ b/webview-ui/src/diagnostics_panel/utilities/exportData.ts @@ -0,0 +1,19 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. + +import { DiagnosticsExportFormat } from '../exporters/TableCsvExporter'; +import { vscode } from './vscode'; + +export type ExportDataRequestMessage = { + type: 'export-data'; + format: DiagnosticsExportFormat; + mimeType: string; + suggestedFileName: string; + content: string; +}; + +export function sendExportDataRequest(message: Omit): void { + vscode.postMessage({ + type: 'export-data', + ...message, + }); +}