From 1e4ae7c6b467bce3fb3f45f0f6c10bc12cbbb67c Mon Sep 17 00:00:00 2001 From: Khelan <141972056+khelanmodi@users.noreply.github.com> Date: Thu, 18 Jun 2026 19:17:32 -0700 Subject: [PATCH 1/2] feat(clusterView): add cluster dashboard / home page webview Adds a cluster overview dashboard that opens from the cluster tree node instead of the empty editor. Lists databases with storage size, collection count and index count, drills into a database to list collections with storage size, document count, average document size, index count and total index size. Live search, sortable columns, and create-database / create-collection flows that reuse the existing native AzureWizard commands (no bespoke webview dialog) so the tree and dashboard stay in sync. Built on the existing registry-driven React webview + tRPC router pattern (Fluent UI v9, VS Code theme tokens); metrics stream in per row with bounded concurrency and degrade to a placeholder when stats calls are denied or unsupported. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- l10n/bundle.l10n.json | 22 ++ package.json | 30 ++ .../openClusterView/openClusterView.ts | 62 ++++ src/documentdb/ClustersClient.ts | 54 +++ src/documentdb/ClustersExtension.ts | 29 ++ src/tree/documentdb/ClusterItemBase.ts | 16 + src/webviews/_integration/WebviewRegistry.ts | 2 + src/webviews/_integration/appRouter.ts | 2 + .../documentdb/clusterView/ClusterView.tsx | 44 +++ .../documentdb/clusterView/clusterView.scss | 118 +++++++ .../clusterView/clusterViewController.ts | 65 ++++ .../clusterView/clusterViewRouter.ts | 315 ++++++++++++++++++ .../clusterView/components/Breadcrumb.tsx | 56 ++++ .../clusterView/components/CollectionList.tsx | 271 +++++++++++++++ .../clusterView/components/DashboardTable.tsx | 186 +++++++++++ .../clusterView/components/DatabaseList.tsx | 245 ++++++++++++++ .../documentdb/clusterView/constants.ts | 31 ++ .../clusterView/hooks/useBoundedMetrics.ts | 114 +++++++ src/webviews/documentdb/clusterView/types.ts | 90 +++++ .../clusterView/utils/format.test.ts | 64 ++++ .../documentdb/clusterView/utils/format.ts | 58 ++++ .../documentdb/clusterView/utils/sort.test.ts | 78 +++++ .../documentdb/clusterView/utils/sort.ts | 59 ++++ 23 files changed, 2011 insertions(+) create mode 100644 src/commands/openClusterView/openClusterView.ts create mode 100644 src/webviews/documentdb/clusterView/ClusterView.tsx create mode 100644 src/webviews/documentdb/clusterView/clusterView.scss create mode 100644 src/webviews/documentdb/clusterView/clusterViewController.ts create mode 100644 src/webviews/documentdb/clusterView/clusterViewRouter.ts create mode 100644 src/webviews/documentdb/clusterView/components/Breadcrumb.tsx create mode 100644 src/webviews/documentdb/clusterView/components/CollectionList.tsx create mode 100644 src/webviews/documentdb/clusterView/components/DashboardTable.tsx create mode 100644 src/webviews/documentdb/clusterView/components/DatabaseList.tsx create mode 100644 src/webviews/documentdb/clusterView/constants.ts create mode 100644 src/webviews/documentdb/clusterView/hooks/useBoundedMetrics.ts create mode 100644 src/webviews/documentdb/clusterView/types.ts create mode 100644 src/webviews/documentdb/clusterView/utils/format.test.ts create mode 100644 src/webviews/documentdb/clusterView/utils/format.ts create mode 100644 src/webviews/documentdb/clusterView/utils/sort.test.ts create mode 100644 src/webviews/documentdb/clusterView/utils/sort.ts diff --git a/l10n/bundle.l10n.json b/l10n/bundle.l10n.json index 3af958ae8..f4a64fb71 100644 --- a/l10n/bundle.l10n.json +++ b/l10n/bundle.l10n.json @@ -3,6 +3,7 @@ " and ": " and ", " on GitHub.": " on GitHub.", " or ": " or ", + "—": "—", ", No public IP or FQDN found.": ", No public IP or FQDN found.", "! Task '{taskName}' failed. {message}": "! Task '{taskName}' failed. {message}", ".limit({0}) exceeds the display batch size ({1}), so only {1} documents will be shown. Use .toArray() to retrieve all {0}, or increase \"{2}\" in Settings.": ".limit({0}) exceeds the display batch size ({1}), so only {1} documents will be shown. Use .toArray() to retrieve all {0}, or increase \"{2}\" in Settings.", @@ -219,6 +220,7 @@ "Authentication is required to run this action.": "Authentication is required to run this action.", "Authentication is required to use this migration provider.": "Authentication is required to use this migration provider.", "Authentication: {0} | Database: {1}": "Authentication: {0} | Database: {1}", + "Avg. document size": "Avg. document size", "Azure account added successfully.": "Azure account added successfully.", "Azure account management failed: {0}": "Azure account management failed: {0}", "Azure account management was cancelled by user.": "Azure account management was cancelled by user.", @@ -275,7 +277,9 @@ "Clipboard is empty.": "Clipboard is empty.", "Close tips": "Close tips", "Cluster metadata not initialized. Client may not be properly connected.": "Cluster metadata not initialized. Client may not be properly connected.", + "Cluster navigation": "Cluster navigation", "Cluster support unknown $(info)": "Cluster support unknown $(info)", + "Collection": "Collection", "collection \"{0}\"": "collection \"{0}\"", "Collection \"{0}\" from database \"{1}\" has been marked for copy. You can now paste this collection into any database or existing collection using the \"Paste Collection...\" option in the context menu.": "Collection \"{0}\" from database \"{1}\" has been marked for copy. You can now paste this collection into any database or existing collection using the \"Paste Collection...\" option in the context menu.", "Collection name cannot begin with the system. prefix (Reserved for internal use).": "Collection name cannot begin with the system. prefix (Reserved for internal use).", @@ -379,6 +383,7 @@ "Database name is required when collection is specified": "Database name is required when collection is specified", "Database name is required.": "Database name is required.", "Database: \"{databaseName}\"": "Database: \"{databaseName}\"", + "Databases": "Databases", "Delete": "Delete", "Delete \"{connectionName}\"?": "Delete \"{connectionName}\"?", "Delete \"{nodeName}\"?": "Delete \"{nodeName}\"?", @@ -545,7 +550,9 @@ "Failed to initialize Azure management clients": "Failed to initialize Azure management clients", "Failed to initialize task": "Failed to initialize task", "Failed to load {0}": "Failed to load {0}", + "Failed to load collections: {0}": "Failed to load collections: {0}", "Failed to load custom prompt template from {path}: {error}. Using built-in template.": "Failed to load custom prompt template from {path}: {error}. Using built-in template.", + "Failed to load databases: {0}": "Failed to load databases: {0}", "Failed to load selected items from storage.": "Failed to load selected items from storage.", "Failed to load template file for {type}: {error}": "Failed to load template file for {type}: {error}", "Failed to modify index: {error}": "Failed to modify index: {error}", @@ -586,6 +593,10 @@ "Fair": "Fair", "Fast execution": "Fast execution", "Fetch Overhead": "Fetch Overhead", + "Filter collections": "Filter collections", + "Filter collections by name": "Filter collections by name", + "Filter databases": "Filter databases", + "Filter databases by name": "Filter databases by name", "Filter: Enter the DocumentDB query filter": "Filter: Enter the DocumentDB query filter", "Find Query": "Find Query", "Finished importing": "Finished importing", @@ -734,7 +745,9 @@ "Loading Azure Accounts Used for Service Discovery…": "Loading Azure Accounts Used for Service Discovery…", "Loading cluster details for \"{cluster}\"": "Loading cluster details for \"{cluster}\"", "Loading Clusters…": "Loading Clusters…", + "Loading collections…": "Loading collections…", "Loading Content": "Loading Content", + "Loading databases…": "Loading databases…", "Loading document {num} of {countUri}": "Loading document {num} of {countUri}", "Loading documents…": "Loading documents…", "Loading resources...": "Loading resources...", @@ -796,6 +809,7 @@ "No code to run. Place the cursor in a code block.": "No code to run. Place the cursor in a code block.", "No collection has been marked for copy. Please use \"Copy Collection...\" first to select a source collection.": "No collection has been marked for copy. Please use \"Copy Collection...\" first to select a source collection.", "No collection selected.": "No collection selected.", + "No collections match your filter.": "No collections match your filter.", "No connections selected to remove.": "No connections selected to remove.", "No Connectivity": "No Connectivity", "No credentials found for cluster \"{0}\"": "No credentials found for cluster \"{0}\"", @@ -803,6 +817,7 @@ "No credentials found for id {clusterId}": "No credentials found for id {clusterId}", "No credentials found for the selected cluster.": "No credentials found for the selected cluster.", "No database connected": "No database connected", + "No databases match your filter.": "No databases match your filter.", "No folder selected.": "No folder selected.", "No index changes needed at this time.": "No index changes needed at this time.", "No index selected.": "No index selected.", @@ -840,6 +855,7 @@ "OK": "OK", "Open \"{0}.{1}\" in Query Playground": "Open \"{0}.{1}\" in Query Playground", "Open batch size setting": "Open batch size setting", + "Open Cluster Overview": "Open Cluster Overview", "Open Collection": "Open Collection", "Open collection \"{0}.{1}\" in Collection View": "Open collection \"{0}.{1}\" in Collection View", "Open current query in a Query Playground": "Open current query in a Query Playground", @@ -1057,6 +1073,7 @@ "Starts with mongodb:// or mongodb+srv://": "Starts with mongodb:// or mongodb+srv://", "Stopping {0}": "Stopping {0}", "Stopping task...": "Stopping task...", + "Storage size": "Storage size", "Submit": "Submit", "Submit Feedback": "Submit Feedback", "Submitting...": "Submitting...", @@ -1163,6 +1180,8 @@ "The worker for this cluster is busy executing a playground": "The worker for this cluster is busy executing a playground", "These signals help us improve, but more context in a discussion, issue report, or a direct message adds even more value. ": "These signals help us improve, but more context in a discussion, issue report, or a direct message adds even more value. ", "This cannot be undone.": "This cannot be undone.", + "This cluster has no databases yet.": "This cluster has no databases yet.", + "This database has no collections yet.": "This database has no collections yet.", "This field is not set": "This field is not set", "This functionality requires installing the Azure Account extension.": "This functionality requires installing the Azure Account extension.", "This functionality requires updating the Azure Account extension to at least version \"{0}\".": "This functionality requires updating the Azure Account extension to at least version \"{0}\".", @@ -1191,6 +1210,7 @@ "To connect to Azure resources, you need to sign in to Azure accounts.": "To connect to Azure resources, you need to sign in to Azure accounts.", "TODO: Share the steps needed to reliably reproduce the problem. Please include actual and expected results.": "TODO: Share the steps needed to reliably reproduce the problem. Please include actual and expected results.", "Total documents to import: {0}": "Total documents to import: {0}", + "Total index size": "Total index size", "Total time taken to execute the query on the server": "Total time taken to execute the query on the server", "Transforming Stage 2 response to UI format": "Transforming Stage 2 response to UI format", "Tree View": "Tree View", @@ -1284,6 +1304,8 @@ "Yes, save my credentials": "Yes, save my credentials", "You are already signed in to tenant \"{0}\"": "You are already signed in to tenant \"{0}\"", "You are not signed in to an Azure account. Please sign in.": "You are not signed in to an Azure account. Please sign in.", + "You are not signed in to the cluster \"{0}\". Expand it in the tree to sign in, then try again.": "You are not signed in to the cluster \"{0}\". Expand it in the tree to sign in, then try again.", + "You are not signed in to the cluster. Expand it in the tree to sign in, then try again.": "You are not signed in to the cluster. Expand it in the tree to sign in, then try again.", "You are not signed in to the MongoDB Cluster. Please sign in (by expanding the node \"{0}\") and try again.": "You are not signed in to the MongoDB Cluster. Please sign in (by expanding the node \"{0}\") and try again.", "You can disable this confirmation by setting \"{0}\" to false.": "You can disable this confirmation by setting \"{0}\" to false.", "You can increase the timeout in Settings:": "You can increase the timeout in Settings:", diff --git a/package.json b/package.json index 2f61764cb..f0a7149a6 100644 --- a/package.json +++ b/package.json @@ -574,6 +574,20 @@ "title": "Open Collection", "icon": "$(files)" }, + { + "//": "Open Cluster Dashboard / Home Page", + "category": "DocumentDB", + "command": "vscode-documentdb.command.clusterView.open", + "title": "Open Cluster Overview", + "icon": "$(dashboard)" + }, + { + "//": "Open Cluster Dashboard / Home Page (inline button)", + "category": "DocumentDB", + "command": "vscode-documentdb.command.clusterView.open.inline", + "title": "Open Cluster Overview", + "icon": "$(dashboard)" + }, { "//": "Copy Collection", "category": "DocumentDB", @@ -794,6 +808,18 @@ "when": "view =~ /azureResourceGroups|azureFocusView/ && viewItem =~ /\\btreeitem_documentdbcluster\\b/i", "group": "inline" }, + { + "//": "[Cluster] Open Cluster Overview (inline)", + "command": "vscode-documentdb.command.clusterView.open.inline", + "when": "view =~ /connectionsView|discoveryView|azure(ResourceGroups|FocusView)/ && viewItem =~ /\\btreeitem_documentdbcluster\\b/i", + "group": "inline@0" + }, + { + "//": "[Cluster] Open Cluster Overview", + "command": "vscode-documentdb.command.clusterView.open", + "when": "view =~ /connectionsView|discoveryView|azure(ResourceGroups|FocusView)/ && viewItem =~ /\\btreeitem_documentdbcluster\\b/i", + "group": "1@0" + }, { "//": "Create database", "command": "vscode-documentdb.command.createDatabase", @@ -1105,6 +1131,10 @@ "command": "vscode-documentdb.command.containerView.open.inline", "when": "never" }, + { + "command": "vscode-documentdb.command.clusterView.open.inline", + "when": "never" + }, { "command": "vscode-documentdb.command.playground.new.inline", "when": "never" diff --git a/src/commands/openClusterView/openClusterView.ts b/src/commands/openClusterView/openClusterView.ts new file mode 100644 index 000000000..005092752 --- /dev/null +++ b/src/commands/openClusterView/openClusterView.ts @@ -0,0 +1,62 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type IActionContext } from '@microsoft/vscode-azext-utils'; +import * as l10n from '@vscode/l10n'; + +import { inferViewIdFromTreeId } from '../../documentdb/Views'; +import { type ClusterItemBase } from '../../tree/documentdb/ClusterItemBase'; +import { trackJourneyCorrelationId } from '../../utils/commandTelemetry'; +import { ClusterViewController } from '../../webviews/documentdb/clusterView/clusterViewController'; + +/** + * Opens the cluster dashboard / home page from a cluster tree node (context + * menu or inline action). Resolves the coordinates the webview needs and + * delegates to {@link openClusterViewInternal}. + */ +export async function openClusterView(context: IActionContext, node: ClusterItemBase): Promise { + // Added manually here as this function can be called bypassing our general command registration. + trackJourneyCorrelationId(context, node); + + if (!node) { + throw new Error(l10n.t('No node selected.')); + } + + context.telemetry.properties.experience = node.experience?.api; + + // Extract viewId from the cluster model, or infer it from the treeId prefix. + const viewId = node.cluster.viewId ?? inferViewIdFromTreeId(node.cluster.treeId); + + return openClusterViewInternal(context, { + clusterId: node.cluster.clusterId, + clusterDisplayName: node.cluster.name, + viewId: viewId, + clusterTreeId: node.cluster.treeId, + }); +} + +/** + * Opens the cluster dashboard from explicit coordinates. Used both by the + * tree-node command above and by the double-click handler (which passes the + * props object directly). + */ +export async function openClusterViewInternal( + _context: IActionContext, + props: { + clusterId: string; + clusterDisplayName: string; + viewId: string; + clusterTreeId: string; + }, +): Promise { + const view = new ClusterViewController({ + clusterId: props.clusterId, + clusterDisplayName: props.clusterDisplayName, + viewId: props.viewId, + clusterTreeId: props.clusterTreeId, + }); + + view.revealToForeground(); +} diff --git a/src/documentdb/ClustersClient.ts b/src/documentdb/ClustersClient.ts index 5a7d83e85..f27f281c8 100644 --- a/src/documentdb/ClustersClient.ts +++ b/src/documentdb/ClustersClient.ts @@ -74,6 +74,32 @@ export interface CollectionItemModel { shardKey?: Record; } +/** + * Database-wide statistics, as returned by the `dbStats` command. Used by the + * cluster dashboard to show per-database collection count, index count, and + * total size. All numeric fields default to 0 when the server omits them. + * + * @see https://www.mongodb.com/docs/manual/reference/command/dbStats/ + */ +export interface DatabaseStats { + /** Number of collections in the database. */ + collections: number; + /** Total number of indexes across all collections in the database. */ + indexes: number; + /** Total size in bytes of the uncompressed data held in the database. */ + dataSize: number; + /** Total size in bytes of all indexes in the database. */ + indexSize: number; + /** Total size in bytes of all documents plus the storage padding. */ + storageSize: number; + /** + * Total size in bytes of the database (data + indexes). Mirrors the + * `totalSize` field of `dbStats`; falls back to `dataSize + indexSize` + * when the server does not report it. + */ + totalSize: number; +} + /** * Find query parameters for MongoDB find operations. * Each field accepts a JSON string representation of the MongoDB query syntax. @@ -666,6 +692,34 @@ export class ClustersClient { return this._databasesCache ?? undefined; } + /** + * Returns database-wide statistics via the `dbStats` command. Used by the + * cluster dashboard for the per-database collection count, index count, and + * total size columns. + * + * The caller is responsible for handling failures (e.g. tiers that restrict + * `dbStats`); this method does not swallow errors so the dashboard can show a + * per-row "unavailable" state without masking real connection problems. + * + * @param databaseName - Name of the database to inspect. + * @returns The mapped {@link DatabaseStats}. + */ + async getDatabaseStats(databaseName: string): Promise { + const stats = await this._mongoClient.db(databaseName).command({ dbStats: 1 }); + + const dataSize = (stats.dataSize as number) ?? 0; + const indexSize = (stats.indexSize as number) ?? 0; + + return { + collections: (stats.collections as number) ?? 0, + indexes: (stats.indexes as number) ?? 0, + dataSize, + indexSize, + storageSize: (stats.storageSize as number) ?? 0, + totalSize: (stats.totalSize as number) ?? dataSize + indexSize, + }; + } + async listIndexes(databaseName: string, collectionName: string): Promise { const collection = this._mongoClient.db(databaseName).collection(collectionName); const indexes = await collection.indexes(); diff --git a/src/documentdb/ClustersExtension.ts b/src/documentdb/ClustersExtension.ts index a6f9aa7f9..cc6a2b612 100644 --- a/src/documentdb/ClustersExtension.ts +++ b/src/documentdb/ClustersExtension.ts @@ -49,6 +49,7 @@ import { unhideIndex } from '../commands/index.unhideIndex/unhideIndex'; import { learnMoreAboutServiceProvider } from '../commands/learnMoreAboutServiceProvider/learnMoreAboutServiceProvider'; import { newConnection } from '../commands/newConnection/newConnection'; import { newLocalConnection } from '../commands/newLocalConnection/newLocalConnection'; +import { openClusterView, openClusterViewInternal } from '../commands/openClusterView/openClusterView'; import { openCollectionView, openCollectionViewInternal } from '../commands/openCollectionView/openCollectionView'; import { openDocumentView } from '../commands/openDocument/openDocument'; import { @@ -667,6 +668,34 @@ export class ClustersExtension implements vscode.Disposable { }), ); + /** + * Cluster dashboard / home page. Like the collection view, it is + * reachable in three ways that share one underlying handler: + * - a double-click on the cluster node (openFromTree), + * - a context-menu "Cluster Overview" action, + * - an inline action button on the cluster node. + */ + registerCommand( + 'vscode-documentdb.command.internal.clusterView.open', + withCommandCorrelation(openClusterViewInternal), + ); + registerDoubleClickCommand( + 'vscode-documentdb.command.internal.clusterView.openFromTree', + withCommandCorrelation(openClusterViewInternal), + doubleClickDebounceDelay, + ); + registerCommandWithTreeNodeUnwrapping( + 'vscode-documentdb.command.clusterView.open', + withTreeNodeCommandCorrelation(openClusterView), + ); + registerCommandWithTreeNodeUnwrapping( + 'vscode-documentdb.command.clusterView.open.inline', + withTreeNodeCommandCorrelation((context, node) => { + context.telemetry.properties.activationSource = 'treeNodeInline'; + return openClusterView(context, node as ClusterItemBase); + }), + ); + registerCommand( 'vscode-documentdb.command.internal.documentView.open', withCommandCorrelation(openDocumentView), diff --git a/src/tree/documentdb/ClusterItemBase.ts b/src/tree/documentdb/ClusterItemBase.ts index 30824bc97..13a7757d4 100644 --- a/src/tree/documentdb/ClusterItemBase.ts +++ b/src/tree/documentdb/ClusterItemBase.ts @@ -283,6 +283,22 @@ export abstract class ClusterItemBase { + const configuration = useConfiguration(); + const [page, setPage] = useState({ kind: 'overview' }); + + return ( +
+ setPage({ kind: 'overview' })} + /> + {page.kind === 'overview' ? ( + setPage({ kind: 'database', databaseName })} /> + ) : ( + + )} +
+ ); +}; diff --git a/src/webviews/documentdb/clusterView/clusterView.scss b/src/webviews/documentdb/clusterView/clusterView.scss new file mode 100644 index 000000000..7b71312eb --- /dev/null +++ b/src/webviews/documentdb/clusterView/clusterView.scss @@ -0,0 +1,118 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +@use '../../index'; + +.clusterViewRoot { + @extend .selectionDisabled; + + display: flex; + flex-direction: column; + height: 100vh; + row-gap: 10px; + padding-top: 10px; + + .dashboardBreadcrumb { + padding: 0 10px; + } + + .dashboardPage { + display: flex; + flex-direction: column; + flex-grow: 1; + flex-shrink: 1; + min-height: 0; + row-gap: 10px; + } + + .dashboardToolbar { + display: flex; + flex-direction: column; + align-items: stretch; + row-gap: 10px; + padding: 0 10px; + } + + .dashboardSearch { + flex: 0 0 auto; + max-width: 320px; + } + + .dashboardToolbarActions { + display: flex; + flex-direction: row; + align-items: center; + gap: 4px; + } + + .dashboardCentered { + display: flex; + flex-grow: 1; + align-items: center; + justify-content: center; + padding: 20px; + } + + .dashboardEmpty { + color: var(--colorNeutralForeground3); + } + + .dashboardTableContainer, + .dashboardTable { + flex-grow: 1; + flex-shrink: 1; + min-height: 0; + } + + .dashboardTable { + overflow: auto; + padding: 0 10px; + + td, + th { + vertical-align: middle; + } + + /* + * Alternating row backgrounds — mirrors the VS Code tree-view + * convention also used by the Results and Index tabs. Parity is + * computed in the component (rowEven / rowOdd) from the row index. + */ + .rowEven > td { + background-color: var(--colorNeutralBackground1); + } + .rowOdd > td { + background-color: var(--colorNeutralBackground2); + } + + /* Database rows drill into a collection list, so cue interactivity. */ + .interactiveRow { + cursor: pointer; + } + .interactiveRow:hover > td { + background-color: var(--colorNeutralBackground1Hover); + } + + /* Column alignment helpers shared by header and body cells. */ + .cell-start { + justify-content: flex-start; + text-align: left; + } + .cell-center { + justify-content: center; + text-align: center; + } + .cell-end { + justify-content: flex-end; + text-align: right; + } + } +} + +.createDialogBody { + display: flex; + flex-direction: column; + row-gap: 10px; +} diff --git a/src/webviews/documentdb/clusterView/clusterViewController.ts b/src/webviews/documentdb/clusterView/clusterViewController.ts new file mode 100644 index 000000000..1f1ac682c --- /dev/null +++ b/src/webviews/documentdb/clusterView/clusterViewController.ts @@ -0,0 +1,65 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; + +import { API } from '../../../DocumentDBExperiences'; +import { ext } from '../../../extensionVariables'; +import { WebviewControllerBase } from '../../_integration/WebviewControllerBase'; +import { type RouterContext } from './clusterViewRouter'; + +/** + * Initial configuration passed to the cluster dashboard webview. Read in the + * React layer via `useConfiguration()`. + */ +export type ClusterViewWebviewConfigurationType = { + /** Stable cluster identifier used for client/cache lookups. */ + clusterId: string; + /** Human-readable cluster name for the dashboard header. */ + clusterDisplayName: string; + /** + * Identifies which tree view this cluster belongs to. + * @see Views enum + */ + viewId: string; + /** The cluster's VS Code TreeView element id (used to refresh the tree). */ + clusterTreeId: string; +}; + +/** + * Webview controller for the cluster dashboard / home page. Hosts the database + * overview and the per-database collection drill-in. + */ +export class ClusterViewController extends WebviewControllerBase { + constructor(initialData: ClusterViewWebviewConfigurationType) { + const title: string = initialData.clusterDisplayName; + + super(ext.context, title, 'clusterView', initialData, vscode.ViewColumn.One, { + light: vscode.Uri.joinPath( + ext.context.extensionUri, + 'resources', + 'icons', + 'vscode-documentdb-icon-light-themes.svg', + ), + dark: vscode.Uri.joinPath( + ext.context.extensionUri, + 'resources', + 'icons', + 'vscode-documentdb-icon-dark-themes.svg', + ), + }); + + const trpcContext: RouterContext = { + dbExperience: API.DocumentDB, + webviewName: 'clusterView', + clusterId: initialData.clusterId, + clusterDisplayName: initialData.clusterDisplayName, + viewId: initialData.viewId, + clusterTreeId: initialData.clusterTreeId, + }; + + this.setupTrpc(trpcContext); + } +} diff --git a/src/webviews/documentdb/clusterView/clusterViewRouter.ts b/src/webviews/documentdb/clusterView/clusterViewRouter.ts new file mode 100644 index 000000000..3f3ed7b2f --- /dev/null +++ b/src/webviews/documentdb/clusterView/clusterViewRouter.ts @@ -0,0 +1,315 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * ============================================================================= + * Cluster Dashboard — tRPC router (BACKEND INTEGRATION SURFACE) + * ============================================================================= + * + * This file is the single seam between the cluster dashboard UI + * (ClusterView and friends, under ./components) and the DocumentDB backend. + * The webview ONLY talks to the backend through these procedures. + * + * Design notes for maintainers: + * ------------------------------------------------------------------ + * • The client is obtained per-call via `ClustersClient.getClient(clusterId)` + * (the same stable-cache pattern used by the Index Management router). + * No ClusterSession is created because the dashboard is read-mostly + * (list + stats) plus two lazy create flows. + * • Metric procedures (`getDatabaseMetrics` / `getCollectionMetrics`) NEVER + * throw for an individual row: a denied/unsupported `dbStats` / `collStats` + * must degrade to a "—" cell, not fail the whole table. They return + * `null` metrics on failure and the UI renders the unavailable state. + * • The cheap list procedures (`listDatabases` / `listCollections`) DO throw + * on failure so the UI can surface an auth/connection error state. + * • Create flows delegate to the shared AzureWizard commands (the same native + * flows used by the tree's right-click "Create database" / "Create + * collection" actions). Those wizards prompt with a native input box, + * validate the name, and refresh the relevant tree node on success — so the + * dashboard and the tree stay in sync without a bespoke webview dialog. + * ============================================================================= + */ + +import { AzureWizard, callWithTelemetryAndErrorHandling, type IActionContext } from '@microsoft/vscode-azext-utils'; +import * as l10n from '@vscode/l10n'; +import { z } from 'zod'; +import { CollectionNameStep } from '../../../commands/createCollection/CollectionNameStep'; +import { type CreateCollectionWizardContext } from '../../../commands/createCollection/CreateCollectionWizardContext'; +import { ExecuteStep as CreateCollectionExecuteStep } from '../../../commands/createCollection/ExecuteStep'; +import { type CreateDatabaseWizardContext } from '../../../commands/createDatabase/CreateDatabaseWizardContext'; +import { DatabaseNameStep } from '../../../commands/createDatabase/DatabaseNameStep'; +import { ExecuteStep as CreateDatabaseExecuteStep } from '../../../commands/createDatabase/ExecuteStep'; +import { ClustersClient } from '../../../documentdb/ClustersClient'; +import { CredentialCache } from '../../../documentdb/CredentialCache'; +import { meterSilentCatch } from '../../../utils/callWithAccumulatingTelemetry'; +import { showConfirmationAsInSettings } from '../../../utils/dialogs/showConfirmation'; +import { nonNullValue } from '../../../utils/nonNull'; +import { type BaseRouterContext } from '../../_integration/appRouter'; +import { publicProcedureWithTelemetry, router, type WithTelemetry } from '../../_integration/trpc'; +import { type CollectionMetrics, type CreateResult, type DatabaseMetrics } from './types'; + +export type RouterContext = BaseRouterContext & { + /** Stable cluster identifier for cache/client lookups. */ + clusterId: string; + /** Human-readable cluster name used for the dashboard header. */ + clusterDisplayName: string; + /** Identifies which tree view this cluster belongs to. */ + viewId: string; + /** + * The cluster's VS Code TreeView element id (`cluster.treeId`). Used to + * refresh the tree after create operations so the dashboard and the tree + * stay in sync. + */ + clusterTreeId: string; +}; + +const DatabaseNameInput = z.object({ databaseName: z.string().min(1) }); +const CollectionNameInput = z.object({ + databaseName: z.string().min(1), + collectionName: z.string().min(1), +}); + +/** + * Runs the shared "Create database" AzureWizard — the exact same native flow + * (input box + name validation + lazy-create + tree refresh) used by the + * cluster node's right-click "Create database" command. Reusing the wizard + * (rather than a webview dialog) keeps a single create code path and a single + * source of validation rules. + * + * Errors are surfaced by `callWithTelemetryAndErrorHandling` as a native error + * notification (its default behaviour); user cancellation is silent. Returns + * whether a database was created so the dashboard can refresh its table. + */ +async function runCreateDatabaseWizard( + clusterId: string, + clusterName: string, + clusterTreeId: string, +): Promise { + if (!CredentialCache.hasCredentials(clusterId)) { + return { + created: false, + error: l10n.t( + 'You are not signed in to the cluster "{0}". Expand it in the tree to sign in, then try again.', + clusterName, + ), + }; + } + + let created = false; + await callWithTelemetryAndErrorHandling( + 'vscode-documentdb.clusterView.createDatabase', + async (actionContext: IActionContext) => { + const wizardContext: CreateDatabaseWizardContext = { + ...actionContext, + credentialsId: clusterId, + clusterName: clusterName, + nodeId: clusterTreeId, + }; + + const wizard = new AzureWizard(wizardContext, { + title: l10n.t('Create database'), + promptSteps: [new DatabaseNameStep()], + executeSteps: [new CreateDatabaseExecuteStep()], + showLoadingPrompt: true, + }); + + await wizard.prompt(); + await wizard.execute(); + + const newName = nonNullValue( + wizardContext.databaseName, + 'wizardContext.databaseName', + 'clusterViewRouter.ts', + ); + showConfirmationAsInSettings(l10n.t('The "{name}" database has been created.', { name: newName })); + created = true; + }, + ); + + return { created }; +} + +/** + * Runs the shared "Create collection" AzureWizard — the same native flow used + * by the database node's right-click "Create collection" command. The owning + * database node is refreshed by the wizard's execute step, keeping the tree in + * sync. Returns whether a collection was created so the dashboard can refresh. + */ +async function runCreateCollectionWizard( + clusterId: string, + databaseName: string, + databaseNodeId: string, +): Promise { + if (!CredentialCache.hasCredentials(clusterId)) { + return { + created: false, + error: l10n.t('You are not signed in to the cluster. Expand it in the tree to sign in, then try again.'), + }; + } + + let created = false; + await callWithTelemetryAndErrorHandling( + 'vscode-documentdb.clusterView.createCollection', + async (actionContext: IActionContext) => { + const wizardContext: CreateCollectionWizardContext = { + ...actionContext, + credentialsId: clusterId, + databaseId: databaseName, + nodeId: databaseNodeId, + }; + + const wizard = new AzureWizard(wizardContext, { + title: l10n.t('Create collection'), + promptSteps: [new CollectionNameStep()], + executeSteps: [new CreateCollectionExecuteStep()], + showLoadingPrompt: true, + }); + + await wizard.prompt(); + await wizard.execute(); + + const newName = nonNullValue( + wizardContext.newCollectionName, + 'wizardContext.newCollectionName', + 'clusterViewRouter.ts', + ); + showConfirmationAsInSettings( + l10n.t('The "{newCollectionName}" collection has been created.', { newCollectionName: newName }), + ); + created = true; + }, + ); + + return { created }; +} + +export const clusterViewRouter = router({ + /** + * BACKEND INTEGRATION POINT — getClusterInfo + * ----------------------------------------------------------------- + * Returns the cluster display name for the dashboard header. Pure + * context read; no backend call. + */ + getClusterInfo: publicProcedureWithTelemetry.query(({ ctx }) => { + const myCtx = ctx as WithTelemetry; + return { clusterDisplayName: myCtx.clusterDisplayName }; + }), + + /** + * BACKEND INTEGRATION POINT — listDatabases + * ----------------------------------------------------------------- + * Cheap list call that powers the immediate render of the overview + * table. Throws on failure so the UI shows a connection/auth error + * state. Returns database names only; metrics stream in separately. + */ + listDatabases: publicProcedureWithTelemetry.query(async ({ ctx }) => { + const myCtx = ctx as WithTelemetry; + const client = await ClustersClient.getClient(myCtx.clusterId); + const databases = await client.listDatabases(); + myCtx.telemetry.measurements.databaseCount = databases.length; + return databases.map((db) => ({ name: db.name })); + }), + + /** + * BACKEND INTEGRATION POINT — getDatabaseMetrics + * ----------------------------------------------------------------- + * Per-row metrics for the overview table via `dbStats`. NEVER throws: + * returns `null` when stats are unavailable/denied so the row degrades + * to a "—" state instead of failing the whole table. `storageSize` → + * on-disk size, `collections` → collection count, `indexes` → index count. + */ + getDatabaseMetrics: publicProcedureWithTelemetry + .input(DatabaseNameInput) + .query(async ({ input, ctx }): Promise => { + const myCtx = ctx as WithTelemetry; + try { + const client = await ClustersClient.getClient(myCtx.clusterId); + const stats = await client.getDatabaseStats(input.databaseName); + return { + storageSize: stats.storageSize, + collectionCount: stats.collections, + indexCount: stats.indexes, + }; + } catch { + meterSilentCatch('clusterView_getDatabaseMetrics'); + return null; + } + }), + + /** + * BACKEND INTEGRATION POINT — listCollections + * ----------------------------------------------------------------- + * Cheap list call for the database drill-in. Throws on failure so the + * UI can surface an error state. Metrics stream in separately. + */ + listCollections: publicProcedureWithTelemetry.input(DatabaseNameInput).query(async ({ input, ctx }) => { + const myCtx = ctx as WithTelemetry; + const client = await ClustersClient.getClient(myCtx.clusterId); + const collections = await client.listCollections(input.databaseName); + myCtx.telemetry.measurements.collectionCount = collections.length; + return collections.map((c) => ({ name: c.name })); + }), + + /** + * BACKEND INTEGRATION POINT — getCollectionMetrics + * ----------------------------------------------------------------- + * Per-row metrics for the drill-in table via `collStats`. NEVER throws: + * returns `null` when stats are unavailable/denied. Maps the raw + * `collStats` fields to the dashboard's collection metric columns: + * `storageSize` → on-disk size, `count` → document count, + * `avgObjSize` → average document size, `nindexes` → index count, + * `totalIndexSize` → total index size. + */ + getCollectionMetrics: publicProcedureWithTelemetry + .input(CollectionNameInput) + .query(async ({ input, ctx }): Promise => { + const myCtx = ctx as WithTelemetry; + try { + const client = await ClustersClient.getClient(myCtx.clusterId); + const stats = await client.getCollectionStats(input.databaseName, input.collectionName); + return { + storageSize: stats.storageSize, + documentCount: stats.count, + avgDocumentSize: stats.avgObjSize, + indexCount: stats.nindexes, + totalIndexSize: stats.totalIndexSize, + }; + } catch { + meterSilentCatch('clusterView_getCollectionMetrics'); + return null; + } + }), + + /** + * BACKEND INTEGRATION POINT — createDatabase + * ----------------------------------------------------------------- + * Delegates to the shared "Create database" AzureWizard so the dashboard + * uses the identical native flow (and validation) as the cluster node's + * right-click command — no bespoke webview dialog. The wizard's execute + * step refreshes the cluster node in the tree. Returns whether a database + * was created so the dashboard can refresh its table. + */ + createDatabase: publicProcedureWithTelemetry.mutation(async ({ ctx }): Promise => { + const myCtx = ctx as WithTelemetry; + return runCreateDatabaseWizard(myCtx.clusterId, myCtx.clusterDisplayName, myCtx.clusterTreeId); + }), + + /** + * BACKEND INTEGRATION POINT — createCollection + * ----------------------------------------------------------------- + * Delegates to the shared "Create collection" AzureWizard for the named + * database, mirroring the database node's right-click command. The wizard + * refreshes the owning database node in the tree. Returns whether a + * collection was created so the dashboard can refresh its table. + */ + createCollection: publicProcedureWithTelemetry + .input(DatabaseNameInput) + .mutation(async ({ input, ctx }): Promise => { + const myCtx = ctx as WithTelemetry; + // The database node id is the cluster tree id plus the db name. + const databaseNodeId = `${myCtx.clusterTreeId}/${input.databaseName}`; + return runCreateCollectionWizard(myCtx.clusterId, input.databaseName, databaseNodeId); + }), +}); diff --git a/src/webviews/documentdb/clusterView/components/Breadcrumb.tsx b/src/webviews/documentdb/clusterView/components/Breadcrumb.tsx new file mode 100644 index 000000000..d36b1577b --- /dev/null +++ b/src/webviews/documentdb/clusterView/components/Breadcrumb.tsx @@ -0,0 +1,56 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Breadcrumb, BreadcrumbButton, BreadcrumbDivider, BreadcrumbItem } from '@fluentui/react-components'; +import { DatabaseRegular, ServerRegular } from '@fluentui/react-icons'; +import * as l10n from '@vscode/l10n'; +import { type JSX } from 'react'; + +export interface DashboardBreadcrumbProps { + clusterDisplayName: string; + /** Current database when drilled in; `undefined` on the overview page. */ + databaseName?: string; + /** Navigate back to the cluster overview. */ + onNavigateToOverview: () => void; +} + +/** + * Cluster → database breadcrumb. On the overview the cluster segment is the + * current (non-interactive) page; when drilled into a database the cluster + * segment becomes a button that returns to the overview. + */ +export const DashboardBreadcrumb = ({ + clusterDisplayName, + databaseName, + onNavigateToOverview, +}: DashboardBreadcrumbProps): JSX.Element => { + const onDatabasePage = databaseName !== undefined; + + return ( + + + {onDatabasePage ? ( + } onClick={onNavigateToOverview}> + {clusterDisplayName} + + ) : ( + } current> + {clusterDisplayName} + + )} + + {onDatabasePage && ( + <> + + + } current> + {databaseName} + + + + )} + + ); +}; diff --git a/src/webviews/documentdb/clusterView/components/CollectionList.tsx b/src/webviews/documentdb/clusterView/components/CollectionList.tsx new file mode 100644 index 000000000..e79b9bdba --- /dev/null +++ b/src/webviews/documentdb/clusterView/components/CollectionList.tsx @@ -0,0 +1,271 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { MessageBar, MessageBarBody, SearchBox, Spinner, Toolbar, ToolbarButton } from '@fluentui/react-components'; +import { AddRegular, ArrowClockwiseRegular } from '@fluentui/react-icons'; +import * as l10n from '@vscode/l10n'; +import { useCallback, useEffect, useMemo, useState, type JSX } from 'react'; +import { useTrpcClient } from '../../../_integration/useTrpcClient'; +import { + DASHBOARD_COLUMN_WIDTH, + DASHBOARD_METRIC_MIN_WIDTH, + DASHBOARD_NAME_MIN_WIDTH, + METRICS_CONCURRENCY_LIMIT, +} from '../constants'; +import { useBoundedMetrics, type MetricLoader } from '../hooks/useBoundedMetrics'; +import { type CollectionMetrics, type CollectionRow, type CollectionSortColumn, type SortDirection } from '../types'; +import { formatBytes, formatCount } from '../utils/format'; +import { filterAndSortRows } from '../utils/sort'; +import { DashboardTable, type DashboardColumn } from './DashboardTable'; + +export interface CollectionListProps { + /** The database whose collections are listed. */ + databaseName: string; +} + +/** + * Database drill-in: one row per collection with streamed index-count and size + * metrics. Mirrors {@link DatabaseList}: cheap `listCollections` renders rows + * immediately, metrics stream in per row with bounded concurrency. + */ +export const CollectionList = ({ databaseName }: CollectionListProps): JSX.Element => { + const { trpcClient } = useTrpcClient(); + + const [names, setNames] = useState>([]); + const [isLoading, setIsLoading] = useState(true); + const [loadError, setLoadError] = useState(undefined); + const [search, setSearch] = useState(''); + const [sortColumn, setSortColumn] = useState('name'); + const [sortDirection, setSortDirection] = useState('ascending'); + const [resetToken, setResetToken] = useState(0); + const [createError, setCreateError] = useState(undefined); + + const loadCollections = useCallback(async (): Promise => { + setIsLoading(true); + setLoadError(undefined); + try { + const rows = await trpcClient.mongoClusters.clusterView.listCollections.query({ databaseName }); + setNames(rows.map((r) => r.name)); + } catch (error) { + setLoadError(error instanceof Error ? error.message : String(error)); + setNames([]); + } finally { + setIsLoading(false); + } + }, [trpcClient, databaseName]); + + useEffect(() => { + void loadCollections(); + }, [loadCollections, resetToken]); + + const metricLoader = useCallback>( + (collectionName, signal) => + trpcClient.mongoClusters.clusterView.getCollectionMetrics.query( + { databaseName, collectionName }, + { signal }, + ), + [trpcClient, databaseName], + ); + + const metrics = useBoundedMetrics(names, metricLoader, METRICS_CONCURRENCY_LIMIT, resetToken); + + const rows = useMemo( + () => + names.map((name) => { + const entry = metrics[name]; + if (!entry || entry.status === 'loading') { + return { name, status: 'loading' }; + } + if (entry.status === 'unavailable' || !entry.metrics) { + return { name, status: 'unavailable' }; + } + return { + name, + status: 'loaded', + storageSize: entry.metrics.storageSize, + documentCount: entry.metrics.documentCount, + avgDocumentSize: entry.metrics.avgDocumentSize, + indexCount: entry.metrics.indexCount, + totalIndexSize: entry.metrics.totalIndexSize, + }; + }), + [names, metrics], + ); + + const getSortValue = useCallback( + (row: CollectionRow): string | number | undefined => { + switch (sortColumn) { + case 'name': + return row.name; + case 'storageSize': + return row.storageSize; + case 'documentCount': + return row.documentCount; + case 'avgDocumentSize': + return row.avgDocumentSize; + case 'indexCount': + return row.indexCount; + case 'totalIndexSize': + return row.totalIndexSize; + } + }, + [sortColumn], + ); + + const visibleRows = useMemo( + () => filterAndSortRows(rows, search, (r) => r.name, getSortValue, sortDirection), + [rows, search, getSortValue, sortDirection], + ); + + const toggleSort = useCallback( + (columnId: string): void => { + const column = columnId as CollectionSortColumn; + if (column === sortColumn) { + setSortDirection((prev) => (prev === 'ascending' ? 'descending' : 'ascending')); + } else { + setSortColumn(column); + setSortDirection('ascending'); + } + }, + [sortColumn], + ); + + const handleCreate = useCallback(async (): Promise => { + setCreateError(undefined); + // Reuses the shared "Create collection" wizard (native input box + + // validation + tree refresh) via the router, rather than a webview + // dialog. A pre-flight failure (e.g. not signed in) is returned to + // surface inline; wizard-time errors are shown natively by the command + // infrastructure. Refresh the table only when a collection was created. + const result = await trpcClient.mongoClusters.clusterView.createCollection.mutate({ databaseName }); + if (result.created) { + setResetToken((prev) => prev + 1); + } else if (result.error) { + setCreateError(result.error); + } + }, [trpcClient, databaseName]); + + const columns = useMemo[]>( + () => [ + { + columnId: 'name', + label: l10n.t('Collection'), + align: 'start', + sortable: true, + width: DASHBOARD_COLUMN_WIDTH.name, + minWidth: DASHBOARD_NAME_MIN_WIDTH, + render: (row) => row.name, + }, + { + columnId: 'storageSize', + label: l10n.t('Storage size'), + align: 'end', + sortable: true, + width: DASHBOARD_COLUMN_WIDTH.metric, + minWidth: DASHBOARD_METRIC_MIN_WIDTH, + showSpinnerWhileLoading: true, + render: (row) => formatBytes(row.storageSize), + }, + { + columnId: 'documentCount', + label: l10n.t('Documents'), + align: 'end', + sortable: true, + width: DASHBOARD_COLUMN_WIDTH.metric, + minWidth: DASHBOARD_METRIC_MIN_WIDTH, + showSpinnerWhileLoading: true, + render: (row) => formatCount(row.documentCount), + }, + { + columnId: 'avgDocumentSize', + label: l10n.t('Avg. document size'), + align: 'end', + sortable: true, + width: DASHBOARD_COLUMN_WIDTH.metric, + minWidth: DASHBOARD_METRIC_MIN_WIDTH, + showSpinnerWhileLoading: true, + render: (row) => formatBytes(row.avgDocumentSize), + }, + { + columnId: 'indexCount', + label: l10n.t('Indexes'), + align: 'end', + sortable: true, + width: DASHBOARD_COLUMN_WIDTH.metric, + minWidth: DASHBOARD_METRIC_MIN_WIDTH, + showSpinnerWhileLoading: true, + render: (row) => formatCount(row.indexCount), + }, + { + columnId: 'totalIndexSize', + label: l10n.t('Total index size'), + align: 'end', + sortable: true, + width: DASHBOARD_COLUMN_WIDTH.metric, + minWidth: DASHBOARD_METRIC_MIN_WIDTH, + showSpinnerWhileLoading: true, + render: (row) => formatBytes(row.totalIndexSize), + }, + ], + [], + ); + + return ( +
+
+ + } onClick={() => void handleCreate()}> + {l10n.t('Create collection')} + + } onClick={() => setResetToken((prev) => prev + 1)}> + {l10n.t('Refresh')} + + + setSearch(data.value)} + aria-label={l10n.t('Filter collections by name')} + /> +
+ + {createError ? ( + + {createError} + + ) : null} + + {loadError ? ( + + {l10n.t('Failed to load collections: {0}', loadError)} + + ) : isLoading ? ( +
+ +
+ ) : visibleRows.length === 0 ? ( +
+

+ {names.length === 0 + ? l10n.t('This database has no collections yet.') + : l10n.t('No collections match your filter.')} +

+
+ ) : ( + + ariaLabel={l10n.t('Collections')} + columns={columns} + rows={visibleRows} + getRowKey={(row) => row.name} + isRowLoading={(row) => row.status === 'loading'} + sortColumn={sortColumn} + sortDirection={sortDirection} + onToggleSort={toggleSort} + /> + )} +
+ ); +}; diff --git a/src/webviews/documentdb/clusterView/components/DashboardTable.tsx b/src/webviews/documentdb/clusterView/components/DashboardTable.tsx new file mode 100644 index 000000000..387f3e3b0 --- /dev/null +++ b/src/webviews/documentdb/clusterView/components/DashboardTable.tsx @@ -0,0 +1,186 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { + createTableColumn, + Spinner, + Table, + TableBody, + TableCell, + TableCellLayout, + TableHeader, + TableHeaderCell, + TableRow, + useTableColumnSizing_unstable, + useTableFeatures, + type TableColumnDefinition, + type TableColumnSizingOptions, +} from '@fluentui/react-components'; +import { useMemo, type JSX, type ReactNode } from 'react'; +import { type SortDirection } from '../types'; + +/** Column descriptor for {@link DashboardTable}. */ +export interface DashboardColumn { + /** Stable column identifier, also used as the sort key. */ + columnId: string; + /** Localised header label. */ + label: string; + /** Cell content alignment. Defaults to `start`. */ + align?: 'start' | 'center' | 'end'; + /** Whether the column header is clickable to sort. Defaults to `false`. */ + sortable?: boolean; + /** + * Ideal/default column width in pixels. Drives Fluent's column-sizing + * feature so columns stay a fixed width instead of stretching to fill the + * editor. Keeping these consistent across the database and collection + * tables makes both views look identical regardless of column count. + */ + width?: number; + /** Minimum column width in pixels. Defaults to `80`. */ + minWidth?: number; + /** Renders the cell content for a row. */ + render: (row: TRow) => ReactNode; + /** + * When true, the cell shows a spinner instead of `render(row)` while the + * row's metrics are still loading. Used for the streamed metric columns. + */ + showSpinnerWhileLoading?: boolean; +} + +export interface DashboardTableProps { + ariaLabel: string; + columns: ReadonlyArray>; + rows: ReadonlyArray; + getRowKey: (row: TRow) => string; + /** True while the given row's streamed metrics are still loading. */ + isRowLoading: (row: TRow) => boolean; + sortColumn: string; + sortDirection: SortDirection; + onToggleSort: (columnId: string) => void; + /** Invoked when a row is activated (click / Enter / Space) for drill-in. */ + onRowActivate?: (row: TRow) => void; +} + +/** Default minimum width applied to columns that don't specify one. */ +const DEFAULT_MIN_WIDTH = 80; + +/** + * Generic sortable, zebra-striped dashboard table built on Fluent UI table + * primitives. Rendering of sort state and cell alignment is driven entirely by + * the {@link DashboardColumn} descriptors so the database overview and the + * collection drill-in can share one implementation. + * + * Column widths are applied through Fluent's column-sizing feature so columns + * keep a fixed width rather than each ballooning to an even share of the editor + * width — this keeps the database and collection tables visually consistent + * regardless of how many columns each has. + * + * The component is presentational: callers pass already-filtered, already-sorted + * rows and own the sort state. Header clicks call `onToggleSort`. + */ +export function DashboardTable({ + ariaLabel, + columns, + rows, + getRowKey, + isRowLoading, + sortColumn, + sortDirection, + onToggleSort, + onRowActivate, +}: DashboardTableProps): JSX.Element { + const interactive = onRowActivate !== undefined; + + const tableColumns = useMemo[]>( + () => columns.map((col) => createTableColumn({ columnId: col.columnId })), + [columns], + ); + + const columnSizingOptions = useMemo(() => { + const options: TableColumnSizingOptions = {}; + for (const col of columns) { + if (col.width !== undefined) { + options[col.columnId] = { + idealWidth: col.width, + defaultWidth: col.width, + minWidth: col.minWidth ?? DEFAULT_MIN_WIDTH, + }; + } + } + return options; + }, [columns]); + + const { columnSizing_unstable, tableRef } = useTableFeatures( + { columns: tableColumns, items: rows as TRow[] }, + [useTableColumnSizing_unstable({ columnSizingOptions, autoFitColumns: false })], + ); + + return ( + + + + {columns.map((col) => ( + onToggleSort(col.columnId) : undefined} + aria-sort={col.sortable && sortColumn === col.columnId ? sortDirection : undefined} + > + {col.label} + + ))} + + + + {rows.map((row, rowIdx) => { + const rowClass = rowIdx % 2 === 0 ? 'rowEven' : 'rowOdd'; + const loading = isRowLoading(row); + return ( + onRowActivate?.(row) : undefined} + onKeyDown={ + interactive + ? (event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + onRowActivate?.(row); + } + } + : undefined + } + tabIndex={interactive ? 0 : undefined} + role={interactive ? 'button' : undefined} + > + {columns.map((col) => ( + + {col.showSpinnerWhileLoading && loading ? ( + + ) : ( + {col.render(row)} + )} + + ))} + + ); + })} + +
+ ); +} diff --git a/src/webviews/documentdb/clusterView/components/DatabaseList.tsx b/src/webviews/documentdb/clusterView/components/DatabaseList.tsx new file mode 100644 index 000000000..5998b5a05 --- /dev/null +++ b/src/webviews/documentdb/clusterView/components/DatabaseList.tsx @@ -0,0 +1,245 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { MessageBar, MessageBarBody, SearchBox, Spinner, Toolbar, ToolbarButton } from '@fluentui/react-components'; +import { AddRegular, ArrowClockwiseRegular } from '@fluentui/react-icons'; +import * as l10n from '@vscode/l10n'; +import { useCallback, useEffect, useMemo, useState, type JSX } from 'react'; +import { useTrpcClient } from '../../../_integration/useTrpcClient'; +import { + DASHBOARD_COLUMN_WIDTH, + DASHBOARD_METRIC_MIN_WIDTH, + DASHBOARD_NAME_MIN_WIDTH, + METRICS_CONCURRENCY_LIMIT, +} from '../constants'; +import { useBoundedMetrics, type MetricLoader } from '../hooks/useBoundedMetrics'; +import { type DatabaseMetrics, type DatabaseRow, type DatabaseSortColumn, type SortDirection } from '../types'; +import { formatBytes, formatCount } from '../utils/format'; +import { filterAndSortRows } from '../utils/sort'; +import { DashboardTable, type DashboardColumn } from './DashboardTable'; + +export interface DatabaseListProps { + /** Drill into the named database (switches the parent view to collections). */ + onOpenDatabase: (databaseName: string) => void; +} + +/** + * Cluster overview: one row per database with streamed collection-count, + * index-count and size metrics. Renders the names immediately from the cheap + * `listDatabases` call, then fills metrics in per row via {@link useBoundedMetrics}. + */ +export const DatabaseList = ({ onOpenDatabase }: DatabaseListProps): JSX.Element => { + const { trpcClient } = useTrpcClient(); + + const [names, setNames] = useState>([]); + const [isLoading, setIsLoading] = useState(true); + const [loadError, setLoadError] = useState(undefined); + const [search, setSearch] = useState(''); + const [sortColumn, setSortColumn] = useState('name'); + const [sortDirection, setSortDirection] = useState('ascending'); + const [resetToken, setResetToken] = useState(0); + const [createError, setCreateError] = useState(undefined); + + /** Load the database name list. Throws surface as an error state. */ + const loadDatabases = useCallback(async (): Promise => { + setIsLoading(true); + setLoadError(undefined); + try { + const rows = await trpcClient.mongoClusters.clusterView.listDatabases.query(); + setNames(rows.map((r) => r.name)); + } catch (error) { + setLoadError(error instanceof Error ? error.message : String(error)); + setNames([]); + } finally { + setIsLoading(false); + } + }, [trpcClient]); + + useEffect(() => { + void loadDatabases(); + }, [loadDatabases, resetToken]); + + const metricLoader = useCallback>( + (databaseName, signal) => + trpcClient.mongoClusters.clusterView.getDatabaseMetrics.query({ databaseName }, { signal }), + [trpcClient], + ); + + const metrics = useBoundedMetrics(names, metricLoader, METRICS_CONCURRENCY_LIMIT, resetToken); + + /** Merge the name list with streamed metrics into renderable rows. */ + const rows = useMemo( + () => + names.map((name) => { + const entry = metrics[name]; + if (!entry || entry.status === 'loading') { + return { name, status: 'loading' }; + } + if (entry.status === 'unavailable' || !entry.metrics) { + return { name, status: 'unavailable' }; + } + return { + name, + status: 'loaded', + storageSize: entry.metrics.storageSize, + collectionCount: entry.metrics.collectionCount, + indexCount: entry.metrics.indexCount, + }; + }), + [names, metrics], + ); + + const getSortValue = useCallback( + (row: DatabaseRow): string | number | undefined => { + switch (sortColumn) { + case 'name': + return row.name; + case 'storageSize': + return row.storageSize; + case 'collectionCount': + return row.collectionCount; + case 'indexCount': + return row.indexCount; + } + }, + [sortColumn], + ); + + const visibleRows = useMemo( + () => filterAndSortRows(rows, search, (r) => r.name, getSortValue, sortDirection), + [rows, search, getSortValue, sortDirection], + ); + + const toggleSort = useCallback( + (columnId: string): void => { + const column = columnId as DatabaseSortColumn; + if (column === sortColumn) { + setSortDirection((prev) => (prev === 'ascending' ? 'descending' : 'ascending')); + } else { + setSortColumn(column); + setSortDirection('ascending'); + } + }, + [sortColumn], + ); + + const handleCreate = useCallback(async (): Promise => { + setCreateError(undefined); + // Reuses the shared "Create database" wizard (native input box + + // validation + tree refresh) via the router, rather than a webview + // dialog. A pre-flight failure (e.g. not signed in) is returned to + // surface inline; wizard-time errors are shown natively by the command + // infrastructure. Refresh the table only when a database was created. + const result = await trpcClient.mongoClusters.clusterView.createDatabase.mutate(); + if (result.created) { + setResetToken((prev) => prev + 1); + } else if (result.error) { + setCreateError(result.error); + } + }, [trpcClient]); + + const columns = useMemo[]>( + () => [ + { + columnId: 'name', + label: l10n.t('Database'), + align: 'start', + sortable: true, + width: DASHBOARD_COLUMN_WIDTH.name, + minWidth: DASHBOARD_NAME_MIN_WIDTH, + render: (row) => row.name, + }, + { + columnId: 'storageSize', + label: l10n.t('Storage size'), + align: 'end', + sortable: true, + width: DASHBOARD_COLUMN_WIDTH.metric, + minWidth: DASHBOARD_METRIC_MIN_WIDTH, + showSpinnerWhileLoading: true, + render: (row) => formatBytes(row.storageSize), + }, + { + columnId: 'collectionCount', + label: l10n.t('Collections'), + align: 'end', + sortable: true, + width: DASHBOARD_COLUMN_WIDTH.metric, + minWidth: DASHBOARD_METRIC_MIN_WIDTH, + showSpinnerWhileLoading: true, + render: (row) => formatCount(row.collectionCount), + }, + { + columnId: 'indexCount', + label: l10n.t('Indexes'), + align: 'end', + sortable: true, + width: DASHBOARD_COLUMN_WIDTH.metric, + minWidth: DASHBOARD_METRIC_MIN_WIDTH, + showSpinnerWhileLoading: true, + render: (row) => formatCount(row.indexCount), + }, + ], + [], + ); + + return ( +
+
+ + } onClick={() => void handleCreate()}> + {l10n.t('Create database')} + + } onClick={() => setResetToken((prev) => prev + 1)}> + {l10n.t('Refresh')} + + + setSearch(data.value)} + aria-label={l10n.t('Filter databases by name')} + /> +
+ + {createError ? ( + + {createError} + + ) : null} + + {loadError ? ( + + {l10n.t('Failed to load databases: {0}', loadError)} + + ) : isLoading ? ( +
+ +
+ ) : visibleRows.length === 0 ? ( +
+

+ {names.length === 0 + ? l10n.t('This cluster has no databases yet.') + : l10n.t('No databases match your filter.')} +

+
+ ) : ( + + ariaLabel={l10n.t('Databases')} + columns={columns} + rows={visibleRows} + getRowKey={(row) => row.name} + isRowLoading={(row) => row.status === 'loading'} + sortColumn={sortColumn} + sortDirection={sortDirection} + onToggleSort={toggleSort} + onRowActivate={(row) => onOpenDatabase(row.name)} + /> + )} +
+ ); +}; diff --git a/src/webviews/documentdb/clusterView/constants.ts b/src/webviews/documentdb/clusterView/constants.ts new file mode 100644 index 000000000..8e1924d6b --- /dev/null +++ b/src/webviews/documentdb/clusterView/constants.ts @@ -0,0 +1,31 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Maximum number of per-row metric (`dbStats` / `collStats`) requests to run in + * parallel when streaming metrics into the dashboard table. Bounded so a large + * cluster does not fan out hundreds of simultaneous stats commands. + */ +export const METRICS_CONCURRENCY_LIMIT = 6; + +/** + * Fixed pixel widths for the dashboard table columns. Applied through Fluent's + * column-sizing feature so the database and collection tables render with + * identical column geometry instead of each column stretching to fill the + * editor (which made the few-column collection table look disproportionately + * wide). The name column flexes wide; the numeric metric columns stay compact. + */ +export const DASHBOARD_COLUMN_WIDTH = { + /** Database / collection name column — wide, holds long identifiers. */ + name: 320, + /** A numeric metric column (counts and human-readable sizes). */ + metric: 140, +} as const; + +/** Minimum width for the name column so it never collapses past usability. */ +export const DASHBOARD_NAME_MIN_WIDTH = 160; + +/** Minimum width for a numeric metric column. */ +export const DASHBOARD_METRIC_MIN_WIDTH = 90; diff --git a/src/webviews/documentdb/clusterView/hooks/useBoundedMetrics.ts b/src/webviews/documentdb/clusterView/hooks/useBoundedMetrics.ts new file mode 100644 index 000000000..9e10e39a3 --- /dev/null +++ b/src/webviews/documentdb/clusterView/hooks/useBoundedMetrics.ts @@ -0,0 +1,114 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { useEffect, useRef, useState } from 'react'; +import { type MetricStatus } from '../types'; + +/** A single row's metric load state, as tracked by {@link useBoundedMetrics}. */ +export interface MetricEntry { + status: MetricStatus; + metrics?: TMetrics; +} + +/** + * Loader for a single row's metrics. Receives the row key and an `AbortSignal` + * that is triggered when the run is superseded (keys changed, reset, or + * unmount). Should resolve to the metrics, or `null` when the metrics are + * unavailable (e.g. a stats command is denied or unsupported). + */ +export type MetricLoader = (key: string, signal: AbortSignal) => Promise; + +/** + * Streams per-row metrics for a list of keys with bounded concurrency. + * + * Rows start in the `loading` state and transition to `loaded` (metrics + * present) or `unavailable` (loader resolved `null` or threw) as results + * arrive. At most `limit` loaders run at once so a large cluster does not fan + * out hundreds of simultaneous stats calls. + * + * The run restarts whenever the set of keys changes or `resetToken` is bumped + * (used by the dashboard's Refresh button). A superseded run is aborted via the + * `AbortSignal` passed to the loader and its late results are ignored. + * + * @param keys - Stable row identities (e.g. database or collection names). + * @param loader - Fetches metrics for one key; kept current via a ref so the + * hook does not restart when the caller passes a new closure. + * @param limit - Maximum number of concurrent loaders. + * @param resetToken - Bump to force a full reload of all rows. + * @returns A map from key to its current {@link MetricEntry}. + */ +export function useBoundedMetrics( + keys: ReadonlyArray, + loader: MetricLoader, + limit: number, + resetToken: number, +): Record> { + const [entries, setEntries] = useState>>({}); + + // Keep the latest loader without making it an effect dependency: a new + // closure each render must not restart the streaming run. + const loaderRef = useRef(loader); + useEffect(() => { + loaderRef.current = loader; + }, [loader]); + + // A stable signature so the effect only re-runs when the actual set of keys + // changes, not on every array-identity change. + const keysSignature = keys.join('\u0000'); + + useEffect(() => { + const abortController = new AbortController(); + const signal = abortController.signal; + + // Seed every row into the loading state for an immediate render. + const initial: Record> = {}; + for (const key of keys) { + initial[key] = { status: 'loading' }; + } + setEntries(initial); + + let cursor = 0; + const workerCount = Math.min(limit, keys.length); + + const runWorker = async (): Promise => { + while (!signal.aborted) { + const index = cursor; + cursor += 1; + if (index >= keys.length) { + return; + } + const key = keys[index]; + let result: TMetrics | null = null; + try { + result = await loaderRef.current(key, signal); + } catch { + result = null; + } + if (signal.aborted) { + return; + } + setEntries((prev) => ({ + ...prev, + [key]: result === null ? { status: 'unavailable' } : { status: 'loaded', metrics: result }, + })); + } + }; + + const workers: Promise[] = []; + for (let i = 0; i < workerCount; i += 1) { + workers.push(runWorker()); + } + void Promise.all(workers); + + return () => { + abortController.abort(); + }; + // `keys` is intentionally excluded in favour of its stable signature + // (`keysSignature`) so the streaming run only restarts when the actual + // set of keys changes, not on every array-identity change. + }, [keysSignature, limit, resetToken]); + + return entries; +} diff --git a/src/webviews/documentdb/clusterView/types.ts b/src/webviews/documentdb/clusterView/types.ts new file mode 100644 index 000000000..e3820de2e --- /dev/null +++ b/src/webviews/documentdb/clusterView/types.ts @@ -0,0 +1,90 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Per-row metric load state. Rows render immediately from the cheap list call + * with `status: 'loading'`, then transition to `loaded` (metrics present) or + * `unavailable` (stats call failed or was denied) as results stream in. + */ +export type MetricStatus = 'loading' | 'loaded' | 'unavailable'; + +/** A single database row in the cluster overview table. */ +export interface DatabaseRow { + name: string; + status: MetricStatus; + /** On-disk storage size in bytes. `undefined` until loaded or when unavailable. */ + storageSize?: number; + /** Number of collections; `undefined` until loaded or when unavailable. */ + collectionCount?: number; + /** Total number of indexes across the database. */ + indexCount?: number; +} + +/** A single collection row in the database drill-in table. */ +export interface CollectionRow { + name: string; + status: MetricStatus; + /** On-disk storage size in bytes. `undefined` until loaded or when unavailable. */ + storageSize?: number; + /** Number of documents in the collection. */ + documentCount?: number; + /** Average document size in bytes. */ + avgDocumentSize?: number; + /** Number of indexes on the collection. */ + indexCount?: number; + /** Total size in bytes of all indexes on the collection. */ + totalIndexSize?: number; +} + +/** Metrics returned by the `getDatabaseMetrics` procedure. */ +export interface DatabaseMetrics { + /** On-disk storage size in bytes (`dbStats.storageSize`). */ + storageSize: number; + /** Number of collections (`dbStats.collections`). */ + collectionCount: number; + /** Total number of indexes across the database (`dbStats.indexes`). */ + indexCount: number; +} + +/** Metrics returned by the `getCollectionMetrics` procedure. */ +export interface CollectionMetrics { + /** On-disk storage size in bytes (`collStats.storageSize`). */ + storageSize: number; + /** Number of documents (`collStats.count`). */ + documentCount: number; + /** Average document size in bytes (`collStats.avgObjSize`). */ + avgDocumentSize: number; + /** Number of indexes (`collStats.nindexes`). */ + indexCount: number; + /** Total size in bytes of all indexes (`collStats.totalIndexSize`). */ + totalIndexSize: number; +} + +/** Sortable column keys for the database overview table. */ +export type DatabaseSortColumn = 'name' | 'storageSize' | 'collectionCount' | 'indexCount'; + +/** Sortable column keys for the collection drill-in table. */ +export type CollectionSortColumn = + | 'name' + | 'storageSize' + | 'documentCount' + | 'avgDocumentSize' + | 'indexCount' + | 'totalIndexSize'; + +/** Sort direction for the dashboard tables. */ +export type SortDirection = 'ascending' | 'descending'; + +/** Result of a create-database / create-collection flow. */ +export interface CreateResult { + /** True when an entity was created and the dashboard table should refresh. */ + created: boolean; + /** + * Human-readable message for a pre-flight failure the dashboard should + * surface inline (e.g. not signed in). Errors raised during the wizard + * itself are shown natively by the command infrastructure, not here. + */ + error?: string; +} diff --git a/src/webviews/documentdb/clusterView/utils/format.test.ts b/src/webviews/documentdb/clusterView/utils/format.test.ts new file mode 100644 index 000000000..88113ae23 --- /dev/null +++ b/src/webviews/documentdb/clusterView/utils/format.test.ts @@ -0,0 +1,64 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { formatBytes, formatCount, formatMetricCell } from './format'; + +describe('clusterView/format', () => { + describe('formatBytes', () => { + it.each<[number, string]>([ + [0, '0 B'], + [512, '512 B'], + [1023, '1023 B'], + [1024, '1.0 KB'], + [1536, '1.5 KB'], + [1024 * 1024, '1.0 MB'], + [1024 * 1024 * 1024, '1.0 GB'], + [1024 * 1024 * 1024 * 1024, '1.0 TB'], + ])('formats %d bytes as "%s"', (bytes, expected) => { + expect(formatBytes(bytes)).toBe(expected); + }); + + it('drops the decimal once the value reaches 10 of a unit', () => { + // 10 KB and above render without a fractional part. + expect(formatBytes(10 * 1024)).toBe('10 KB'); + expect(formatBytes(15 * 1024)).toBe('15 KB'); + }); + + it('caps the unit at TB for very large values', () => { + // 1024 TB stays in TB rather than introducing a new unit. + expect(formatBytes(1024 * 1024 * 1024 * 1024 * 1024)).toBe('1024 TB'); + }); + + it('returns an em dash for undefined or NaN', () => { + expect(formatBytes(undefined)).toBe('—'); + expect(formatBytes(Number.NaN)).toBe('—'); + }); + }); + + describe('formatCount', () => { + it('formats integers with locale grouping', () => { + expect(formatCount(0)).toBe('0'); + expect(formatCount(42)).toBe('42'); + expect(formatCount(1000)).toBe((1000).toLocaleString()); + }); + + it('returns an em dash for undefined or NaN', () => { + expect(formatCount(undefined)).toBe('—'); + expect(formatCount(Number.NaN)).toBe('—'); + }); + }); + + describe('formatMetricCell', () => { + it('defers to the formatter only when loaded', () => { + expect(formatMetricCell('loaded', 2048, formatBytes)).toBe('2.0 KB'); + expect(formatMetricCell('loaded', 7, formatCount)).toBe('7'); + }); + + it('renders an em dash while loading or when unavailable', () => { + expect(formatMetricCell('loading', 2048, formatBytes)).toBe('—'); + expect(formatMetricCell('unavailable', 2048, formatBytes)).toBe('—'); + }); + }); +}); diff --git a/src/webviews/documentdb/clusterView/utils/format.ts b/src/webviews/documentdb/clusterView/utils/format.ts new file mode 100644 index 000000000..168303c8b --- /dev/null +++ b/src/webviews/documentdb/clusterView/utils/format.ts @@ -0,0 +1,58 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as l10n from '@vscode/l10n'; +import { type MetricStatus } from '../types'; + +const BYTES_IN_KIB = 1024; +const SIZE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB'] as const; + +/** + * Format a byte count with a localised binary unit suffix (KB/MB/GB…). + * Mirrors the index-view formatter so size columns read consistently across + * the extension's webviews. + */ +export function formatBytes(bytes: number | undefined): string { + if (bytes === undefined || Number.isNaN(bytes)) { + return l10n.t('—'); + } + if (bytes < BYTES_IN_KIB) { + return `${bytes} ${SIZE_UNITS[0]}`; + } + let value = bytes; + let unitIndex = 0; + while (value >= BYTES_IN_KIB && unitIndex < SIZE_UNITS.length - 1) { + value /= BYTES_IN_KIB; + unitIndex += 1; + } + return `${value.toFixed(value >= 10 ? 0 : 1)} ${SIZE_UNITS[unitIndex]}`; +} + +/** + * Format an integer count for display. Returns an em dash when the value is + * unavailable so cells stay aligned with the size column's placeholder. + */ +export function formatCount(value: number | undefined): string { + if (value === undefined || Number.isNaN(value)) { + return l10n.t('—'); + } + return value.toLocaleString(); +} + +/** + * Render the display string for a metric cell based on its load state. + * `loading` shows an em dash placeholder (the table also renders a spinner), + * `unavailable` shows an em dash, and `loaded` defers to the value formatter. + */ +export function formatMetricCell( + status: MetricStatus, + value: number | undefined, + formatter: (value: number | undefined) => string, +): string { + if (status === 'loaded') { + return formatter(value); + } + return l10n.t('—'); +} diff --git a/src/webviews/documentdb/clusterView/utils/sort.test.ts b/src/webviews/documentdb/clusterView/utils/sort.test.ts new file mode 100644 index 000000000..543b157cb --- /dev/null +++ b/src/webviews/documentdb/clusterView/utils/sort.test.ts @@ -0,0 +1,78 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type SortDirection } from '../types'; +import { compareSortValues, filterAndSortRows } from './sort'; + +interface TestRow { + name: string; + size?: number; +} + +describe('clusterView/sort', () => { + describe('compareSortValues', () => { + it('orders numbers arithmetically', () => { + expect(compareSortValues(1, 2)).toBeLessThan(0); + expect(compareSortValues(2, 1)).toBeGreaterThan(0); + expect(compareSortValues(2, 2)).toBe(0); + }); + + it('orders strings case-insensitively and numerically', () => { + expect(compareSortValues('apple', 'Banana')).toBeLessThan(0); + // Numeric-aware: db2 sorts before db10. + expect(compareSortValues('db2', 'db10')).toBeLessThan(0); + }); + + it('always sorts undefined last', () => { + expect(compareSortValues(undefined, 5)).toBeGreaterThan(0); + expect(compareSortValues(5, undefined)).toBeLessThan(0); + expect(compareSortValues(undefined, undefined)).toBe(0); + }); + }); + + describe('filterAndSortRows', () => { + const rows: TestRow[] = [ + { name: 'alpha', size: 30 }, + { name: 'Beta', size: 10 }, + { name: 'gamma', size: undefined }, + { name: 'delta', size: 20 }, + ]; + + const byName = (r: TestRow): string => r.name; + const bySize = (r: TestRow): number | undefined => r.size; + + const run = ( + search: string, + getValue: (r: TestRow) => string | number | undefined, + direction: SortDirection, + ): string[] => filterAndSortRows(rows, search, byName, getValue, direction).map((r) => r.name); + + it('filters case-insensitively by name substring', () => { + expect(run('a', byName, 'ascending')).toEqual(['alpha', 'Beta', 'delta', 'gamma']); + expect(run('ET', byName, 'ascending')).toEqual(['Beta']); + }); + + it('returns all rows when the search term is empty', () => { + expect(run('', byName, 'ascending')).toHaveLength(rows.length); + }); + + it('sorts ascending and descending by name', () => { + expect(run('', byName, 'ascending')).toEqual(['alpha', 'Beta', 'delta', 'gamma']); + expect(run('', byName, 'descending')).toEqual(['gamma', 'delta', 'Beta', 'alpha']); + }); + + it('keeps undefined metric values last in both directions', () => { + // gamma (undefined size) stays last ascending and descending. + expect(run('', bySize, 'ascending')).toEqual(['Beta', 'delta', 'alpha', 'gamma']); + expect(run('', bySize, 'descending')).toEqual(['alpha', 'delta', 'Beta', 'gamma']); + }); + + it('does not mutate the input array', () => { + const snapshot = rows.map((r) => r.name); + filterAndSortRows(rows, '', byName, bySize, 'descending'); + expect(rows.map((r) => r.name)).toEqual(snapshot); + }); + }); +}); diff --git a/src/webviews/documentdb/clusterView/utils/sort.ts b/src/webviews/documentdb/clusterView/utils/sort.ts new file mode 100644 index 000000000..1db9debee --- /dev/null +++ b/src/webviews/documentdb/clusterView/utils/sort.ts @@ -0,0 +1,59 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type SortDirection } from '../types'; + +/** + * Compare two sort values for table ordering. Strings compare + * case-insensitively and numerically (so `db2` sorts before `db10`); numbers + * compare arithmetically. `undefined` (an unavailable metric) always sorts + * last regardless of direction so missing data never floats to the top. + * + * @returns A negative / zero / positive number for ascending order. + */ +export function compareSortValues(a: string | number | undefined, b: string | number | undefined): number { + if (a === undefined && b === undefined) { + return 0; + } + if (a === undefined) { + return 1; + } + if (b === undefined) { + return -1; + } + if (typeof a === 'string' && typeof b === 'string') { + return a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' }); + } + return (a as number) - (b as number); +} + +/** + * Filter rows whose name contains the (case-insensitive) search term, then sort + * by the value returned from `getSortValue` in the requested direction. + * `undefined` values are pushed to the end in both directions. + */ +export function filterAndSortRows( + rows: ReadonlyArray, + search: string, + getName: (row: TRow) => string, + getSortValue: (row: TRow) => string | number | undefined, + direction: SortDirection, +): TRow[] { + const term = search.trim().toLowerCase(); + const filtered = term.length === 0 ? [...rows] : rows.filter((row) => getName(row).toLowerCase().includes(term)); + + filtered.sort((a, b) => { + const valueA = getSortValue(a); + const valueB = getSortValue(b); + // Keep undefined metrics last irrespective of direction. + if (valueA === undefined || valueB === undefined) { + return compareSortValues(valueA, valueB); + } + const result = compareSortValues(valueA, valueB); + return direction === 'ascending' ? result : -result; + }); + + return filtered; +} From 693535573238e45fdb64d9590d1d8b29a70e4b1e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 6 Jul 2026 11:33:35 +0000 Subject: [PATCH 2/2] Migrate cluster dashboard webview to @microsoft/vscode-ext-webview --- l10n/bundle.l10n.json | 8 ++-- .../openClusterView/openClusterView.ts | 4 +- .../documentdb/clusterView/ClusterView.tsx | 2 +- .../clusterView/clusterViewController.ts | 45 ++++++++++--------- .../clusterView/clusterViewRouter.ts | 2 +- .../clusterView/components/CollectionList.tsx | 2 +- .../clusterView/components/DatabaseList.tsx | 2 +- 7 files changed, 34 insertions(+), 31 deletions(-) diff --git a/l10n/bundle.l10n.json b/l10n/bundle.l10n.json index 918aebfa2..b0a5f6b14 100644 --- a/l10n/bundle.l10n.json +++ b/l10n/bundle.l10n.json @@ -933,11 +933,10 @@ "No credentials found for cluster {0}": "No credentials found for cluster {0}", "No credentials found for id {clusterId}": "No credentials found for id {clusterId}", "No credentials found for the selected cluster.": "No credentials found for the selected cluster.", + "No databases match your filter.": "No databases match your filter.", "No DocumentDB services found in this context.": "No DocumentDB services found in this context.", "No DocumentDB services found in this namespace.": "No DocumentDB services found in this namespace.", "No DocumentDB targets were found in context \"{0}\". DKO resources are preferred, and generic fallback currently looks for DocumentDB gateway services.": "No DocumentDB targets were found in context \"{0}\". DKO resources are preferred, and generic fallback currently looks for DocumentDB gateway services.", - "No database connected": "No database connected", - "No databases match your filter.": "No databases match your filter.", "No entries": "No entries", "No folder selected.": "No folder selected.", "No index changes needed at this time.": "No index changes needed at this time.", @@ -1386,8 +1385,8 @@ "These signals help us improve, but more context in a discussion, issue report, or a direct message adds even more value. ": "These signals help us improve, but more context in a discussion, issue report, or a direct message adds even more value. ", "They look like binary files rather than kubeconfigs.": "They look like binary files rather than kubeconfigs.", "This cannot be undone.": "This cannot be undone.", - "This ClusterIP service requires port-forwarding. Confirm or change the local port to forward to {0}/{1}:{2}.": "This ClusterIP service requires port-forwarding. Confirm or change the local port to forward to {0}/{1}:{2}.", "This cluster has no databases yet.": "This cluster has no databases yet.", + "This ClusterIP service requires port-forwarding. Confirm or change the local port to forward to {0}/{1}:{2}.": "This ClusterIP service requires port-forwarding. Confirm or change the local port to forward to {0}/{1}:{2}.", "This database has no collections yet.": "This database has no collections yet.", "This field is not set": "This field is not set", "This functionality requires installing the Azure Account extension.": "This functionality requires installing the Azure Account extension.", @@ -1526,10 +1525,9 @@ "Yes, save my credentials": "Yes, save my credentials", "You are already signed in to tenant \"{0}\"": "You are already signed in to tenant \"{0}\"", "You are not signed in to an Azure account. Please sign in.": "You are not signed in to an Azure account. Please sign in.", - "You are not signed in to the DocumentDB cluster. Please sign in (by expanding the node \"{0}\") and try again.": "You are not signed in to the DocumentDB cluster. Please sign in (by expanding the node \"{0}\") and try again.", - "You are not signed in to the MongoDB Cluster. Please sign in (by expanding the node \"{0}\") and try again.": "You are not signed in to the MongoDB Cluster. Please sign in (by expanding the node \"{0}\") and try again.", "You are not signed in to the cluster \"{0}\". Expand it in the tree to sign in, then try again.": "You are not signed in to the cluster \"{0}\". Expand it in the tree to sign in, then try again.", "You are not signed in to the cluster. Expand it in the tree to sign in, then try again.": "You are not signed in to the cluster. Expand it in the tree to sign in, then try again.", + "You are not signed in to the DocumentDB cluster. Please sign in (by expanding the node \"{0}\") and try again.": "You are not signed in to the DocumentDB cluster. Please sign in (by expanding the node \"{0}\") and try again.", "You can disable this confirmation by setting \"{0}\" to false.": "You can disable this confirmation by setting \"{0}\" to false.", "You can increase the timeout in Settings:": "You can increase the timeout in Settings:", "You clicked a link that wants to open a DocumentDB connection in VS Code.": "You clicked a link that wants to open a DocumentDB connection in VS Code.", diff --git a/src/commands/openClusterView/openClusterView.ts b/src/commands/openClusterView/openClusterView.ts index 005092752..ee196f7d9 100644 --- a/src/commands/openClusterView/openClusterView.ts +++ b/src/commands/openClusterView/openClusterView.ts @@ -9,7 +9,7 @@ import * as l10n from '@vscode/l10n'; import { inferViewIdFromTreeId } from '../../documentdb/Views'; import { type ClusterItemBase } from '../../tree/documentdb/ClusterItemBase'; import { trackJourneyCorrelationId } from '../../utils/commandTelemetry'; -import { ClusterViewController } from '../../webviews/documentdb/clusterView/clusterViewController'; +import { openClusterWebview } from '../../webviews/documentdb/clusterView/clusterViewController'; /** * Opens the cluster dashboard / home page from a cluster tree node (context @@ -51,7 +51,7 @@ export async function openClusterViewInternal( clusterTreeId: string; }, ): Promise { - const view = new ClusterViewController({ + const view = openClusterWebview({ clusterId: props.clusterId, clusterDisplayName: props.clusterDisplayName, viewId: props.viewId, diff --git a/src/webviews/documentdb/clusterView/ClusterView.tsx b/src/webviews/documentdb/clusterView/ClusterView.tsx index 4e13bbe83..76780ce1a 100644 --- a/src/webviews/documentdb/clusterView/ClusterView.tsx +++ b/src/webviews/documentdb/clusterView/ClusterView.tsx @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { useConfiguration } from '@microsoft/vscode-ext-react-webview'; +import { useConfiguration } from '@microsoft/vscode-ext-webview/react'; import { type JSX, useState } from 'react'; import './clusterView.scss'; import { type ClusterViewWebviewConfigurationType } from './clusterViewController'; diff --git a/src/webviews/documentdb/clusterView/clusterViewController.ts b/src/webviews/documentdb/clusterView/clusterViewController.ts index 1f1ac682c..7b85ab10c 100644 --- a/src/webviews/documentdb/clusterView/clusterViewController.ts +++ b/src/webviews/documentdb/clusterView/clusterViewController.ts @@ -7,7 +7,7 @@ import * as vscode from 'vscode'; import { API } from '../../../DocumentDBExperiences'; import { ext } from '../../../extensionVariables'; -import { WebviewControllerBase } from '../../_integration/WebviewControllerBase'; +import { type AppWebviewController, openAppWebview } from '../../_integration/openAppWebview'; import { type RouterContext } from './clusterViewRouter'; /** @@ -29,14 +29,30 @@ export type ClusterViewWebviewConfigurationType = { }; /** - * Webview controller for the cluster dashboard / home page. Hosts the database - * overview and the per-database collection drill-in. + * Opens the cluster dashboard / home page webview. Hosts the database overview + * and the per-database collection drill-in. */ -export class ClusterViewController extends WebviewControllerBase { - constructor(initialData: ClusterViewWebviewConfigurationType) { - const title: string = initialData.clusterDisplayName; +export function openClusterWebview( + initialData: ClusterViewWebviewConfigurationType, +): AppWebviewController { + const title: string = initialData.clusterDisplayName; - super(ext.context, title, 'clusterView', initialData, vscode.ViewColumn.One, { + const trpcContext: RouterContext = { + dbExperience: API.DocumentDB, + webviewName: 'clusterView', + clusterId: initialData.clusterId, + clusterDisplayName: initialData.clusterDisplayName, + viewId: initialData.viewId, + clusterTreeId: initialData.clusterTreeId, + }; + + return openAppWebview({ + title, + webviewName: 'clusterView', + config: initialData, + context: trpcContext, + viewColumn: vscode.ViewColumn.One, + icon: { light: vscode.Uri.joinPath( ext.context.extensionUri, 'resources', @@ -49,17 +65,6 @@ export class ClusterViewController extends WebviewControllerBase { - const { trpcClient } = useTrpcClient(); + const trpcClient = useTrpcClient(); const [names, setNames] = useState>([]); const [isLoading, setIsLoading] = useState(true); diff --git a/src/webviews/documentdb/clusterView/components/DatabaseList.tsx b/src/webviews/documentdb/clusterView/components/DatabaseList.tsx index 5998b5a05..a1cf5c2ea 100644 --- a/src/webviews/documentdb/clusterView/components/DatabaseList.tsx +++ b/src/webviews/documentdb/clusterView/components/DatabaseList.tsx @@ -31,7 +31,7 @@ export interface DatabaseListProps { * `listDatabases` call, then fills metrics in per row via {@link useBoundedMetrics}. */ export const DatabaseList = ({ onOpenDatabase }: DatabaseListProps): JSX.Element => { - const { trpcClient } = useTrpcClient(); + const trpcClient = useTrpcClient(); const [names, setNames] = useState>([]); const [isLoading, setIsLoading] = useState(true);