diff --git a/src/stats/stats-provider.ts b/src/stats/stats-provider.ts index 7574f570..48c53f80 100644 --- a/src/stats/stats-provider.ts +++ b/src/stats/stats-provider.ts @@ -84,11 +84,25 @@ export class StatsProvider { const childStringValues: string[][] = []; for (const child of stat.children ?? []) { - if (!(child.values && typeof child.values[0] === 'string' && child.values[0].length > 0)) { + if (!child.values || child.values.length === 0) { continue; } - childStringValues.push([child.name, child.values[0]]); + // Handle different value types + if (typeof child.values[0] === 'string' && child.values[0].length > 0) { + // Original behavior: string values + childStringValues.push([child.name, child.values[0]]); + } else if (typeof child.values[0] === 'number') { + // New behavior: numeric values + if (child.values.length === 1) { + // Single numeric value + childStringValues.push([child.name, child.values[0].toString()]); + } else if (child.values.length >= 2) { + // Multiple numeric values - create a row with all values + const valueStrings = child.values.map(v => v.toString()); + childStringValues.push([child.name, ...valueStrings]); + } + } } const childStatData: StatData = { diff --git a/webview-ui/src/diagnostics_panel/App.css b/webview-ui/src/diagnostics_panel/App.css index 69fe245d..c36e182b 100644 --- a/webview-ui/src/diagnostics_panel/App.css +++ b/webview-ui/src/diagnostics_panel/App.css @@ -156,3 +156,56 @@ main { background-color: var(--vscode-button-background); color: var(--vscode-button-foreground); } + +/* Multi-column table with enhanced column interaction */ +#multi-column-grid { + width: 100%; + overflow-x: auto; + table-layout: fixed; +} + +/* Enhanced column styling for better visual feedback */ +#multi-column-grid vscode-data-grid-cell[cell-type="columnheader"] { + font-weight: bold; + background-color: var(--vscode-editor-background); + border-bottom: 2px solid var(--vscode-editorGroup-border); + border-right: 1px solid var(--vscode-editorGroup-border); + position: relative; + cursor: default; + user-select: none; + padding: 8px 12px; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Add visual resize handle to column headers */ +#multi-column-grid vscode-data-grid-cell[cell-type="columnheader"]::after { + content: ''; + position: absolute; + top: 0; + right: 0; + width: 4px; + height: 100%; + background: transparent; + cursor: col-resize; + z-index: 1; +} + +#multi-column-grid vscode-data-grid-cell[cell-type="columnheader"]:hover::after { + background: var(--vscode-focusBorder); + opacity: 0.5; +} + +/* Regular cell styling */ +#multi-column-grid vscode-data-grid-cell:not([cell-type="columnheader"]) { + padding: 8px 12px; + border-right: 1px solid var(--vscode-editorGroup-border); + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; +} + +/* Default styling for value columns when no columnWidths prop is provided */ +#multi-column-grid vscode-data-grid-cell:not([grid-column="1"]) { + text-align: right; +} diff --git a/webview-ui/src/diagnostics_panel/StatisticProvider.tsx b/webview-ui/src/diagnostics_panel/StatisticProvider.tsx index 07bca1ce..6443ba8b 100644 --- a/webview-ui/src/diagnostics_panel/StatisticProvider.tsx +++ b/webview-ui/src/diagnostics_panel/StatisticProvider.tsx @@ -118,7 +118,10 @@ export class MultipleStatisticProvider extends StatisticProvider { } if (event.values.length === 0) { - return; + // Allow events through if they have children_string_values even if values is empty + if (!event.children_string_values || event.children_string_values.length === 0) { + return; + } } if (this.options.valuesFilter && !this.options.valuesFilter(event)) { diff --git a/webview-ui/src/diagnostics_panel/controls/MinecraftMultiColumnStatisticTable.tsx b/webview-ui/src/diagnostics_panel/controls/MinecraftMultiColumnStatisticTable.tsx new file mode 100644 index 00000000..c4b6bf6e --- /dev/null +++ b/webview-ui/src/diagnostics_panel/controls/MinecraftMultiColumnStatisticTable.tsx @@ -0,0 +1,404 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. + +import { useCallback, useEffect, useState, useMemo } from 'react'; +import { MultipleStatisticProvider, StatisticUpdatedMessage } from '../StatisticProvider'; +import { StatisticResolver } from '../StatisticResolver'; +import { + VSCodeDataGrid, + VSCodeDataGridRow, + VSCodeDataGridCell, + VSCodeDropdown, + VSCodeOption, +} from '@vscode/webview-ui-toolkit/react'; + +export enum MinecraftMultiColumnStatisticTableSortOrder { + Ascending, + Descending, +} + +export enum MinecraftMultiColumnStatisticTableSortType { + Alphabetical, + Numerical, +} + +export enum MinecraftMultiColumnStatisticTableSortColumn { + Key = 'key', + // Value columns will be added dynamically based on valueLabels +} + +type MultiColumnTrackedStat = { + category: string; + values: (string | number)[]; + time: number; +}; + +type MinecraftMultiColumnStatisticTableProps = { + title: string; + statisticDataProvider: MultipleStatisticProvider; + statisticResolver: StatisticResolver; + defaultSortOrder?: MinecraftMultiColumnStatisticTableSortOrder; + defaultSortType?: MinecraftMultiColumnStatisticTableSortType; + defaultSortColumn?: string; + + keyLabel: string; + valueLabels: string[]; // Array of labels for value columns + prettifyNames?: boolean; // Whether to format packet names (camelCase -> Camel Case) or keep original format + columnWidths?: string[]; // Optional array of column widths (first is key column, rest are value columns) +}; + +const sortOrderOptions = [ + { id: MinecraftMultiColumnStatisticTableSortOrder.Ascending, label: 'Ascending' }, + { id: MinecraftMultiColumnStatisticTableSortOrder.Descending, label: 'Descending' }, +]; + +const sortTypeOptions = [ + { id: MinecraftMultiColumnStatisticTableSortType.Alphabetical, label: 'Alphabetical' }, + { id: MinecraftMultiColumnStatisticTableSortType.Numerical, label: 'Numerical' }, +]; + +// Helper function to process children_string_values and populate categoryMap +function processChildrenStringValues( + children_string_values: string[][], + categoryMap: Map, + valueLabels: string[], + prettifyNames: boolean, + eventTime: number +): void { + children_string_values.forEach(childRow => { + if (childRow.length >= 2) { + // Format: [packet_name, value1, value2, value3, value4, value5, ...] + const packetName = childRow[0]; + + // Parse values from the row, preserving original types + const values: (string | number)[] = []; + for (let i = 1; i < childRow.length; i++) { + const rawValue = childRow[i]; + // Try to parse as number, but keep as string if it's not numeric + const numValue = parseFloat(rawValue); + values.push(isNaN(numValue) ? rawValue : numValue); + } + + // Ensure we have at least as many values as valueLabels expects + while (values.length < valueLabels.length) { + values.push(''); + } + + // Process packet name based on prettifyNames setting + const cleanPacketName = prettifyNames + ? packetName + .split('::') + .pop() + ?.replace(/([a-z])([A-Z])/g, '$1 $2') // Add spaces before capital letters + ?.replace(/^./, (str: string) => str.toUpperCase()) || // Capitalize first letter + packetName + : packetName.split('::').pop() || packetName; + + categoryMap.set(cleanPacketName, { + category: cleanPacketName, + values: values, + time: eventTime, + }); + } + }); +} + +export default function MinecraftMultiColumnStatisticTable({ + title, + statisticDataProvider, + statisticResolver, + defaultSortOrder = MinecraftMultiColumnStatisticTableSortOrder.Descending, + defaultSortType = MinecraftMultiColumnStatisticTableSortType.Numerical, + defaultSortColumn, + keyLabel, + valueLabels, + prettifyNames = true, // Default to prettifying names for backward compatibility + columnWidths, +}: MinecraftMultiColumnStatisticTableProps): JSX.Element { + // states + const [data, setData] = useState([]); + const [selectedSortOrder, setSelectedSortOrder] = + useState(defaultSortOrder); + const [selectedSortType, setSelectedSortType] = + useState(defaultSortType); + const [selectedSortColumn, setSelectedSortColumn] = useState( + defaultSortColumn || MinecraftMultiColumnStatisticTableSortColumn.Key + ); + + // Memoize sort column options to prevent unnecessary recreations + const sortColumnOptions = useMemo( + () => [ + { id: MinecraftMultiColumnStatisticTableSortColumn.Key, label: keyLabel }, + ...valueLabels.map((label, index) => ({ id: `value_${index}`, label })), + ], + [keyLabel, valueLabels] + ); + + const _onSelectedSortOrderChange = useCallback((e: Event | React.FormEvent): void => { + const target = e.target as HTMLSelectElement; + setSelectedSortOrder(sortOrderOptions[target.selectedIndex].id); + }, []); + + const _onSelectedSortTypeChange = useCallback((e: Event | React.FormEvent): void => { + const target = e.target as HTMLSelectElement; + setSelectedSortType(sortTypeOptions[target.selectedIndex].id); + }, []); + + const _onSelectedSortColumnChange = useCallback( + (e: Event | React.FormEvent): void => { + const target = e.target as HTMLSelectElement; + setSelectedSortColumn(sortColumnOptions[target.selectedIndex].id); + }, + [sortColumnOptions] + ); + + useEffect(() => { + const eventHandler = (event: StatisticUpdatedMessage): void => { + // Update data with new data point + setData((_prevState: MultiColumnTrackedStat[]): MultiColumnTrackedStat[] => { + // Group stats by category and collect values + const categoryMap = new Map(); + + // For consolidated_data events with children_string_values, skip the statisticResolver + // and process the data directly since it's already in the correct format + if ( + event.id === 'consolidated_data' && + event.children_string_values && + event.children_string_values.length > 0 + ) { + processChildrenStringValues( + event.children_string_values, + categoryMap, + valueLabels, + prettifyNames, + event.time || Date.now() + ); + } else { + // Use the statisticResolver for other event types + const rawStats = statisticResolver(event, []); + + rawStats.forEach(stat => { + if (!stat.category) { + return; + } + + const existing = categoryMap.get(stat.category); + if (existing) { + // Add value to existing category (ensuring we have enough slots) + while (existing.values.length < valueLabels.length) { + existing.values.push(0); + } + existing.values[existing.values.length - 1] = stat.absoluteValue; + existing.time = Math.max(existing.time, stat.time); + } else { + // Create new entry + const newStat: MultiColumnTrackedStat = { + category: stat.category, + values: [stat.absoluteValue], + time: stat.time, + }; + // Pad values array to match number of columns + while (newStat.values.length < valueLabels.length) { + newStat.values.push(0); + } + categoryMap.set(stat.category, newStat); + } + }); + + // Handle multi-value events (children_string_values) for non-consolidated events + if (event.children_string_values && event.children_string_values.length > 0) { + processChildrenStringValues( + event.children_string_values, + categoryMap, + valueLabels, + prettifyNames, + event.time || Date.now() + ); + } + } + + let newData = Array.from(categoryMap.values()); + + // Calculate the latest tick for each category + const latestTicks = new Map(); + newData.forEach(dataPoint => { + const currentTick = latestTicks.get(dataPoint.category) ?? 0; + if (dataPoint.time > currentTick) { + latestTicks.set(dataPoint.category, dataPoint.time); + } + }); + + // Filter out data points that are older than the latest tick + newData = newData.filter(dataPoint => { + const latestTick = latestTicks.get(dataPoint.category); + return latestTick !== undefined && dataPoint.time === latestTick; + }); + + // Sort based on sortOrder, sortType, and sortColumn + newData.sort((a, b) => { + let compareValue: number; + + if (selectedSortColumn === MinecraftMultiColumnStatisticTableSortColumn.Key) { + // Sort by category name + if (selectedSortType === MinecraftMultiColumnStatisticTableSortType.Alphabetical) { + compareValue = a.category.localeCompare(b.category); + } else { + // For numerical sort on key, still use alphabetical + compareValue = a.category.localeCompare(b.category); + } + } else { + // Sort by specific value column + const columnIndex = parseInt(selectedSortColumn.replace('value_', '')); + if (columnIndex >= 0 && columnIndex < valueLabels.length) { + const aValue = a.values[columnIndex] ?? ''; + const bValue = b.values[columnIndex] ?? ''; + + if (selectedSortType === MinecraftMultiColumnStatisticTableSortType.Alphabetical) { + compareValue = String(aValue).localeCompare(String(bValue)); + } else { + // For numerical sort, convert to numbers if possible + const aNum = typeof aValue === 'number' ? aValue : parseFloat(String(aValue)); + const bNum = typeof bValue === 'number' ? bValue : parseFloat(String(bValue)); + + if (isNaN(aNum) && isNaN(bNum)) { + compareValue = String(aValue).localeCompare(String(bValue)); + } else if (isNaN(aNum)) { + compareValue = 1; + } else if (isNaN(bNum)) { + compareValue = -1; + } else { + compareValue = aNum - bNum; + } + } + } else { + compareValue = a.category.localeCompare(b.category); + } + } + + return selectedSortOrder === MinecraftMultiColumnStatisticTableSortOrder.Ascending + ? compareValue + : -compareValue; + }); + + return newData; + }); + }; + + statisticDataProvider.registerWindowListener(window); + statisticDataProvider.addSubscriber(eventHandler); + + // Remove old listener + return () => { + statisticDataProvider.removeSubscriber(eventHandler); + statisticDataProvider.unregisterWindowListener(window); + }; + }, [ + statisticDataProvider, + statisticResolver, + selectedSortOrder, + selectedSortType, + selectedSortColumn, + valueLabels, + prettifyNames, + ]); + + return ( +
+

{title}

+
+
+ + elem.id === selectedSortOrder)} + > + {sortOrderOptions.map(sortOption => ( + {sortOption.label} + ))} + +
+
+
+ + elem.id === selectedSortType)} + > + {sortTypeOptions.map(sortOption => ( + {sortOption.label} + ))} + +
+
+
+ + elem.id === selectedSortColumn)} + > + {sortColumnOptions.map(sortOption => ( + {sortOption.label} + ))} + +
+
+ + + + {keyLabel} + + {valueLabels.map((label, index) => ( + + {label} + + ))} + + {data.map(dataPoint => ( + + + {dataPoint.category} + + {dataPoint.values.map((value, index) => ( + + {typeof value === 'number' ? value.toFixed(1) : value} + + ))} + + ))} + +
+ ); +} diff --git a/webview-ui/src/diagnostics_panel/prefabs/index.tsx b/webview-ui/src/diagnostics_panel/prefabs/index.tsx index 4c88ce0d..5ca19bd9 100644 --- a/webview-ui/src/diagnostics_panel/prefabs/index.tsx +++ b/webview-ui/src/diagnostics_panel/prefabs/index.tsx @@ -12,6 +12,8 @@ import serverScriptSubscriberCountsTab from './tabs/ServerScriptSubscriberCounts import clientTimingTab from './tabs/ClientTiming'; import clientMemoryTab from './tabs/ClientMemory'; +import editorNetworkStatsTab from './tabs/EditorNetworkStats'; + export default [ worldTab, serverMemoryTab, @@ -23,4 +25,5 @@ export default [ clientTimingTab, clientMemoryTab, dynamicPropertyTab, + editorNetworkStatsTab, ]; diff --git a/webview-ui/src/diagnostics_panel/prefabs/tabs/EditorNetworkStats.tsx b/webview-ui/src/diagnostics_panel/prefabs/tabs/EditorNetworkStats.tsx new file mode 100644 index 00000000..e5ba3b7a --- /dev/null +++ b/webview-ui/src/diagnostics_panel/prefabs/tabs/EditorNetworkStats.tsx @@ -0,0 +1,36 @@ +import { MultipleStatisticProvider } from '../../StatisticProvider'; +import { TabPrefab, TabPrefabDataSource } from '../TabPrefab'; +import MinecraftMultiColumnStatisticTable from '../../controls/MinecraftMultiColumnStatisticTable'; +import { createStatResolver, StatisticType, YAxisType } from '../../StatisticResolver'; + +const statsTab: TabPrefab = { + name: 'Editor Network Stats', + dataSource: TabPrefabDataSource.Server, + content: () => { + return ( +
+ +
+ ); + }, +}; + +export default statsTab;