diff --git a/src/panels/minecraft-diagnostics.ts b/src/panels/minecraft-diagnostics.ts index eafc068..f1a34b4 100644 --- a/src/panels/minecraft-diagnostics.ts +++ b/src/panels/minecraft-diagnostics.ts @@ -9,6 +9,7 @@ import { StatData, StatsListener, StatsProvider } from '../stats/stats-provider' import { DiagnosticsTabDescriptor } from '../diagnostics-schema'; const DIAGNOSTICS_TAB_STATES_KEY = 'minecraftDiagnosticsTabStates'; +const STAT_MESSAGE_FLUSH_DELAY_MS = 16; type DiagnosticsTabStates = Record; export class MinecraftDiagnosticsPanel { @@ -18,6 +19,8 @@ export class MinecraftDiagnosticsPanel { private _disposables: Disposable[] = []; private _statsTracker: StatsProvider; private _statsCallback: StatsListener | undefined = undefined; + private _pendingStatMessages: Array> = []; + private _statFlushTimer: ReturnType | undefined; private _eventEmitter: EventEmitter; private _debuggerRequestHandler: DebuggerRequestHandler; @@ -112,7 +115,7 @@ export class MinecraftDiagnosticsPanel { time: stat.tick, group_full_id: stat.parent_full_id, }; - this._panel.webview.postMessage(message); + this.queueStatMessage(message); } }, onSpeedUpdated: (speed: number) => { @@ -144,6 +147,25 @@ export class MinecraftDiagnosticsPanel { this._statsTracker.addStatListener(this._statsCallback); } + private queueStatMessage(message: Record): void { + this._pendingStatMessages.push(message); + + if (this._statFlushTimer === undefined) { + this._statFlushTimer = setTimeout(() => this.flushStatMessages(), STAT_MESSAGE_FLUSH_DELAY_MS); + } + } + + private flushStatMessages(): void { + this._statFlushTimer = undefined; + if (this._pendingStatMessages.length === 0) { + return; + } + + const events = this._pendingStatMessages; + this._pendingStatMessages = []; + this._panel.webview.postMessage({ type: 'statistic-updated-batch', events }); + } + private getDiagnosticsTabStates(): DiagnosticsTabStates { const states = this._globalState.get(DIAGNOSTICS_TAB_STATES_KEY, {}); if (states === null || typeof states !== 'object' || Array.isArray(states)) { @@ -251,6 +273,12 @@ export class MinecraftDiagnosticsPanel { this._statsCallback = undefined; } + if (this._statFlushTimer !== undefined) { + clearTimeout(this._statFlushTimer); + this._statFlushTimer = undefined; + } + this._pendingStatMessages = []; + // Remove the current panel from the active panel list MinecraftDiagnosticsPanel.activeDiagnosticsPanels = MinecraftDiagnosticsPanel.activeDiagnosticsPanels.filter( panel => panel !== this, diff --git a/webview-ui/src/diagnostics_panel/StatisticProvider.tsx b/webview-ui/src/diagnostics_panel/StatisticProvider.tsx index fe9b983..bb4ff80 100644 --- a/webview-ui/src/diagnostics_panel/StatisticProvider.tsx +++ b/webview-ui/src/diagnostics_panel/StatisticProvider.tsx @@ -3,7 +3,6 @@ import type { StatisticPrefab } from './prefabs/StatisticPrefab'; export interface StatisticUpdatedMessage { - type: 'statistic-updated'; should_aggregate: boolean; values: number[]; children_string_values: string[][]; @@ -27,8 +26,10 @@ export class StatisticProvider { } private _handleRawEvent(event: MessageEvent) { - if (event.data.type === 'statistic-updated') { - this._handleEvent(event.data as StatisticUpdatedMessage); + if (event.data.type === 'statistic-updated-batch' && Array.isArray(event.data.events)) { + for (const statisticEvent of event.data.events) { + this._handleEvent(statisticEvent as StatisticUpdatedMessage); + } } } diff --git a/webview-ui/src/diagnostics_panel/controls/StatGroupSelectionBox.tsx b/webview-ui/src/diagnostics_panel/controls/StatGroupSelectionBox.tsx index ccb28f8..5bb631a 100644 --- a/webview-ui/src/diagnostics_panel/controls/StatGroupSelectionBox.tsx +++ b/webview-ui/src/diagnostics_panel/controls/StatGroupSelectionBox.tsx @@ -34,29 +34,35 @@ export function StatGroupSelectionBox({ labelName, statParentId, onChange, helpT //draws chart useEffect(() => { - const eventHandler = (e: MessageEvent): void => { - // Object containing type prop and value prop - const msg: MessageEvent = e; - - switch (msg.data.type) { - case 'statistic-updated': { - if (msg.data.group !== statParentId) { - return; + const handleStatistic = (statistic: { + group: string; + id: string; + name: string; + }): void => { + if (statistic.group !== statParentId) { + return; + } + // Add it to the list + setGroup(prevState => { + const isNewGroup = !prevState || prevState.findIndex(x => x.id === statistic.id) === -1; + if (isNewGroup) { + // auto select the first group we get if nothing selected + if (!selectedGroupId) { + setSelectedGroupId(statistic.id); } - // Add it to the list - setGroup(prevState => { - const isNewGroup = !prevState || prevState.findIndex(x => x.id === msg.data.id) === -1; - if (isNewGroup) { - // auto select the first group we get if nothing selected - if (!selectedGroupId) { - setSelectedGroupId(msg.data.id); - } - // add new group to end of list - const newState = [...(prevState ?? []), { id: msg.data.id, name: msg.data.name }]; - return newState; - } - return prevState; - }); + // add new group to end of list + const newState = [...(prevState ?? []), { id: statistic.id, name: statistic.name }]; + return newState; + } + return prevState; + }); + }; + + const eventHandler = (e: MessageEvent): void => { + const message = e.data; + if (message.type === 'statistic-updated-batch' && Array.isArray(message.events)) { + for (const statistic of message.events) { + handleStatistic(statistic); } } }; diff --git a/webview-ui/src/diagnostics_panel/prefabs/index.tsx b/webview-ui/src/diagnostics_panel/prefabs/index.tsx index 7781210..1c3d34d 100644 --- a/webview-ui/src/diagnostics_panel/prefabs/index.tsx +++ b/webview-ui/src/diagnostics_panel/prefabs/index.tsx @@ -8,6 +8,7 @@ import dynamicPropertyTab from './tabs/DynamicProperties'; import serverScriptHandleCountsTab from './tabs/ServerScriptHandleCounts'; import serverScriptSubscriberCountsTab from './tabs/ServerScriptSubscriberCounts'; +import serverEntitySystemTab from './tabs/ServerEntitySystems'; import clientTimingTab from './tabs/ClientTiming'; import clientMemoryTab from './tabs/ClientMemory'; @@ -24,6 +25,7 @@ export default [ serverBandwidthTab, serverScriptHandleCountsTab, serverScriptSubscriberCountsTab, + serverEntitySystemTab, clientTimingTab, clientMemoryTab, clientEntitySystemTab, diff --git a/webview-ui/src/diagnostics_panel/prefabs/tabs/ClientEntitySystems.tsx b/webview-ui/src/diagnostics_panel/prefabs/tabs/ClientEntitySystems.tsx index 602eda1..c14b1aa 100644 --- a/webview-ui/src/diagnostics_panel/prefabs/tabs/ClientEntitySystems.tsx +++ b/webview-ui/src/diagnostics_panel/prefabs/tabs/ClientEntitySystems.tsx @@ -1,1199 +1,14 @@ // Copyright (C) Microsoft Corporation. All rights reserved. -import { ParentNameStatResolver, StatisticType, YAxisType, createStatResolver } from '../../StatisticResolver'; -import { TabPrefab, TabPrefabDataSource, TabPrefabParams } from '../TabPrefab'; -import MinecraftGroupedStatisticTable, { - GroupedStatisticTableRow, - GroupedStatisticTableRowDetailsContext, - MinecraftGroupedStatisticTableExportRow, - MinecraftGroupedStatisticTableColumnAggregation, - MinecraftGroupedStatisticTableDisplayMode, - MinecraftGroupedStatisticTableHandle, - MinecraftGroupedStatisticTableSelectionSnapshot, - MinecraftGroupedStatisticTableSortOrder, - MinecraftGroupedStatisticTableSortType, - SPARKLINE_SAMPLE_INTERVAL_SECONDS, -} from '../../controls/MinecraftGroupedStatisticTable'; -import { - DebuggerRequestResultMessage, - getDebuggerRequestResult, - isDebuggerRequestInFlight, - sendDebuggerRequest, - useDebuggerRequestUpdates, -} from '../../utilities/useDebuggerRequests'; -import { DebuggerRequestResultBanner } from '../../controls/DebuggerRequestResult'; -import { MultipleStatisticProvider, StatisticUpdatedMessage } from '../../StatisticProvider'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { VSCodeButton, VSCodeCheckbox, VSCodeDropdown, VSCodeOption } from '@vscode/webview-ui-toolkit/react'; -import { CsvCellValue, TableCsvExporter } from '../../exporters/TableCsvExporter'; -import { sendExportDataRequest } from '../../utilities/exportData'; -import { SparklineCell } from '../../controls/SparklineCell'; +import { TabPrefabDataSource } from '../TabPrefab'; +import { DebuggerRequestTarget } from '../../utilities/useDebuggerRequests'; +import { createEntitySystemsTab } from './EntitySystemsProfiler'; -type DiagnosticsExportTableType = 'entity' | 'system'; - -const START_ENTITY_SYSTEM_PROFILER_REQUEST = 'Start Entity System Profiler'; -const FILTER_MULTI_ENTITY_REQUEST = 'Filter Multi Entity System Profiler'; -const FILTER_SINGLE_ENTITY_REQUEST = 'Filter Single Entity System Profiler'; -const FILTER_BY_SYSTEM_REQUEST = 'Filter By System Entity System Profiler'; -const REQUEST_CSV_ENTITY_SYSTEM_PROFILER_REQUEST = 'Request CSV Entity System Profiler'; - -const DEBUGGER_REQUEST_COMMANDS = [ - { command: START_ENTITY_SYSTEM_PROFILER_REQUEST, label: 'Start' }, - { command: 'Stop Entity System Profiler', label: 'Stop' }, - { command: 'Clear Entity System Profiler', label: 'Clear' }, -]; - -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; -type OptionalEntityStats = { - positionX?: number; - positionY?: number; - positionZ?: number; - dimension?: string; -}; - -const OPTIONAL_ENTITY_STAT_IDS = ['position_x', 'position_y', 'position_z', 'dimension'] as const; - -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 buildEntitySystemMatrixCsvFileName(now: Date): string { - return `entity-system-profiler-${formatExportTimestamp(now)}.csv`; -} - -function getFilterSelectionModeTooltip(ecsVersion: number): string { - if (ecsVersion >= 2) { - return ( - 'Select Filtering Mode:\n' + - '\u2022 "No Filter" will display system and entity timings for all entities and systems.\n' + - '\u2022 "Filter By Entity" allows you to select specific entities, and only show the system information for that selection.\n' + - '\u2022 "Filter By Single Entity" allows you to select one specific entity, and only display information for that selection.\n' + - ' This option has some performance benefits on the backend profiler versus multi select.\n' + - '\u2022 "Filter By System" allows you to select specific systems and will only show the entities and entity timing values for those systems.' - ); - } else if (ecsVersion === 1) { - return ( - 'Select Filtering Mode:\n' + - '\u2022 "No Filter" will display system and entity timings for all entities and systems.\n' + - '\u2022 "Filter By Entity" allows you to select specific entities, and only show the system information for that selection.\n' + - '\u2022 "Filter By Single Entity" allows you to select one specific entity, and only display information for that selection.\n' + - ' This option has some performance benefits on the backend profiler versus multi select.' - ); - } else { - console.error('Received unknown ECS version from client, defaulting to showing all filter options in tooltip'); - return ( - 'Select Filtering Mode:\n' + - '\u2022 "No Filter" will display system and entity timings for all entities and systems.\n' + - '\u2022 "Filter By Entity" allows you to select specific entities, and only show the system information for that selection.\n' + - '\u2022 "Filter By Single Entity" allows you to select one specific entity, and only display information for that selection.\n' + - ' This option has some performance benefits on the backend profiler versus multi select.\n' + - '\u2022 "Filter By System" allows you to select specific systems and will only show the entities and entity timing values for those systems.' - ); - } -} - -function ceilToDecimalPlace(value: number, decimalPlaces: number): string { - return (Math.ceil(value * Math.pow(10, decimalPlaces)) / Math.pow(10, decimalPlaces)).toFixed(decimalPlaces); -} - -function getTimingColumnLabel(unit: TimingUnit): string { - if (unit === 'ms') { - return 'Time (ms)'; - } - - if (unit === 'us') { - return 'Time (µs)'; - } - - return 'Time (ns)'; -} - -function formatTimingValue(value: number, unit: TimingUnit): string { - if (!Number.isFinite(value)) { - return `0 ${unit}`; - } - - if (unit === 'ms') { - return `${ceilToDecimalPlace(value / 1_000_000, 3)} ms`; - } - - if (unit === 'us') { - return `${ceilToDecimalPlace(value / 1_000, 1)} µs`; - } - - return `${value} ns`; -} - -function formatPercentageValue(value: number): string { - if (!Number.isFinite(value)) { - return '0 %'; - } - - return `${ceilToDecimalPlace(value, 0)} %`; -} - -function resolveEcsColumn(eventId: string): number | undefined { - switch (eventId) { - case 'time_in_ns': - return 0; - case 'percent_of_total': - return 1; - default: - return undefined; - } -} - -function resolveEntityTypeGroupKey(fullName: string): string { - // Example fullName: "minecraft:entity_name<> (2:12345)" - // First remove anything after the first '<' - let groupName = fullName ? fullName.split('<')[0] : 'NULL'; - // Then account for the case we don't have the '<>' and remove anything after the first '(' - groupName = groupName ? groupName.split('(')[0] : 'NULL'; - return groupName; -} - -function extractEntityId(entityCategory: string): string | undefined { - const match = entityCategory.match(/[#:](\d+)\)\s*$/); - return match?.[1]; -} - -function resolveLatestNumericEventValue(event: StatisticUpdatedMessage): number | undefined { - if (event.values.length > 0) { - const value = event.values[event.values.length - 1]; - return Number.isFinite(value) ? value : undefined; - } - - const rows = event.children_string_values || []; - for (const row of rows) { - for (let index = row.length - 1; index >= 0; index -= 1) { - const numericValue = Number.parseFloat(row[index]); - if (Number.isFinite(numericValue)) { - return numericValue; - } - } - } - - return undefined; -} - -function resolveLatestStringEventValue(event: StatisticUpdatedMessage): string | undefined { - const rows = event.children_string_values || []; - - for (const row of rows) { - for (let index = row.length - 1; index >= 0; index -= 1) { - const value = row[index]?.trim(); - if (value && Number.isNaN(Number.parseFloat(value))) { - return value; - } - } - } - - if (event.values.length > 0) { - return String(event.values[event.values.length - 1]); - } - - return undefined; -} - -function formatOptionalCoordinate(value: number | undefined): string { - if (value === undefined || !Number.isFinite(value)) { - return 'N/A'; - } - - return value.toFixed(2); -} - -function formatOptionalDimension(value: string | undefined): string { - if (!value || value.trim().length === 0) { - return 'N/A'; - } - - return value; -} - -function formatSparklineDurationFromPoints(points: number): string { - const totalSeconds = points * SPARKLINE_SAMPLE_INTERVAL_SECONDS; - - if (totalSeconds >= 60) { - const minutes = Math.floor(totalSeconds / 60); - const seconds = Math.round(totalSeconds % 60); - return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`; - } - - return totalSeconds < 10 ? `${totalSeconds.toFixed(1)}s` : `${Math.round(totalSeconds)}s`; -} - -function formatStatKey(key: string): string { - return key.split('(')[0].trim(); -} - -function resolveSystemId(fullName: string): string | undefined { - // Example fullname: "System Name (4)" - const indexAndBracket = fullName ? fullName.split('(')[1] : undefined; - const index = indexAndBracket ? indexAndBracket.split(')')[0].trim() : undefined; - return index; -} - -function resolveSystemCategoryGroupKey(fullName: string, systemCategoryLegendMap: Map): string { - // Example fullName: "System Name (4)" - // Take anything before the first '(' - const indexAndBracket = fullName ? fullName.split('(')[1] : undefined; - const index = indexAndBracket ? indexAndBracket.split(')')[0].trim() : undefined; - // Then lookup the category name from the legend map if it exists - return index ? systemCategoryLegendMap.get(index) || UNCATEGORIZED_SYSTEM_GROUP : UNCATEGORIZED_SYSTEM_GROUP; -} - -const StatsTab: TabPrefab = { +const StatsTab = createEntitySystemsTab({ name: 'Client - Entity Systems', dataSource: TabPrefabDataSource.Client, - content: ({ selectedClient, onRunCommand }: TabPrefabParams) => { - useDebuggerRequestUpdates(); - const [lastRequestedCommand, setLastRequestedCommand] = useState(''); - const [entityTimingUnit, setEntityTimingUnit] = useState('ms'); - const [systemTimingUnit, setSystemTimingUnit] = useState('us'); - const [entityViewMode, setEntityViewMode] = useState('grouped'); - const [systemViewMode, setSystemViewMode] = useState('grouped'); - const [systemCategoryLegendMap, setSystemCategoryLegendMap] = useState>(new Map()); - const [filterSelectionMode, setFilterSelectionMode] = useState('no-filter'); - const [availableEntities, setAvailableEntities] = useState<{ id: string; fullName: string }[]>([]); - const [optionalEntityStatsById, setOptionalEntityStatsById] = useState>( - new Map(), - ); - const [selectedSingleEntityId, setSelectedSingleEntityId] = useState(undefined); - const [filteredEntityCount, setFilteredEntityCount] = useState(undefined); - const [filteredSystemCount, setFilteredSystemCount] = useState(undefined); - const [ecsVersion, setECSVersion] = useState(1); - const [isStarted, setIsStarted] = useState(false); - const [trendDurationPoints, setTrendDurationPoints] = useState(DEFAULT_TREND_DURATION_POINTS); - const [isEntitySystemMatrixCsvExportPending, setIsEntitySystemMatrixCsvExportPending] = - useState(false); - const [isEntitySystemMatrixCsvDialogOpen, setIsEntitySystemMatrixCsvDialogOpen] = useState(false); - const [groupEntitySystemMatrixCsvEntities, setGroupEntitySystemMatrixCsvEntities] = useState(false); - const [groupEntitySystemMatrixCsvSystems, setGroupEntitySystemMatrixCsvSystems] = useState(false); - const entityTimingsTableRef = useRef(null); - const systemTimingsTableRef = useRef(null); - const isMountRef = useRef(true); - const csvExporter = useMemo(() => new TableCsvExporter(), []); - const categoriesProvider = useMemo(() => { - if (!selectedClient) { - return undefined; - } - - return new MultipleStatisticProvider({ - statisticParentId: new RegExp(`${selectedClient}_client_ecs_categories`), - }); - }, [selectedClient]); - - const entityIdsProvider = useMemo(() => { - if (!selectedClient) { - return undefined; - } - - return new MultipleStatisticProvider({ - statisticIds: ['time_in_ns'], - statisticParentId: new RegExp(`${selectedClient}_client_ecs_entities`), - }); - }, [selectedClient]); - - const optionalEntityStatsProvider = useMemo(() => { - if (!selectedClient) { - return undefined; - } - - return new MultipleStatisticProvider({ - statisticIds: [...OPTIONAL_ENTITY_STAT_IDS], - statisticParentId: new RegExp(`${selectedClient}_client_ecs_entities`), - }); - }, [selectedClient]); - - useEffect(() => { - setSystemCategoryLegendMap(new Map()); - - if (!selectedClient || !categoriesProvider) { - return; - } - - const eventHandler = (event: StatisticUpdatedMessage): void => { - const nameAndIndex = event.group_name; - const name = nameAndIndex.split('(')[0].trim(); - const index = nameAndIndex.split('(')[1]?.split(')')[0].trim(); - - if (index && name) { - setSystemCategoryLegendMap(prev => { - const newMap = new Map(prev); - newMap.set(index, name); - return newMap; - }); - } - }; - - categoriesProvider.registerWindowListener(window); - categoriesProvider.addSubscriber(eventHandler); - - return () => { - categoriesProvider.removeSubscriber(eventHandler); - categoriesProvider.unregisterWindowListener(window); - }; - }, [categoriesProvider, selectedClient]); - - useEffect(() => { - setAvailableEntities([]); - setSelectedSingleEntityId(undefined); - - if (!entityIdsProvider) { - return; - } - - const seenIds = new Set(); - - const eventHandler = (event: StatisticUpdatedMessage): void => { - const entityId = extractEntityId(event.group_name); - if (entityId && !seenIds.has(entityId)) { - seenIds.add(entityId); - setAvailableEntities(prev => [...prev, { id: entityId, fullName: event.group_name }]); - } - }; - - entityIdsProvider.registerWindowListener(window); - entityIdsProvider.addSubscriber(eventHandler); - - return () => { - entityIdsProvider.removeSubscriber(eventHandler); - entityIdsProvider.unregisterWindowListener(window); - }; - }, [entityIdsProvider]); - - useEffect(() => { - setOptionalEntityStatsById(new Map()); - - if (!optionalEntityStatsProvider) { - return; - } - - const eventHandler = (event: StatisticUpdatedMessage): void => { - const entityId = extractEntityId(event.group_name); - if (!entityId) { - return; - } - - setOptionalEntityStatsById(previousStats => { - const nextStats = new Map(previousStats); - const existing = nextStats.get(entityId) ?? {}; - - switch (event.id) { - case 'position_x': { - const numericValue = resolveLatestNumericEventValue(event); - nextStats.set(entityId, { - ...existing, - positionX: numericValue, - }); - break; - } - case 'position_y': { - const numericValue = resolveLatestNumericEventValue(event); - nextStats.set(entityId, { - ...existing, - positionY: numericValue, - }); - break; - } - case 'position_z': { - const numericValue = resolveLatestNumericEventValue(event); - nextStats.set(entityId, { - ...existing, - positionZ: numericValue, - }); - break; - } - case 'dimension': { - const dimensionValue = resolveLatestStringEventValue(event); - nextStats.set(entityId, { - ...existing, - dimension: dimensionValue, - }); - break; - } - default: - break; - } - - return nextStats; - }); - }; - - optionalEntityStatsProvider.registerWindowListener(window); - optionalEntityStatsProvider.addSubscriber(eventHandler); - - return () => { - optionalEntityStatsProvider.removeSubscriber(eventHandler); - optionalEntityStatsProvider.unregisterWindowListener(window); - }; - }, [optionalEntityStatsProvider]); - - useEffect(() => { - if (isMountRef.current) { - isMountRef.current = false; - return; - } - - entityTimingsTableRef.current?.clearSelection(); - setSelectedSingleEntityId(undefined); - setFilteredEntityCount(undefined); - setFilteredSystemCount(undefined); - - setLastRequestedCommand(START_ENTITY_SYSTEM_PROFILER_REQUEST); - sendDebuggerRequest(START_ENTITY_SYSTEM_PROFILER_REQUEST, {}); - }, [filterSelectionMode]); - - const handleEntitySelectionChange = useCallback( - (snapshot: MinecraftGroupedStatisticTableSelectionSnapshot): void => { - const entityIds = Array.from( - new Set( - snapshot.resolvedSelectedRows - .map(row => extractEntityId(row.category)) - .filter((entityId): entityId is string => entityId !== undefined), - ), - ); - - setFilteredEntityCount(entityIds.length); - setLastRequestedCommand(FILTER_MULTI_ENTITY_REQUEST); - sendDebuggerRequest(FILTER_MULTI_ENTITY_REQUEST, { entityIds }); - }, - [filterSelectionMode], - ); - - const handleSystemSelectionChange = useCallback( - (snapshot: MinecraftGroupedStatisticTableSelectionSnapshot): void => { - const systemIds = Array.from( - new Set( - snapshot.resolvedSelectedRows - .map(row => resolveSystemId(row.category)) - .filter((systemId): systemId is string => systemId !== undefined), - ), - ); - - setFilteredSystemCount(systemIds.length); - setLastRequestedCommand(FILTER_BY_SYSTEM_REQUEST); - sendDebuggerRequest(FILTER_BY_SYSTEM_REQUEST, { systemIds }); - }, - [filterSelectionMode], - ); - - 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(); - - setOptionalEntityStatsById(new Map()); - setSelectedSingleEntityId(undefined); - setFilteredEntityCount(undefined); - setFilteredSystemCount(undefined); - }, []); - - const filteredEntityLabel = (() => { - if (filterSelectionMode === 'filter-by-single-entity') { - return selectedSingleEntityId ? 'Showing System Timings For 1 Entity' : 'Showing All System Timings'; - } - if ( - filterSelectionMode === 'filter-by-entity' && - filteredEntityCount !== undefined && - filteredEntityCount > 0 - ) { - return `Showing System Timings For ${filteredEntityCount} ${filteredEntityCount === 1 ? 'Entity' : 'Entities'}`; - } - return 'Showing All System Timings'; - })(); - - const filteredSystemLabel = (() => { - if ( - filterSelectionMode === 'filter-by-system' && - filteredSystemCount !== undefined && - filteredSystemCount > 0 - ) { - return `Showing Entity Timings For ${filteredSystemCount} ${filteredSystemCount === 1 ? 'System' : 'Systems'}`; - } - return 'Showing All Entity Timings'; - })(); - - const lastResult: DebuggerRequestResultMessage | undefined = lastRequestedCommand - ? getDebuggerRequestResult(lastRequestedCommand) - : undefined; - const entitySystemMatrixCsvResult = getDebuggerRequestResult(REQUEST_CSV_ENTITY_SYSTEM_PROFILER_REQUEST); - - useEffect(() => { - if (lastRequestedCommand !== START_ENTITY_SYSTEM_PROFILER_REQUEST) { - return; - } - - if (lastResult?.response?.args) { - setECSVersion(lastResult.response.args as number); - console.log(`Received ECS version ${ecsVersion} from client`); - } - - setIsStarted(lastResult?.response?.success ?? false); - console.log(`Entity System Profiler is started: ${isStarted}`); - }, [lastResult, lastRequestedCommand]); - - useEffect(() => { - if ( - !isEntitySystemMatrixCsvExportPending || - isDebuggerRequestInFlight(REQUEST_CSV_ENTITY_SYSTEM_PROFILER_REQUEST) - ) { - return; - } - - setIsEntitySystemMatrixCsvExportPending(false); - - if ( - entitySystemMatrixCsvResult?.response?.success !== true || - typeof entitySystemMatrixCsvResult.response.args !== 'string' - ) { - return; - } - - sendExportDataRequest({ - format: csvExporter.format, - mimeType: csvExporter.mimeType, - suggestedFileName: buildEntitySystemMatrixCsvFileName(new Date()), - content: entitySystemMatrixCsvResult.response.args, - }); - }, [entitySystemMatrixCsvResult, csvExporter, isEntitySystemMatrixCsvExportPending]); - - const openEntitySystemMatrixCsvDialog = useCallback((): void => { - setIsEntitySystemMatrixCsvDialogOpen(true); - }, []); - - const closeEntitySystemMatrixCsvDialog = useCallback((): void => { - setIsEntitySystemMatrixCsvDialogOpen(false); - }, []); - - const requestEntitySystemMatrixCsv = useCallback((): void => { - setIsEntitySystemMatrixCsvExportPending(true); - setIsEntitySystemMatrixCsvDialogOpen(false); - setLastRequestedCommand(REQUEST_CSV_ENTITY_SYSTEM_PROFILER_REQUEST); - sendDebuggerRequest(REQUEST_CSV_ENTITY_SYSTEM_PROFILER_REQUEST, { - csvArgs: { - groupEntities: groupEntitySystemMatrixCsvEntities, - groupSystems: groupEntitySystemMatrixCsvSystems, - }, - }); - }, [groupEntitySystemMatrixCsvEntities, groupEntitySystemMatrixCsvSystems]); - - 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], - ); - - const renderEntityRowDetails = useCallback( - ( - row: GroupedStatisticTableRow, - _groupKey: string, - context: GroupedStatisticTableRowDetailsContext, - ): JSX.Element => { - const entityId = extractEntityId(row.category); - const optionalStats = entityId ? optionalEntityStatsById.get(entityId) : undefined; - const coordinates = [optionalStats?.positionX, optionalStats?.positionY, optionalStats?.positionZ]; - const dimension = optionalStats?.dimension?.trim() ?? ''; - const hasValidTeleportDestination = - selectedClient.trim().length > 0 && - dimension.length > 0 && - coordinates.every(value => typeof value === 'number' && Number.isFinite(value)); - - // Teleports the selected client to this entity's dimension and coordinates if they are valid. - const teleportCommand = hasValidTeleportDestination - ? `/execute in ${dimension} run tp ${JSON.stringify(selectedClient)} ${coordinates.join(' ')}` - : undefined; - - return ( -
-
-
- Position X - - {formatOptionalCoordinate(optionalStats?.positionX)} - -
-
- Position Y - - {formatOptionalCoordinate(optionalStats?.positionY)} - -
-
- Position Z - - {formatOptionalCoordinate(optionalStats?.positionZ)} - -
-
- Dimension - - {formatOptionalDimension(optionalStats?.dimension)} - -
-
- { - if (teleportCommand) { - onRunCommand(teleportCommand); - } - }} - > - Teleport to Entity - -
-
- Trend ({formatSparklineDurationFromPoints(context.sparklineTickRange)}) -
- formatTimingValue(value, entityTimingUnit)} - displayedMin={context.sparklineDisplayedMin} - displayedMax={context.sparklineDisplayedMax} - showYAxisTicks={true} - yAxisTickCount={5} - yAxisLabelFormatter={value => formatTimingValue(value, entityTimingUnit)} - lineStrokeWidth={2} - /> -
-
- ); - }, - [entityTimingUnit, optionalEntityStatsById, selectedClient, onRunCommand], - ); - - return ( -
-
-
-
- -
-

Entity System Profiler Controls

- {DEBUGGER_REQUEST_COMMANDS.map(command => { - const inFlight = isDebuggerRequestInFlight(command.command); - return ( - { - setLastRequestedCommand(command.command); - - if (command.command === 'Clear Entity System Profiler') { - clearProfilerData(); - } - - sendDebuggerRequest(command.command); - }} - style={{ margin: '5px' }} - > - {command.label} - - ); - })} - {isStarted && ( -
-
- - ) => { - const target = event.target as HTMLSelectElement; - setFilterSelectionMode(target.value as FilterSelectionMode); - }} - title={getFilterSelectionModeTooltip(ecsVersion)} - > - No Filter - Filter By Entity - - Filter By Single Entity - - {ecsVersion >= 2 && ( - Filter By System - )} - -
- {filterSelectionMode === 'filter-by-single-entity' && ( -
- - ) => { - const target = event.target as HTMLSelectElement; - const entityId = target.value || undefined; - setSelectedSingleEntityId(entityId); - if (entityId) { - setLastRequestedCommand(FILTER_SINGLE_ENTITY_REQUEST); - sendDebuggerRequest(FILTER_SINGLE_ENTITY_REQUEST, { - entityIds: [entityId], - }); - } else { - // If we don't have a valid entity selected, still send the event with no id's - setLastRequestedCommand(FILTER_SINGLE_ENTITY_REQUEST); - sendDebuggerRequest(FILTER_SINGLE_ENTITY_REQUEST, { - entityIds: [], - }); - } - }} - > - -- select an entity -- - {availableEntities.map(({ id, fullName }) => ( - - {fullName} - - ))} - -
- )} -
- - ) => { - 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 - - {ecsVersion >= 4 && ( - - Export Entity System Matrix CSV - - )} -
-
- )} -
-
- {isStarted && ( -
-
-
-
-
-

Entity Timings

-
- - {filteredSystemLabel} - -
-
-
-
- - ) => { - const target = event.target as HTMLSelectElement; - setEntityTimingUnit(target.value as TimingUnit); - }} - > - Nanoseconds - Microseconds - Milliseconds - -
-
- - ) => { - const target = event.target as HTMLSelectElement; - setEntityViewMode(target.value as EntityViewMode); - }} - > - Grouped - Flat - -
-
-
- - resolveEcsColumn(event.id)} - sparklineColumnIndex={0} - sparklineTickRange={trendDurationPoints} - sparklineValueFormatter={value => formatTimingValue(value, entityTimingUnit)} - rowDetailsRenderer={ecsVersion >= 3 ? renderEntityRowDetails : undefined} - valueFormatter={(value, columnIndex) => { - // Timing column - if (columnIndex === 0) { - return formatTimingValue(Number(value), entityTimingUnit); - } - // Percentage column - else if (columnIndex === 1) { - return formatPercentageValue(Number(value)); - } - - return String(value); - }} - /> -
-
-
-
-

System Timings

-
- - {filteredEntityLabel} - -
-
-
-
- - ) => { - const target = event.target as HTMLSelectElement; - setSystemTimingUnit(target.value as TimingUnit); - }} - > - Nanoseconds - Microseconds - Milliseconds - -
-
- - ) => { - const target = event.target as HTMLSelectElement; - setSystemViewMode(target.value as SystemViewMode); - }} - > - Grouped - Flat - -
-
-
- - resolveSystemCategoryGroupKey(fullName, systemCategoryLegendMap) - } - groupCountLabel="systems" - defaultCollapsed={true} - groupColumnAggregations={[ - MinecraftGroupedStatisticTableColumnAggregation.Average, - MinecraftGroupedStatisticTableColumnAggregation.Sum, - ]} - statisticDataProvider={ - new MultipleStatisticProvider({ - statisticIds: ['time_in_ns', 'percent_of_total'], - statisticParentId: new RegExp(`${selectedClient}_client_ecs_systems`), - }) - } - statisticResolver={ParentNameStatResolver( - createStatResolver({ - type: StatisticType.Absolute, - tickRange: 20 * 10, - yAxisType: YAxisType.Absolute, - valueScalar: 1, - }), - )} - defaultSortColumn="value_1" - defaultSortOrder={MinecraftGroupedStatisticTableSortOrder.Descending} - defaultSortType={MinecraftGroupedStatisticTableSortType.Numerical} - prettifyNames={false} - nonConsolidatedColumnResolver={event => resolveEcsColumn(event.id)} - sparklineColumnIndex={0} - sparklineTickRange={trendDurationPoints} - sparklineValueFormatter={value => formatTimingValue(value, systemTimingUnit)} - keyFormatter={formatStatKey} - valueFormatter={(value, columnIndex) => { - // Timing column - if (columnIndex === 0) { - return formatTimingValue(Number(value), systemTimingUnit); - } - // Percentage column - else if (columnIndex === 1) { - return formatPercentageValue(Number(value)); - } - - return String(value); - }} - /> -
-
-
- )} - {isEntitySystemMatrixCsvDialogOpen && ( -
-
-

Export Entity System Matrix CSV

-

Select which data should be grouped in the CSV.

-
- - setGroupEntitySystemMatrixCsvEntities(currentValue => !currentValue) - } - > - Group Entities - - setGroupEntitySystemMatrixCsvSystems(currentValue => !currentValue)} - > - Group Systems - -
-
- Cancel - - Export CSV - -
-
-
- )} -
- ); - }, -}; + requestTarget: DebuggerRequestTarget.Client, + statGroupPrefix: selectedClient => `${selectedClient}_client_ecs`, +}); export default StatsTab; diff --git a/webview-ui/src/diagnostics_panel/prefabs/tabs/EntitySystemsProfiler.tsx b/webview-ui/src/diagnostics_panel/prefabs/tabs/EntitySystemsProfiler.tsx new file mode 100644 index 0000000..63f6a15 --- /dev/null +++ b/webview-ui/src/diagnostics_panel/prefabs/tabs/EntitySystemsProfiler.tsx @@ -0,0 +1,1257 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. + +// This is a template for the Entity Systems Profiler tabs. +// It provides the structure and utilities for displaying entity and system statistics in a grouped table format. +// Exports createEntitySystemsTab for other tabs to create configured instances of it (e.g. Client and Server). + +import { ParentNameStatResolver, StatisticType, YAxisType, createStatResolver } from '../../StatisticResolver'; +import { TabPrefab, TabPrefabDataSource, TabPrefabParams } from '../TabPrefab'; +import MinecraftGroupedStatisticTable, { + GroupedStatisticTableRow, + GroupedStatisticTableRowDetailsContext, + MinecraftGroupedStatisticTableExportRow, + MinecraftGroupedStatisticTableColumnAggregation, + MinecraftGroupedStatisticTableDisplayMode, + MinecraftGroupedStatisticTableHandle, + MinecraftGroupedStatisticTableSelectionSnapshot, + MinecraftGroupedStatisticTableSortOrder, + MinecraftGroupedStatisticTableSortType, + SPARKLINE_SAMPLE_INTERVAL_SECONDS, +} from '../../controls/MinecraftGroupedStatisticTable'; +import { + DebuggerRequestResultMessage, + DebuggerRequestTarget, + getDebuggerRequestResult, + isDebuggerRequestInFlight, + sendDebuggerRequest, + useDebuggerRequestUpdates, +} from '../../utilities/useDebuggerRequests'; +import { DebuggerRequestResultBanner } from '../../controls/DebuggerRequestResult'; +import { MultipleStatisticProvider, StatisticUpdatedMessage } from '../../StatisticProvider'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { VSCodeButton, VSCodeCheckbox, VSCodeDropdown, VSCodeOption } from '@vscode/webview-ui-toolkit/react'; +import { CsvCellValue, TableCsvExporter } from '../../exporters/TableCsvExporter'; +import { sendExportDataRequest } from '../../utilities/exportData'; +import { SparklineCell } from '../../controls/SparklineCell'; + +export const ECS_VERSIONS = { + baseline: 1, // Initial entity/system timings and entity filtering. + systemFiltering: 2, // Adds filtering by system. + entityDetails: 3, // Adds entity position/dimension details and row trends. + matrixCsvExport: 4, // Adds entity-system matrix CSV export. + serverProfiler: 5, // Adds server-side ECS profiling. +} as const; + +type DiagnosticsExportTableType = 'entity' | 'system'; + +const START_ENTITY_SYSTEM_PROFILER_REQUEST = 'Start Entity System Profiler'; +const STOP_ENTITY_SYSTEM_PROFILER_REQUEST = 'Stop Entity System Profiler'; +const CLEAR_ENTITY_SYSTEM_PROFILER_REQUEST = 'Clear Entity System Profiler'; +const FILTER_MULTI_ENTITY_REQUEST = 'Filter Multi Entity System Profiler'; +const FILTER_SINGLE_ENTITY_REQUEST = 'Filter Single Entity System Profiler'; +const FILTER_BY_SYSTEM_REQUEST = 'Filter By System Entity System Profiler'; +const REQUEST_CSV_ENTITY_SYSTEM_PROFILER_REQUEST = 'Request CSV Entity System Profiler'; + +const DEBUGGER_REQUEST_COMMANDS = [ + { command: START_ENTITY_SYSTEM_PROFILER_REQUEST, label: 'Start' }, + { command: STOP_ENTITY_SYSTEM_PROFILER_REQUEST, label: 'Stop' }, + { command: CLEAR_ENTITY_SYSTEM_PROFILER_REQUEST, label: 'Clear' }, +]; + +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; +type OptionalEntityStats = { + positionX?: number; + positionY?: number; + positionZ?: number; + dimension?: string; +}; + +const OPTIONAL_ENTITY_STAT_IDS = ['position_x', 'position_y', 'position_z', 'dimension'] as const; + +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 buildEntitySystemMatrixCsvFileName(now: Date): string { + return `entity-system-profiler-${formatExportTimestamp(now)}.csv`; +} + +function getFilterSelectionModeTooltip(ecsVersion: number): string { + if (ecsVersion >= ECS_VERSIONS.systemFiltering) { + return ( + 'Select Filtering Mode:\n' + + '\u2022 "No Filter" will display system and entity timings for all entities and systems.\n' + + '\u2022 "Filter By Entity" allows you to select specific entities, and only show the system information for that selection.\n' + + '\u2022 "Filter By Single Entity" allows you to select one specific entity, and only display information for that selection.\n' + + ' This option has some performance benefits on the backend profiler versus multi select.\n' + + '\u2022 "Filter By System" allows you to select specific systems and will only show the entities and entity timing values for those systems.' + ); + } else if (ecsVersion === ECS_VERSIONS.baseline) { + return ( + 'Select Filtering Mode:\n' + + '\u2022 "No Filter" will display system and entity timings for all entities and systems.\n' + + '\u2022 "Filter By Entity" allows you to select specific entities, and only show the system information for that selection.\n' + + '\u2022 "Filter By Single Entity" allows you to select one specific entity, and only display information for that selection.\n' + + ' This option has some performance benefits on the backend profiler versus multi select.' + ); + } else { + console.error('Received unknown ECS version from client, defaulting to showing all filter options in tooltip'); + return ( + 'Select Filtering Mode:\n' + + '\u2022 "No Filter" will display system and entity timings for all entities and systems.\n' + + '\u2022 "Filter By Entity" allows you to select specific entities, and only show the system information for that selection.\n' + + '\u2022 "Filter By Single Entity" allows you to select one specific entity, and only display information for that selection.\n' + + ' This option has some performance benefits on the backend profiler versus multi select.\n' + + '\u2022 "Filter By System" allows you to select specific systems and will only show the entities and entity timing values for those systems.' + ); + } +} + +function ceilToDecimalPlace(value: number, decimalPlaces: number): string { + return (Math.ceil(value * Math.pow(10, decimalPlaces)) / Math.pow(10, decimalPlaces)).toFixed(decimalPlaces); +} + +function getTimingColumnLabel(unit: TimingUnit): string { + if (unit === 'ms') { + return 'Time (ms)'; + } + + if (unit === 'us') { + return 'Time (µs)'; + } + + return 'Time (ns)'; +} + +function formatTimingValue(value: number, unit: TimingUnit): string { + if (!Number.isFinite(value)) { + return `0 ${unit}`; + } + + if (unit === 'ms') { + return `${ceilToDecimalPlace(value / 1_000_000, 3)} ms`; + } + + if (unit === 'us') { + return `${ceilToDecimalPlace(value / 1_000, 1)} µs`; + } + + return `${value} ns`; +} + +function formatPercentageValue(value: number): string { + if (!Number.isFinite(value)) { + return '0 %'; + } + + return `${ceilToDecimalPlace(value, 0)} %`; +} + +function resolveEcsColumn(eventId: string): number | undefined { + switch (eventId) { + case 'time_in_ns': + return 0; + case 'percent_of_total': + return 1; + default: + return undefined; + } +} + +function resolveEntityTypeGroupKey(fullName: string): string { + // Example fullName: "minecraft:entity_name<> (2:12345)" + // First remove anything after the first '<' + let groupName = fullName ? fullName.split('<')[0] : 'NULL'; + // Then account for the case we don't have the '<>' and remove anything after the first '(' + groupName = groupName ? groupName.split('(')[0] : 'NULL'; + return groupName; +} + +function extractEntityId(entityCategory: string): string | undefined { + const match = entityCategory.match(/[#:](\d+)\)\s*$/); + return match?.[1]; +} + +function resolveLatestNumericEventValue(event: StatisticUpdatedMessage): number | undefined { + if (event.values.length > 0) { + const value = event.values[event.values.length - 1]; + return Number.isFinite(value) ? value : undefined; + } + + const rows = event.children_string_values || []; + for (const row of rows) { + for (let index = row.length - 1; index >= 0; index -= 1) { + const numericValue = Number.parseFloat(row[index]); + if (Number.isFinite(numericValue)) { + return numericValue; + } + } + } + + return undefined; +} + +function resolveLatestStringEventValue(event: StatisticUpdatedMessage): string | undefined { + const rows = event.children_string_values || []; + + for (const row of rows) { + for (let index = row.length - 1; index >= 0; index -= 1) { + const value = row[index]?.trim(); + if (value && Number.isNaN(Number.parseFloat(value))) { + return value; + } + } + } + + if (event.values.length > 0) { + return String(event.values[event.values.length - 1]); + } + + return undefined; +} + +function formatOptionalCoordinate(value: number | undefined): string { + if (value === undefined || !Number.isFinite(value)) { + return 'N/A'; + } + + return value.toFixed(2); +} + +function formatOptionalDimension(value: string | undefined): string { + if (!value || value.trim().length === 0) { + return 'N/A'; + } + + return value; +} + +function formatSparklineDurationFromPoints(points: number): string { + const totalSeconds = points * SPARKLINE_SAMPLE_INTERVAL_SECONDS; + + if (totalSeconds >= 60) { + const minutes = Math.floor(totalSeconds / 60); + const seconds = Math.round(totalSeconds % 60); + return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`; + } + + return totalSeconds < 10 ? `${totalSeconds.toFixed(1)}s` : `${Math.round(totalSeconds)}s`; +} + +function formatStatKey(key: string): string { + return key.split('(')[0].trim(); +} + +function resolveSystemId(fullName: string): string | undefined { + // Example fullname: "System Name (4)" + const indexAndBracket = fullName ? fullName.split('(')[1] : undefined; + const index = indexAndBracket ? indexAndBracket.split(')')[0].trim() : undefined; + return index; +} + +function resolveSystemCategoryGroupKey(fullName: string, systemCategoryLegendMap: Map): string { + // Example fullName: "System Name (4)" + // Take anything before the first '(' + const indexAndBracket = fullName ? fullName.split('(')[1] : undefined; + const index = indexAndBracket ? indexAndBracket.split(')')[0].trim() : undefined; + // Then lookup the category name from the legend map if it exists + return index ? systemCategoryLegendMap.get(index) || UNCATEGORIZED_SYSTEM_GROUP : UNCATEGORIZED_SYSTEM_GROUP; +} + +interface EntitySystemsTabConfig { + name: string; + dataSource: TabPrefabDataSource.Client | TabPrefabDataSource.Server; + requestTarget: DebuggerRequestTarget; + statGroupPrefix: (selectedClient: string) => string; + minSupportedVersion?: number; +} + +export function createEntitySystemsTab(config: EntitySystemsTabConfig): TabPrefab { + const content = ({ selectedClient, onRunCommand }: TabPrefabParams) => { + const statGroupPrefix = config.statGroupPrefix(selectedClient); + const canCollectAuxiliaryStats = + config.dataSource === TabPrefabDataSource.Server || selectedClient.length > 0; + useDebuggerRequestUpdates(); + const [lastRequestedCommand, setLastRequestedCommand] = useState(''); + const [entityTimingUnit, setEntityTimingUnit] = useState('ms'); + const [systemTimingUnit, setSystemTimingUnit] = useState('us'); + const [entityViewMode, setEntityViewMode] = useState('grouped'); + const [systemViewMode, setSystemViewMode] = useState('grouped'); + const [systemCategoryLegendMap, setSystemCategoryLegendMap] = useState>(new Map()); + const [filterSelectionMode, setFilterSelectionMode] = useState('no-filter'); + const [availableEntities, setAvailableEntities] = useState<{ id: string; fullName: string }[]>([]); + const [optionalEntityStatsById, setOptionalEntityStatsById] = useState>( + new Map(), + ); + const [selectedSingleEntityId, setSelectedSingleEntityId] = useState(undefined); + const [filteredEntityCount, setFilteredEntityCount] = useState(undefined); + const [filteredSystemCount, setFilteredSystemCount] = useState(undefined); + const [ecsVersion, setECSVersion] = useState(ECS_VERSIONS.baseline); + const [hasReceivedEcsVersion, setHasReceivedEcsVersion] = useState(false); + const [isStarted, setIsStarted] = useState(false); + const [trendDurationPoints, setTrendDurationPoints] = useState(DEFAULT_TREND_DURATION_POINTS); + const [isEntitySystemMatrixCsvExportPending, setIsEntitySystemMatrixCsvExportPending] = + useState(false); + const [isEntitySystemMatrixCsvDialogOpen, setIsEntitySystemMatrixCsvDialogOpen] = useState(false); + const [groupEntitySystemMatrixCsvEntities, setGroupEntitySystemMatrixCsvEntities] = useState(false); + const [groupEntitySystemMatrixCsvSystems, setGroupEntitySystemMatrixCsvSystems] = useState(false); + const entityTimingsTableRef = useRef(null); + const systemTimingsTableRef = useRef(null); + const isMountRef = useRef(true); + const csvExporter = useMemo(() => new TableCsvExporter(), []); + const categoriesProvider = useMemo(() => { + if (!canCollectAuxiliaryStats) { + return undefined; + } + + return new MultipleStatisticProvider({ + statisticParentId: new RegExp(`${statGroupPrefix}_categories`), + }); + }, [canCollectAuxiliaryStats, statGroupPrefix]); + + const entityIdsProvider = useMemo(() => { + if (!canCollectAuxiliaryStats) { + return undefined; + } + + return new MultipleStatisticProvider({ + statisticIds: ['time_in_ns'], + statisticParentId: new RegExp(`${statGroupPrefix}_entities`), + }); + }, [canCollectAuxiliaryStats, statGroupPrefix]); + + const optionalEntityStatsProvider = useMemo(() => { + if (!canCollectAuxiliaryStats) { + return undefined; + } + + return new MultipleStatisticProvider({ + statisticIds: [...OPTIONAL_ENTITY_STAT_IDS], + statisticParentId: new RegExp(`${statGroupPrefix}_entities`), + }); + }, [canCollectAuxiliaryStats, statGroupPrefix]); + + useEffect(() => { + setSystemCategoryLegendMap(new Map()); + + if (!canCollectAuxiliaryStats || !categoriesProvider) { + return; + } + + const eventHandler = (event: StatisticUpdatedMessage): void => { + const nameAndIndex = event.group_name; + const name = nameAndIndex.split('(')[0].trim(); + const index = nameAndIndex.split('(')[1]?.split(')')[0].trim(); + + if (index && name) { + setSystemCategoryLegendMap(prev => { + const newMap = new Map(prev); + newMap.set(index, name); + return newMap; + }); + } + }; + + categoriesProvider.registerWindowListener(window); + categoriesProvider.addSubscriber(eventHandler); + + return () => { + categoriesProvider.removeSubscriber(eventHandler); + categoriesProvider.unregisterWindowListener(window); + }; + }, [canCollectAuxiliaryStats, categoriesProvider]); + + useEffect(() => { + setAvailableEntities([]); + setSelectedSingleEntityId(undefined); + + if (!entityIdsProvider) { + return; + } + + const seenIds = new Set(); + + const eventHandler = (event: StatisticUpdatedMessage): void => { + const entityId = extractEntityId(event.group_name); + if (entityId && !seenIds.has(entityId)) { + seenIds.add(entityId); + setAvailableEntities(prev => [...prev, { id: entityId, fullName: event.group_name }]); + } + }; + + entityIdsProvider.registerWindowListener(window); + entityIdsProvider.addSubscriber(eventHandler); + + return () => { + entityIdsProvider.removeSubscriber(eventHandler); + entityIdsProvider.unregisterWindowListener(window); + }; + }, [entityIdsProvider]); + + useEffect(() => { + setOptionalEntityStatsById(new Map()); + + if (!optionalEntityStatsProvider) { + return; + } + + const eventHandler = (event: StatisticUpdatedMessage): void => { + const entityId = extractEntityId(event.group_name); + if (!entityId) { + return; + } + + setOptionalEntityStatsById(previousStats => { + const nextStats = new Map(previousStats); + const existing = nextStats.get(entityId) ?? {}; + + switch (event.id) { + case 'position_x': { + const numericValue = resolveLatestNumericEventValue(event); + nextStats.set(entityId, { + ...existing, + positionX: numericValue, + }); + break; + } + case 'position_y': { + const numericValue = resolveLatestNumericEventValue(event); + nextStats.set(entityId, { + ...existing, + positionY: numericValue, + }); + break; + } + case 'position_z': { + const numericValue = resolveLatestNumericEventValue(event); + nextStats.set(entityId, { + ...existing, + positionZ: numericValue, + }); + break; + } + case 'dimension': { + const dimensionValue = resolveLatestStringEventValue(event); + nextStats.set(entityId, { + ...existing, + dimension: dimensionValue, + }); + break; + } + default: + break; + } + + return nextStats; + }); + }; + + optionalEntityStatsProvider.registerWindowListener(window); + optionalEntityStatsProvider.addSubscriber(eventHandler); + + return () => { + optionalEntityStatsProvider.removeSubscriber(eventHandler); + optionalEntityStatsProvider.unregisterWindowListener(window); + }; + }, [optionalEntityStatsProvider]); + + useEffect(() => { + if (isMountRef.current) { + isMountRef.current = false; + return; + } + + entityTimingsTableRef.current?.clearSelection(); + setSelectedSingleEntityId(undefined); + setFilteredEntityCount(undefined); + setFilteredSystemCount(undefined); + + setLastRequestedCommand(START_ENTITY_SYSTEM_PROFILER_REQUEST); + sendDebuggerRequest(START_ENTITY_SYSTEM_PROFILER_REQUEST, { target: config.requestTarget }); + }, [filterSelectionMode]); + + const handleEntitySelectionChange = useCallback( + (snapshot: MinecraftGroupedStatisticTableSelectionSnapshot): void => { + const entityIds = Array.from( + new Set( + snapshot.resolvedSelectedRows + .map(row => extractEntityId(row.category)) + .filter((entityId): entityId is string => entityId !== undefined), + ), + ); + + setFilteredEntityCount(entityIds.length); + setLastRequestedCommand(FILTER_MULTI_ENTITY_REQUEST); + sendDebuggerRequest(FILTER_MULTI_ENTITY_REQUEST, { target: config.requestTarget, entityIds }); + }, + [filterSelectionMode], + ); + + const handleSystemSelectionChange = useCallback( + (snapshot: MinecraftGroupedStatisticTableSelectionSnapshot): void => { + const systemIds = Array.from( + new Set( + snapshot.resolvedSelectedRows + .map(row => resolveSystemId(row.category)) + .filter((systemId): systemId is string => systemId !== undefined), + ), + ); + + setFilteredSystemCount(systemIds.length); + setLastRequestedCommand(FILTER_BY_SYSTEM_REQUEST); + sendDebuggerRequest(FILTER_BY_SYSTEM_REQUEST, { target: config.requestTarget, systemIds }); + }, + [filterSelectionMode], + ); + + 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(); + + setOptionalEntityStatsById(new Map()); + setSelectedSingleEntityId(undefined); + setFilteredEntityCount(undefined); + setFilteredSystemCount(undefined); + }, []); + + const filteredEntityLabel = (() => { + if (filterSelectionMode === 'filter-by-single-entity') { + return selectedSingleEntityId ? 'Showing System Timings For 1 Entity' : 'Showing All System Timings'; + } + if ( + filterSelectionMode === 'filter-by-entity' && + filteredEntityCount !== undefined && + filteredEntityCount > 0 + ) { + return `Showing System Timings For ${filteredEntityCount} ${filteredEntityCount === 1 ? 'Entity' : 'Entities'}`; + } + return 'Showing All System Timings'; + })(); + + const filteredSystemLabel = (() => { + if ( + filterSelectionMode === 'filter-by-system' && + filteredSystemCount !== undefined && + filteredSystemCount > 0 + ) { + return `Showing Entity Timings For ${filteredSystemCount} ${filteredSystemCount === 1 ? 'System' : 'Systems'}`; + } + return 'Showing All Entity Timings'; + })(); + + const lastResult: DebuggerRequestResultMessage | undefined = lastRequestedCommand + ? getDebuggerRequestResult(lastRequestedCommand, config.requestTarget) + : undefined; + const isOnSupportedEcsVersion = + !hasReceivedEcsVersion || + config.minSupportedVersion === undefined || + ecsVersion >= config.minSupportedVersion; + const entitySystemMatrixCsvResult = getDebuggerRequestResult( + REQUEST_CSV_ENTITY_SYSTEM_PROFILER_REQUEST, + config.requestTarget, + ); + + useEffect(() => { + if (lastRequestedCommand !== START_ENTITY_SYSTEM_PROFILER_REQUEST) { + return; + } + + if (typeof lastResult?.response?.args === 'number') { + setECSVersion(lastResult.response.args); + setHasReceivedEcsVersion(true); + console.log(`Received ECS version ${lastResult.response.args} from client`); + } + + setIsStarted(lastResult?.response?.success ?? false); + console.log(`Entity System Profiler is started: ${isStarted}`); + }, [lastResult, lastRequestedCommand]); + + useEffect(() => { + if ( + !isEntitySystemMatrixCsvExportPending || + isDebuggerRequestInFlight(REQUEST_CSV_ENTITY_SYSTEM_PROFILER_REQUEST) + ) { + return; + } + + setIsEntitySystemMatrixCsvExportPending(false); + + if ( + entitySystemMatrixCsvResult?.response?.success !== true || + typeof entitySystemMatrixCsvResult.response.args !== 'string' + ) { + return; + } + + sendExportDataRequest({ + format: csvExporter.format, + mimeType: csvExporter.mimeType, + suggestedFileName: buildEntitySystemMatrixCsvFileName(new Date()), + content: entitySystemMatrixCsvResult.response.args, + }); + }, [entitySystemMatrixCsvResult, csvExporter, isEntitySystemMatrixCsvExportPending]); + + const openEntitySystemMatrixCsvDialog = useCallback((): void => { + setIsEntitySystemMatrixCsvDialogOpen(true); + }, []); + + const closeEntitySystemMatrixCsvDialog = useCallback((): void => { + setIsEntitySystemMatrixCsvDialogOpen(false); + }, []); + + const requestEntitySystemMatrixCsv = useCallback((): void => { + setIsEntitySystemMatrixCsvExportPending(true); + setIsEntitySystemMatrixCsvDialogOpen(false); + setLastRequestedCommand(REQUEST_CSV_ENTITY_SYSTEM_PROFILER_REQUEST); + sendDebuggerRequest(REQUEST_CSV_ENTITY_SYSTEM_PROFILER_REQUEST, { + target: config.requestTarget, + csvArgs: { + groupEntities: groupEntitySystemMatrixCsvEntities, + groupSystems: groupEntitySystemMatrixCsvSystems, + }, + }); + }, [groupEntitySystemMatrixCsvEntities, groupEntitySystemMatrixCsvSystems]); + + 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], + ); + + const renderEntityRowDetails = useCallback( + ( + row: GroupedStatisticTableRow, + _groupKey: string, + context: GroupedStatisticTableRowDetailsContext, + ): JSX.Element => { + const entityId = extractEntityId(row.category); + const optionalStats = entityId ? optionalEntityStatsById.get(entityId) : undefined; + const coordinates = [optionalStats?.positionX, optionalStats?.positionY, optionalStats?.positionZ]; + const dimension = optionalStats?.dimension?.trim() ?? ''; + const hasValidTeleportDestination = + selectedClient.trim().length > 0 && + dimension.length > 0 && + coordinates.every(value => typeof value === 'number' && Number.isFinite(value)); + + // Teleports the selected client to this entity's dimension and coordinates if they are valid. + const teleportCommand = hasValidTeleportDestination + ? `/execute in ${dimension} run tp ${JSON.stringify(selectedClient)} ${coordinates.join(' ')}` + : undefined; + + return ( +
+
+
+ Position X + + {formatOptionalCoordinate(optionalStats?.positionX)} + +
+
+ Position Y + + {formatOptionalCoordinate(optionalStats?.positionY)} + +
+
+ Position Z + + {formatOptionalCoordinate(optionalStats?.positionZ)} + +
+
+ Dimension + + {formatOptionalDimension(optionalStats?.dimension)} + +
+
+ { + if (teleportCommand) { + onRunCommand(teleportCommand); + } + }} + > + Teleport to Entity + +
+
+ Trend ({formatSparklineDurationFromPoints(context.sparklineTickRange)}) +
+ formatTimingValue(value, entityTimingUnit)} + displayedMin={context.sparklineDisplayedMin} + displayedMax={context.sparklineDisplayedMax} + showYAxisTicks={true} + yAxisTickCount={5} + yAxisLabelFormatter={value => formatTimingValue(value, entityTimingUnit)} + lineStrokeWidth={2} + /> +
+
+ ); + }, + [entityTimingUnit, optionalEntityStatsById, selectedClient, onRunCommand], + ); + + return ( +
+
+
+
+ {!isOnSupportedEcsVersion ? ( +
+
Warning
+
+
+ The connected Minecraft game version doesn't support this feature. Controls + may not work properly. +
+
+
+ ) : ( + + )} +
+

Entity System Profiler Controls

+ {DEBUGGER_REQUEST_COMMANDS.map(command => { + const inFlight = isDebuggerRequestInFlight(command.command); + return ( + { + setLastRequestedCommand(command.command); + + if (command.command === CLEAR_ENTITY_SYSTEM_PROFILER_REQUEST) { + clearProfilerData(); + } + + sendDebuggerRequest(command.command, { target: config.requestTarget }); + }} + style={{ margin: '5px' }} + > + {command.label} + + ); + })} + {isStarted && isOnSupportedEcsVersion && ( +
+
+ + ) => { + const target = event.target as HTMLSelectElement; + setFilterSelectionMode(target.value as FilterSelectionMode); + }} + title={getFilterSelectionModeTooltip(ecsVersion)} + > + No Filter + Filter By Entity + + Filter By Single Entity + + {ecsVersion >= ECS_VERSIONS.systemFiltering && ( + Filter By System + )} + +
+ {filterSelectionMode === 'filter-by-single-entity' && ( +
+ + ) => { + const target = event.target as HTMLSelectElement; + const entityId = target.value || undefined; + setSelectedSingleEntityId(entityId); + if (entityId) { + setLastRequestedCommand(FILTER_SINGLE_ENTITY_REQUEST); + sendDebuggerRequest(FILTER_SINGLE_ENTITY_REQUEST, { + target: config.requestTarget, + entityIds: [entityId], + }); + } else { + // If we don't have a valid entity selected, still send the event with no id's + setLastRequestedCommand(FILTER_SINGLE_ENTITY_REQUEST); + sendDebuggerRequest(FILTER_SINGLE_ENTITY_REQUEST, { + target: config.requestTarget, + entityIds: [], + }); + } + }} + > + -- select an entity -- + {availableEntities.map(({ id, fullName }) => ( + + {fullName} + + ))} + +
+ )} +
+ + ) => { + 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 + + {ecsVersion >= ECS_VERSIONS.matrixCsvExport && ( + + Export Entity System Matrix CSV + + )} +
+
+ )} +
+
+ {isStarted && isOnSupportedEcsVersion && ( +
+
+
+
+
+

Entity Timings

+
+ + {filteredSystemLabel} + +
+
+
+
+ + ) => { + const target = event.target as HTMLSelectElement; + setEntityTimingUnit(target.value as TimingUnit); + }} + > + Nanoseconds + Microseconds + Milliseconds + +
+
+ + ) => { + const target = event.target as HTMLSelectElement; + setEntityViewMode(target.value as EntityViewMode); + }} + > + Grouped + Flat + +
+
+
+ + resolveEcsColumn(event.id)} + sparklineColumnIndex={0} + sparklineTickRange={trendDurationPoints} + sparklineValueFormatter={value => formatTimingValue(value, entityTimingUnit)} + rowDetailsRenderer={ + ecsVersion >= ECS_VERSIONS.entityDetails ? renderEntityRowDetails : undefined + } + valueFormatter={(value, columnIndex) => { + // Timing column + if (columnIndex === 0) { + return formatTimingValue(Number(value), entityTimingUnit); + } + // Percentage column + else if (columnIndex === 1) { + return formatPercentageValue(Number(value)); + } + + return String(value); + }} + /> +
+
+
+
+

System Timings

+
+ + {filteredEntityLabel} + +
+
+
+
+ + ) => { + const target = event.target as HTMLSelectElement; + setSystemTimingUnit(target.value as TimingUnit); + }} + > + Nanoseconds + Microseconds + Milliseconds + +
+
+ + ) => { + const target = event.target as HTMLSelectElement; + setSystemViewMode(target.value as SystemViewMode); + }} + > + Grouped + Flat + +
+
+
+ + resolveSystemCategoryGroupKey(fullName, systemCategoryLegendMap) + } + groupCountLabel="systems" + defaultCollapsed={true} + groupColumnAggregations={[ + MinecraftGroupedStatisticTableColumnAggregation.Average, + MinecraftGroupedStatisticTableColumnAggregation.Sum, + ]} + statisticDataProvider={ + new MultipleStatisticProvider({ + statisticIds: ['time_in_ns', 'percent_of_total'], + statisticParentId: new RegExp(`${statGroupPrefix}_systems`), + }) + } + statisticResolver={ParentNameStatResolver( + createStatResolver({ + type: StatisticType.Absolute, + tickRange: 20 * 10, + yAxisType: YAxisType.Absolute, + valueScalar: 1, + }), + )} + defaultSortColumn="value_1" + defaultSortOrder={MinecraftGroupedStatisticTableSortOrder.Descending} + defaultSortType={MinecraftGroupedStatisticTableSortType.Numerical} + prettifyNames={false} + nonConsolidatedColumnResolver={event => resolveEcsColumn(event.id)} + sparklineColumnIndex={0} + sparklineTickRange={trendDurationPoints} + sparklineValueFormatter={value => formatTimingValue(value, systemTimingUnit)} + keyFormatter={formatStatKey} + valueFormatter={(value, columnIndex) => { + // Timing column + if (columnIndex === 0) { + return formatTimingValue(Number(value), systemTimingUnit); + } + // Percentage column + else if (columnIndex === 1) { + return formatPercentageValue(Number(value)); + } + + return String(value); + }} + /> +
+
+
+ )} + {isEntitySystemMatrixCsvDialogOpen && ( +
+
+

Export Entity System Matrix CSV

+

Select which data should be grouped in the CSV.

+
+ + setGroupEntitySystemMatrixCsvEntities(currentValue => !currentValue) + } + > + Group Entities + + setGroupEntitySystemMatrixCsvSystems(currentValue => !currentValue)} + > + Group Systems + +
+
+ Cancel + + Export CSV + +
+
+
+ )} +
+ ); + }; + + return { + name: config.name, + dataSource: config.dataSource, + content, + }; +} diff --git a/webview-ui/src/diagnostics_panel/prefabs/tabs/ServerEntitySystems.tsx b/webview-ui/src/diagnostics_panel/prefabs/tabs/ServerEntitySystems.tsx new file mode 100644 index 0000000..b485267 --- /dev/null +++ b/webview-ui/src/diagnostics_panel/prefabs/tabs/ServerEntitySystems.tsx @@ -0,0 +1,15 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. + +import { TabPrefabDataSource } from '../TabPrefab'; +import { DebuggerRequestTarget } from '../../utilities/useDebuggerRequests'; +import { createEntitySystemsTab, ECS_VERSIONS } from './EntitySystemsProfiler'; + +const StatsTab = createEntitySystemsTab({ + name: 'Server - Entity Systems', + dataSource: TabPrefabDataSource.Server, + requestTarget: DebuggerRequestTarget.Server, + statGroupPrefix: () => 'server_ecs', + minSupportedVersion: ECS_VERSIONS.serverProfiler, +}); + +export default StatsTab; diff --git a/webview-ui/src/diagnostics_panel/utilities/useDebuggerRequests.ts b/webview-ui/src/diagnostics_panel/utilities/useDebuggerRequests.ts index ba21228..797d107 100644 --- a/webview-ui/src/diagnostics_panel/utilities/useDebuggerRequests.ts +++ b/webview-ui/src/diagnostics_panel/utilities/useDebuggerRequests.ts @@ -13,7 +13,12 @@ export type DebuggerRequestResultMessage = { error?: string; }; -const InFlightDebuggerRequests = new Set(); +export enum DebuggerRequestTarget { + Client = 'client', + Server = 'server', +} + +const InFlightDebuggerRequests = new Map(); const DebuggerRequestResults = new Map(); const DebuggerRequestListeners = new Set<() => void>(); @@ -30,6 +35,17 @@ function subscribeToDebuggerRequestUpdates(listener: () => void): () => void { }; } +function getDebuggerRequestTarget(request: string, args: unknown): DebuggerRequestTarget | undefined{ + if (typeof args === 'object' && args !== null && 'target' in args) { + if (args.target !== DebuggerRequestTarget.Client && args.target !== DebuggerRequestTarget.Server) { + console.warn(`Invalid target for debugger request '${request}':`, args.target); + return undefined; + } + return args.target; + } + return undefined; +} + // Sends a debugger request to the extension host. // Duplicate requests with the same `request` string are ignored if one is in-flight. export function sendDebuggerRequest(request: string, args?: unknown): void { @@ -38,7 +54,8 @@ export function sendDebuggerRequest(request: string, args?: unknown): void { return; } - InFlightDebuggerRequests.add(request); + const target = getDebuggerRequestTarget(request, args); + InFlightDebuggerRequests.set(request, target); notifyDebuggerRequestListeners(); vscode.postMessage({ @@ -53,14 +70,18 @@ export function isDebuggerRequestInFlight(request: string): boolean { return InFlightDebuggerRequests.has(request); } -// Returns the latest result message for a given request key. -export function getDebuggerRequestResult(request: string): DebuggerRequestResultMessage | undefined { - return DebuggerRequestResults.get(request); +// Returns the latest result message for a given request and optional target. +export function getDebuggerRequestResult( + request: string, + target?: DebuggerRequestTarget, +): DebuggerRequestResultMessage | undefined { + return DebuggerRequestResults.get(JSON.stringify([request, target])); } // Accepts an incoming result message of type `debugger-request-result` and updates results. export function handleDebuggerRequestResult(message: DebuggerRequestResultMessage): void { - DebuggerRequestResults.set(message.request, message); + const target = InFlightDebuggerRequests.get(message.request); + DebuggerRequestResults.set(JSON.stringify([message.request, target]), message); InFlightDebuggerRequests.delete(message.request); notifyDebuggerRequestListeners(); }