diff --git a/.github/ISSUE_TEMPLATE/accessibility-review.md b/.github/ISSUE_TEMPLATE/accessibility-review.md new file mode 100644 index 000000000..e2b3d3886 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/accessibility-review.md @@ -0,0 +1,21 @@ +--- +name: Accessibility Review +about: Accessibility Issue Template +title: '' +labels: a11y +assignees: '' +--- + +## User Experience: + +Note: Login Credentials must not be provided within the bug. + +## Repro Steps + +## Actual Result: + +## Expected Result: + +## Priority + +Use labels `P1`, `P2`, or `P3` to define the priority of this issue. diff --git a/.gitignore b/.gitignore index 6d3144e39..7bc4f395e 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ # Build results [Dd]ebug/ +!resources/debug/ [Dd]ebugPublic/ [Rr]elease/ [Rr]eleases/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 41eb91ed4..01e04d928 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Change Log +## 0.6.0 + +### New Features & Improvements + +- **Query Insights with Performance Advisor**: Introduces a new "Query Insights" tab that provides a three-stage analysis of query performance. This includes a static query plan, detailed execution statistics, and AI-powered recommendations from GitHub Copilot to help understand performance bottlenecks and optimize slow queries. +- **Improved Query Specification**: The query editor now supports `projection`, `sort`, `skip`, and `limit` parameters, in addition to `filter`. Autocompletion is also enabled for `projection` and `sort` fields. +- **Index Management from the Tree View**: Users can now `drop`, `hide`, and `unhide` indexes directly from the context menu in the Connections View. +- **Azure Cosmos DB for MongoDB (vCore)** is now **Azure DocumentDB**: Renamed the service in the UI and in the documentation. + +### Fixes + +- **UI Element Visibility**: Fixed issues where the autocomplete list in the query editor and tooltips in tree/table views could be hidden by other UI elements. + ## 0.5.2 ### New Features & Improvements diff --git a/docs/design-documents/PLAN_INDEX_COMMANDS.md b/docs/design-documents/PLAN_INDEX_COMMANDS.md new file mode 100644 index 000000000..5d3771238 --- /dev/null +++ b/docs/design-documents/PLAN_INDEX_COMMANDS.md @@ -0,0 +1,782 @@ +# Implementation Plan: Index Management Commands + +## Overview + +Implement three new index management commands for MongoDB collections: + +- `vscode-documentdb.command.hideIndex` - Hide an index from query planner +- `vscode-documentdb.command.dropIndex` - Delete an index +- `vscode-documentdb.command.unhideIndex` - Unhide a previously hidden index + +## Background: Command Architecture Investigation + +### 1. Command Registration in `package.json` + +Commands are registered in three sections of `package.json`: + +#### A. **Command Declaration** (`contributes.commands`) + +```json +{ + "//": "Delete Collection", + "category": "DocumentDB", + "command": "vscode-documentdb.command.dropCollection", + "title": "Delete Collection…" +} +``` + +#### B. **Context Menu Integration** (`contributes.menus.view/item/context`) + +```json +{ + "//": "[Collection] Drop collection", + "command": "vscode-documentdb.command.dropCollection", + "when": "view =~ /connectionsView|discoveryView|azure(ResourceGroups|FocusView)/ && viewItem =~ /\\btreeitem_collection\\b/i && viewItem =~ /\\bexperience_(documentDB|mongoRU)\\b/i", + "group": "3@1" +} +``` + +**Key elements:** + +- `when` clause determines visibility based on: + - `view` - which tree view is active + - `viewItem` - matches context value from tree item (uses regex) +- `group` - controls menu section and ordering (`section@priority`) + +#### C. **Command Palette Hiding** (`contributes.menus.commandPalette`) + +```json +{ + "command": "vscode-documentdb.command.dropCollection", + "when": "never" +} +``` + +This prevents the command from appearing in the Command Palette (Ctrl+Shift+P). + +### 2. Command Registration in Code + +**Location:** `src/documentdb/ClustersExtension.ts` + +```typescript +registerCommandWithTreeNodeUnwrapping('vscode-documentdb.command.dropCollection', deleteCollection); +``` + +**Two registration methods:** + +- `registerCommand` - Direct command registration +- `registerCommandWithTreeNodeUnwrapping` - Automatically unwraps tree node from arguments + +### 3. Command Folder Structure + +Commands are organized in `src/commands/` with one folder per command: + +``` +src/commands/ + deleteCollection/ + deleteCollection.ts # Command implementation +``` + +**Convention:** + +- Folder name matches the command action (e.g., `deleteCollection`) +- Main file has same name as folder +- Each command is a self-contained module + +### 4. `deleteCollection` Implementation Analysis + +**File:** `src/commands/deleteCollection/deleteCollection.ts` + +**Key patterns:** + +#### A. Function Signature + +```typescript +export async function deleteCollection(context: IActionContext, node: CollectionItem): Promise; +``` + +- Takes `IActionContext` for telemetry +- Takes tree node as second parameter (unwrapped automatically) + +#### B. Validation + +```typescript +if (!node) { + throw new Error(l10n.t('No node selected.')); +} +``` + +#### C. Telemetry + +```typescript +context.telemetry.properties.experience = node.experience.api; +``` + +#### D. Confirmation Dialog + +```typescript +const confirmed = await getConfirmationAsInSettings( + l10n.t('Delete "{nodeName}"?', { nodeName: node.collectionInfo.name }), + message + '\n' + l10n.t('This cannot be undone.'), + node.collectionInfo.name, // Word to type for confirmation +); + +if (!confirmed) { + return; +} +``` + +**Three confirmation styles** (user-configurable): + +1. **Word Confirmation** - User types the name +2. **Challenge Confirmation** - User solves a math problem +3. **Button Confirmation** - Simple Yes/No buttons + +#### E. Progress Indicator with `showDeleting` + +```typescript +const client = await ClustersClient.getClient(node.cluster.id); + +let success = false; +await ext.state.showDeleting(node.id, async () => { + success = await client.dropCollection(node.databaseInfo.name, node.collectionInfo.name); +}); +``` + +**What `showDeleting` does:** + +- Sets a temporary "description" on the tree item showing "Deleting..." +- Executes the async operation +- Automatically clears the description when done +- Part of `TreeElementStateManager` from `@microsoft/vscode-azext-utils` + +#### F. Success Message + +```typescript +if (success) { + showConfirmationAsInSettings(successMessage); +} +``` + +#### G. Tree Refresh + +```typescript +finally { + const lastSlashIndex = node.id.lastIndexOf('/'); + let parentId = node.id; + if (lastSlashIndex !== -1) { + parentId = parentId.substring(0, lastSlashIndex); + } + ext.state.notifyChildrenChanged(parentId); +} +``` + +Notifies the parent node to refresh its children. + +### 5. Index Tree Item Context + +**File:** `src/tree/documentdb/IndexItem.ts` + +**Context Value:** + +```typescript +public contextValue: string = 'treeItem_index'; +private readonly experienceContextValue: string = ''; + +constructor(...) { + this.experienceContextValue = `experience_${this.experience.api}`; + this.contextValue = createContextValue([this.contextValue, this.experienceContextValue]); +} +``` + +**Result:** `treeItem_index experience_documentDB` (or `experience_mongoRU`) + +**Index Information Available:** + +```typescript +readonly indexInfo: IndexItemModel { + name: string; + type: 'traditional' | 'search'; + key?: { [key: string]: number | string }; + version?: number; + unique?: boolean; + sparse?: boolean; + background?: boolean; + hidden?: boolean; // ← Important for hide/unhide + expireAfterSeconds?: number; + partialFilterExpression?: Document; + // ... +} +``` + +### 6. ClustersClient API Methods + +**File:** `src/documentdb/ClustersClient.ts` + +**Available methods:** + +```typescript +async hideIndex(databaseName: string, collectionName: string, indexName: string): Promise + +async dropIndex(databaseName: string, collectionName: string, indexName: string): Promise + +async unhideIndex(databaseName: string, collectionName: string, indexName: string): Promise +``` + +--- + +## Implementation Plan + +### Phase 1: Command Structure Setup + +#### 1.1 Create Command Folders and Files + +Create three new command folders with implementations: + +``` +src/commands/ + hideIndex/ + hideIndex.ts + dropIndex/ + dropIndex.ts + unhideIndex/ + unhideIndex.ts +``` + +#### 1.2 Update `package.json` - Commands Section + +Add three command declarations in `contributes.commands`: + +```json +{ + "//": "Hide Index", + "category": "DocumentDB", + "command": "vscode-documentdb.command.hideIndex", + "title": "Hide Index…" +}, +{ + "//": "Delete Index", + "category": "DocumentDB", + "command": "vscode-documentdb.command.dropIndex", + "title": "Delete Index…" +}, +{ + "//": "Unhide Index", + "category": "DocumentDB", + "command": "vscode-documentdb.command.unhideIndex", + "title": "Unhide Index" +} +``` + +#### 1.3 Update `package.json` - Context Menu Section + +Add menu items in `contributes.menus.view/item/context` for index items: + +```json +{ + "//": "[Index] Hide Index", + "command": "vscode-documentdb.command.hideIndex", + "when": "view =~ /connectionsView|discoveryView|azure(ResourceGroups|FocusView)/ && viewItem =~ /\\btreeitem_index\\b/i && viewItem =~ /\\bexperience_(documentDB|mongoRU)\\b/i", + "group": "2@1" +}, +{ + "//": "[Index] Unhide Index", + "command": "vscode-documentdb.command.unhideIndex", + "when": "view =~ /connectionsView|discoveryView|azure(ResourceGroups|FocusView)/ && viewItem =~ /\\btreeitem_index\\b/i && viewItem =~ /\\bexperience_(documentDB|mongoRU)\\b/i", + "group": "2@2" +}, +{ + "//": "[Index] Delete Index", + "command": "vscode-documentdb.command.dropIndex", + "when": "view =~ /connectionsView|discoveryView|azure(ResourceGroups|FocusView)/ && viewItem =~ /\\btreeitem_index\\b/i && viewItem =~ /\\bexperience_(documentDB|mongoRU)\\b/i", + "group": "3@1" +} +``` + +**Note:** Group `2@x` for hide/unhide (modification), group `3@x` for delete (destructive). + +#### 1.4 Update `package.json` - Command Palette Hiding + +Add entries to hide commands from Command Palette: + +```json +{ + "command": "vscode-documentdb.command.hideIndex", + "when": "never" +}, +{ + "command": "vscode-documentdb.command.dropIndex", + "when": "never" +}, +{ + "command": "vscode-documentdb.command.unhideIndex", + "when": "never" +} +``` + +### Phase 2: Command Implementation + +#### 2.1 Implement `hideIndex.ts` + +**File:** `src/commands/hideIndex/hideIndex.ts` + +```typescript +/*--------------------------------------------------------------------------------------------- + * 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 { ClustersClient } from '../../documentdb/ClustersClient'; +import { ext } from '../../extensionVariables'; +import { type IndexItem } from '../../tree/documentdb/IndexItem'; +import { getConfirmationAsInSettings } from '../../utils/dialogs/getConfirmation'; +import { showConfirmationAsInSettings } from '../../utils/dialogs/showConfirmation'; + +export async function hideIndex(context: IActionContext, node: IndexItem): Promise { + if (!node) { + throw new Error(l10n.t('No index selected.')); + } + + context.telemetry.properties.experience = node.experience.api; + context.telemetry.properties.indexName = node.indexInfo.name; + + // Prevent hiding the _id index + if (node.indexInfo.name === '_id_') { + throw new Error(l10n.t('The _id index cannot be hidden.')); + } + + // Check if already hidden + if (node.indexInfo.hidden) { + throw new Error(l10n.t('Index "{indexName}" is already hidden.', { indexName: node.indexInfo.name })); + } + + const message = l10n.t( + 'Hide index "{indexName}" from collection "{collectionName}"? This will prevent the query planner from using this index.', + { + indexName: node.indexInfo.name, + collectionName: node.collectionInfo.name, + }, + ); + const successMessage = l10n.t('Index "{indexName}" has been hidden.', { indexName: node.indexInfo.name }); + + const confirmed = await getConfirmationAsInSettings( + l10n.t('Hide index "{indexName}"?', { indexName: node.indexInfo.name }), + message, + node.indexInfo.name, + ); + + if (!confirmed) { + return; + } + + try { + const client = await ClustersClient.getClient(node.cluster.id); + + let result: Document | null = null; + await ext.state.showUpdating(node.id, async () => { + result = await client.hideIndex(node.databaseInfo.name, node.collectionInfo.name, node.indexInfo.name); + }); + + if (result) { + showConfirmationAsInSettings(successMessage); + } + } finally { + // Refresh parent (collection's indexes folder) + const lastSlashIndex = node.id.lastIndexOf('/'); + let parentId = node.id; + if (lastSlashIndex !== -1) { + parentId = parentId.substring(0, lastSlashIndex); + } + ext.state.notifyChildrenChanged(parentId); + } +} +``` + +**Key differences from deleteCollection:** + +- Requires confirmation (user must confirm the action) +- Uses `ext.state.showUpdating` instead of `showDeleting` +- Validates that index is not already hidden +- Prevents hiding `_id_` index + +#### 2.2 Implement `unhideIndex.ts` + +**File:** `src/commands/unhideIndex/unhideIndex.ts` + +```typescript +/*--------------------------------------------------------------------------------------------- + * 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 { ClustersClient } from '../../documentdb/ClustersClient'; +import { ext } from '../../extensionVariables'; +import { type IndexItem } from '../../tree/documentdb/IndexItem'; +import { getConfirmationAsInSettings } from '../../utils/dialogs/getConfirmation'; +import { showConfirmationAsInSettings } from '../../utils/dialogs/showConfirmation'; + +export async function unhideIndex(context: IActionContext, node: IndexItem): Promise { + if (!node) { + throw new Error(l10n.t('No index selected.')); + } + + context.telemetry.properties.experience = node.experience.api; + context.telemetry.properties.indexName = node.indexInfo.name; + + // Check if index is actually hidden + if (!node.indexInfo.hidden) { + throw new Error(l10n.t('Index "{indexName}" is not hidden.', { indexName: node.indexInfo.name })); + } + + const message = l10n.t( + 'Unhide index "{indexName}" from collection "{collectionName}"? This will allow the query planner to use this index again.', + { + indexName: node.indexInfo.name, + collectionName: node.collectionInfo.name, + }, + ); + const successMessage = l10n.t('Index "{indexName}" has been unhidden.', { indexName: node.indexInfo.name }); + + const confirmed = await getConfirmationAsInSettings( + l10n.t('Unhide index "{indexName}"?', { indexName: node.indexInfo.name }), + message, + node.indexInfo.name, + ); + + if (!confirmed) { + return; + } + + try { + const client = await ClustersClient.getClient(node.cluster.id); + + let result: Document | null = null; + await ext.state.showUpdating(node.id, async () => { + result = await client.unhideIndex(node.databaseInfo.name, node.collectionInfo.name, node.indexInfo.name); + }); + + if (result) { + showConfirmationAsInSettings(successMessage); + } + } finally { + // Refresh parent (collection's indexes folder) + const lastSlashIndex = node.id.lastIndexOf('/'); + let parentId = node.id; + if (lastSlashIndex !== -1) { + parentId = parentId.substring(0, lastSlashIndex); + } + ext.state.notifyChildrenChanged(parentId); + } +} +``` + +#### 2.3 Implement `dropIndex.ts` + +**File:** `src/commands/dropIndex/dropIndex.ts` + +```typescript +/*--------------------------------------------------------------------------------------------- + * 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 { ClustersClient } from '../../documentdb/ClustersClient'; +import { ext } from '../../extensionVariables'; +import { type IndexItem } from '../../tree/documentdb/IndexItem'; +import { getConfirmationAsInSettings } from '../../utils/dialogs/getConfirmation'; +import { showConfirmationAsInSettings } from '../../utils/dialogs/showConfirmation'; + +export async function dropIndex(context: IActionContext, node: IndexItem): Promise { + if (!node) { + throw new Error(l10n.t('No index selected.')); + } + + context.telemetry.properties.experience = node.experience.api; + context.telemetry.properties.indexName = node.indexInfo.name; + + // Prevent deleting the _id index + if (node.indexInfo.name === '_id_') { + throw new Error(l10n.t('The _id index cannot be deleted.')); + } + + const message = l10n.t('Delete index "{indexName}" from collection "{collectionName}"?', { + indexName: node.indexInfo.name, + collectionName: node.collectionInfo.name, + }); + const successMessage = l10n.t('Index "{indexName}" has been deleted.', { indexName: node.indexInfo.name }); + + const confirmed = await getConfirmationAsInSettings( + l10n.t('Delete index "{indexName}"?', { indexName: node.indexInfo.name }), + message + '\n' + l10n.t('This cannot be undone.'), + node.indexInfo.name, + ); + + if (!confirmed) { + return; + } + + try { + const client = await ClustersClient.getClient(node.cluster.id); + + let result: { ok: number } | null = null; + await ext.state.showDeleting(node.id, async () => { + const dropResult = await client.dropIndex(node.databaseInfo.name, node.collectionInfo.name, node.indexInfo.name); + result = dropResult.ok ? { ok: 1 } : null; + }); + + if (result && result.ok === 1) { + showConfirmationAsInSettings(successMessage); + } + } finally { + // Refresh parent (collection's indexes folder) + const lastSlashIndex = node.id.lastIndexOf('/'); + let parentId = node.id; + if (lastSlashIndex !== -1) { + parentId = parentId.substring(0, lastSlashIndex); + } + ext.state.notifyChildrenChanged(parentId); + } +} +``` + +**Key features:** + +- Requires confirmation (destructive action) +- Uses `ext.state.showDeleting` for progress +- Prevents deleting `_id_` index +- Uses confirmation word matching the index name + +### Phase 3: Command Registration + +#### 3.1 Update `ClustersExtension.ts` + +**File:** `src/documentdb/ClustersExtension.ts` + +Add imports at the top: + +```typescript +import { dropIndex } from '../commands/dropIndex/dropIndex'; +import { hideIndex } from '../commands/hideIndex/hideIndex'; +import { unhideIndex } from '../commands/unhideIndex/unhideIndex'; +``` + +Add registrations (around line 284, near other command registrations): + +```typescript +registerCommandWithTreeNodeUnwrapping('vscode-documentdb.command.hideIndex', hideIndex); +registerCommandWithTreeNodeUnwrapping('vscode-documentdb.command.unhideIndex', unhideIndex); +registerCommandWithTreeNodeUnwrapping('vscode-documentdb.command.dropIndex', dropIndex); +``` + +### Phase 4: Progress Indicator Enhancement + +#### 4.1 Verify `TreeElementStateManager` Methods + +Check if `showUpdating` exists. If not, use alternatives: + +**Option A:** Use `showUpdating` if available: + +```typescript +await ext.state.showUpdating(node.id, async () => { + // operation +}); +``` + +**Option B:** Use `showCreatingChild` or custom description: + +```typescript +await ext.state.showCreatingChild(node.id, 'Hiding index...', async () => { + // operation +}); +``` + +**Option C:** Manual description management: + +```typescript +try { + ext.state.notifyChangedData(node.id, { description: 'Hiding...' }); + await client.hideIndex(...); +} finally { + ext.state.notifyChangedData(node.id, { description: undefined }); +} +``` + +### Phase 5: Smart Context Menu (Optional Enhancement) + +#### 5.1 Dynamic Menu Visibility + +To show only relevant commands (hide when hidden, unhide when not hidden): + +**Add to `IndexItem.ts`:** + +```typescript +constructor(...) { + // Existing code... + + // Add hidden state to context + if (this.indexInfo.hidden) { + this.contextValue = createContextValue([ + this.contextValue, + this.experienceContextValue, + 'hidden' + ]); + } +} +``` + +**Update `package.json` menus:** + +```json +{ + "//": "[Index] Hide Index", + "command": "vscode-documentdb.command.hideIndex", + "when": "view =~ /connectionsView|discoveryView/ && viewItem =~ /\\btreeitem_index\\b/i && !(viewItem =~ /\\bhidden\\b/i)", + "group": "2@1" +}, +{ + "//": "[Index] Unhide Index", + "command": "vscode-documentdb.command.unhideIndex", + "when": "view =~ /connectionsView|discoveryView/ && viewItem =~ /\\btreeitem_index\\b/i && viewItem =~ /\\bhidden\\b/i", + "group": "2@1" +} +``` + +This ensures: + +- "Hide Index" only shows when index is NOT hidden +- "Unhide Index" only shows when index IS hidden + +### Phase 6: Testing Checklist + +#### 6.1 Manual Testing + +Test each command with: + +- ✅ Regular index (non-\_id) +- ✅ Hidden index (for unhide) +- ✅ Visible index (for hide) +- ✅ \_id index (should fail appropriately) +- ✅ MongoDB RU experience +- ✅ DocumentDB experience + +#### 6.2 Confirmation Styles + +Test dropIndex with all three confirmation styles: + +- ✅ Word Confirmation +- ✅ Challenge Confirmation +- ✅ Button Confirmation + +#### 6.3 Progress Indicators + +Verify: + +- ✅ "Deleting..." appears during dropIndex +- ✅ "Updating..." (or equivalent) appears during hide/unhide +- ✅ Description clears after operation +- ✅ Tree refreshes automatically + +#### 6.4 Error Cases + +Test: + +- ✅ Cancel confirmation (dropIndex) +- ✅ Hide already hidden index +- ✅ Unhide already visible index +- ✅ Try to hide/drop \_id index +- ✅ Network/connection errors + +### Phase 7: Documentation + +#### 7.1 Add to l10n + +Run localization extraction: + +```bash +npm run l10n +``` + +This will extract all `l10n.t()` strings to `l10n/bundle.l10n.json`. + +#### 7.2 Update CHANGELOG.md + +Add entry: + +```markdown +### Added + +- Index management commands: Hide Index, Unhide Index, Delete Index +- Context menu options on index items for index operations +- Progress indicators during index operations +``` + +--- + +## Summary of Files to Create/Modify + +### New Files (3) + +1. `src/commands/hideIndex/hideIndex.ts` +2. `src/commands/unhideIndex/unhideIndex.ts` +3. `src/commands/dropIndex/dropIndex.ts` + +### Modified Files (3) + +1. `package.json` - Add command declarations, menus, and command palette hiding +2. `src/documentdb/ClustersExtension.ts` - Register commands +3. `src/tree/documentdb/IndexItem.ts` - (Optional) Add hidden state to context value + +### Generated Files (1) + +1. `l10n/bundle.l10n.json` - Updated via `npm run l10n` + +--- + +## Command Summary Table + +| Command | Confirmation | Progress State | Reversible | Destructive | Can Apply to \_id | +| ----------- | ------------ | -------------- | ---------- | ----------- | --------------------- | +| hideIndex | Yes | "Updating..." | Yes | No | No | +| unhideIndex | Yes | "Updating..." | Yes | No | N/A (can't hide \_id) | +| dropIndex | Yes | "Deleting..." | No | Yes | No | + +--- + +## Implementation Order + +1. ✅ Create command files (hideIndex, unhideIndex, dropIndex) +2. ✅ Update package.json (commands, menus, commandPalette) +3. ✅ Register commands in ClustersExtension.ts +4. ✅ Test basic functionality +5. ✅ Add smart context menu (optional) +6. ✅ Run l10n extraction +7. ✅ Update CHANGELOG.md +8. ✅ Full testing cycle + +--- + +## Notes for Implementation + +- **Use `nonNullProp` and `nonNullValue`** helpers from project guidelines for null safety +- **Follow TypeScript strict mode** - no `any` types +- **Use `l10n.t()` for all user-facing strings** for localization +- **Telemetry properties** should include `experience` and `indexName` +- **Error messages** should be clear and actionable +- **Progress indicators** improve UX for potentially slow operations +- **Tree refresh** is critical to show updated state + +--- + +## Future Enhancements (Not in This Plan) + +- Bulk operations (hide/delete multiple indexes) +- Index rebuild command +- Index statistics visualization +- Index usage recommendations +- Visual index builder/editor diff --git a/docs/design-documents/performance-advisor.md b/docs/design-documents/performance-advisor.md new file mode 100644 index 000000000..97dbbe6da --- /dev/null +++ b/docs/design-documents/performance-advisor.md @@ -0,0 +1,481 @@ +# Query Insights Tab & Advisor + +This document outlines the structure and data presented in the **Query Insights** tab for query analysis within the DocumentDB VS Code extension. + +--- + +## 1. Overview + +When a user executes a `find` (and other explainable read commands), a **Query Insights** tab appears alongside **Results**. The feature is organized in **three stages**: + +1. **Initial View (cheap data + plan)** + Show a **Summary Bar** with immediate, low-cost metrics (client timing, docs returned) and parse the **query planner** via `explain("queryPlanner")`. No re-execution. +2. **Detailed Execution Analysis (cancellable)** + Run `explain("executionStats")` to gather authoritative counts and timing. Populate the Summary Bar with examined counts and render per-shard stage details. +3. **AI-Powered Advisor (opt-in)** + Send the collected statistics (shape + metrics) to an AI service for actionable recommendations. + +> **Note on Aggregation Pipelines** +> The **API** returns explain data for aggregation pipelines in a structure that differs from `find`. We will document pipeline handling separately when we implement pipeline insights. This document focuses on `find` and similarly structured read commands. + +--- + +## 2. Stage 1: Initial Performance View (Cheap Data + Query Plan) + +Populated as soon as the query finishes, using fast signals plus `explain("queryPlanner")`. No full re-execution. + +### 2.1. Metrics Row (Top Area) + +> **Always visible**, displayed as individual metric cards. For **sharded** queries, aggregate across shards; for **non-sharded**, show single values. + +**Metrics displayed:** + +- **Execution Time** — Shows server timing from Stage 2 (e.g., `180 ms`) +- **Documents Returned** — Count of documents in result set (e.g., `100`) +- **Keys Examined** — Shows `n/a` until Stage 2, then actual count (e.g., `100`) +- **Docs Examined** — Shows `n/a` until Stage 2, then actual count (e.g., `100`) + +**Aggregation rules (when sharded):** + +- `KeysExamined = Σ shard.totalKeysExamined` +- `DocsExamined = Σ shard.totalDocsExamined` +- `Documents Returned`: prefer top-level `nReturned` once Stage 2 runs; before that, use the count in **Results**. +- `Execution Time`: prefer `executionTimeMillis` (Stage 2); otherwise client timing. + +**Stage 1 — Metrics display** + +All metrics shown in individual metric cards. Initially: + +- Execution Time: Shows client timing (e.g., `180 ms`) +- Documents Returned: Shows count from results (e.g., `100`) +- Keys Examined: `n/a` +- Docs Examined: `n/a` + +### 2.2. Query Plan Summary (Planner-only) + +Built from `explain("queryPlanner")`. This is **fast** and does **not** execute the plan. Use it to populate concise UI without runtime stats. + +#### What we show (planner-only) + +- **Winning Plan** — the **full logical plan tree** chosen by the planner (not just a single stage). +- **Rejected Plans** — a count, if exists +- **Targeting (sharded)** — which shards are listed in `shards[]`. +- **Execution Time (client)** — end-to-end as measured by the client (the planner has no server timing). + +#### Non-sharded example (planner-only) + +**Winning plan (snippet)** + +```json +{ + "queryPlanner": { + "parsedQuery": { "status": { "$eq": "PENDING" } }, + "winningPlan": { + "stage": "PROJECTION", + "inputStage": { + "stage": "FETCH", + "inputStage": { + "stage": "IXSCAN", + "indexName": "status_1", + "keyPattern": { "status": 1 }, + "indexBounds": { "status": ["[\"PENDING\",\"PENDING\"]"] } + } + } + }, + "rejectedPlans": [{ "planSummary": "COLLSCAN" }] + } +} +``` + +**UI from this plan** + +- **Winning Plan:** `IXSCAN → FETCH → PROJECTION` +- **Rejected Plans:** `1 other plan considered` (`COLLSCAN`) + +#### Sharded example (planner-only) + +**Targeting & per-shard plans (snippet)** + +```json +{ + "queryPlanner": { + "winningPlan": { + "stage": "SHARD_MERGE", + "inputStages": [ + { + "shardName": "shardA", + "winningPlan": { + "stage": "FETCH", + "inputStage": { + "stage": "IXSCAN", + "indexName": "status_1", + "indexBounds": { "status": ["[\"PENDING\",\"PENDING\"]"] } + } + } + }, + { + "shardName": "shardB", + "winningPlan": { + "stage": "COLLSCAN", + "filter": { "status": { "$eq": "PENDING" } } + } + } + ] + }, + "rejectedPlans": [] + } +} +``` + +**UI from this plan** + +- **Targeting:** `shards[] = [shardA, shardB]` +- **Merge Summary:** top node `SHARD_MERGE` (results will be merged). +- **Per-shard Winning Plans:** + - **shardA:** `IXSCAN → FETCH` + - **shardB:** `COLLSCAN` **(badge)** +- **Rejected Plans:** `0` + +#### Answers to common UI questions (planner-only) (these are actualy relevant to stage 2) + +- **Is “Winning Plan” the whole plan or just `IXSCAN`?** + It’s the **whole plan tree** chosen by the planner. Render it as a sequential spine; `IXSCAN` is the access stage within that plan. + +- **How many plans were considered? Do we know why rejected?** + The planner returns a **list/count of `rejectedPlans`** (summaries only). No per-plan runtime stats or explicit rejection reasons in planner-only mode. + +- **Are the index bounds good or bad?** + **Good bounds** are narrow and specific, minimizing the keys scanned. An equality match like `status: ["PENDING", "PENDING"]` is very efficient. **Bad bounds** are wide or unbounded, forcing a large index scan. For example, `status: ["[MinKey", "MaxKey"]` means the entire index is scanned, which offers no filtering advantage and is often a sign that the index is only being used for sorting. Flag this as **Unbounded bounds**. + +- **Why is an in-memory sort bad?** + A `SORT` stage means results are sorted outside index order, which is typically slower and memory-heavy. If a `SORT` stage is present in the plan (or implied by the requested sort without an index), flag **Blocked sort**; confirm in Stage 2. + +- **How to know if no `FETCH` is expected (index-only/covering)?** + If the winning path **does not include `FETCH`** and the projection uses only indexed fields, mark **Index-only**. Confirm in Stage 2 (executed plan). + +### 2.3. Query Efficiency Analysis Card + +> **PENDING**: This card is implemented in the UI mock but needs data model design. + +A summary card displaying high-level query efficiency metrics: + +- **Execution Strategy** — The top-level stage type (e.g., `COLLSCAN`, `IXSCAN`, `SHARD_MERGE`) +- **Index Used** — Name of the index if IXSCAN, otherwise "None" +- **Examined/Returned Ratio** — Formatted ratio (e.g., `5,000 : 1`) +- **In-Memory Sort** — Yes/No indicator if SORT stage is present +- **Performance Rating** — Visual rating (Good/Fair/Poor) with description based on efficiency metrics + +### 2.4. Call to Action + +> **[Button] Run Detailed Analysis** +> +> _Runs `explain("executionStats")` to populate examined counts, timing, and per-stage stats._ +> +> **Note**: In the current implementation, Stage 2 analysis starts automatically after Stage 1 completes. + +--- + +## 3. Stage 2: Detailed Execution Analysis (executionStats) + +Built from `explain("executionStats")`. Executes the winning plan to completion (respecting `limit/skip`) and returns **authoritative runtime metrics**. + +### 3.1. Metrics Row (now authoritative) + +Replace `n/a` with real values from `executionStats`. + +**Stage 2 — Metrics display** + +All metrics updated with authoritative server data: + +- **Execution Time:** Server-reported `executionTimeMillis` (e.g., `120 ms`) +- **Documents Returned:** From `nReturned` (e.g., `100`) +- **Keys Examined:** From `totalKeysExamined` (e.g., `100`) +- **Docs Examined:** From `totalDocsExamined` (e.g., `100`) + +### 3.2. Query Efficiency Analysis Card (now populated) + +The Query Efficiency Analysis card is updated with real execution data: + +- **Execution Strategy:** Extracted from top-level stage (e.g., `COLLSCAN`, `IXSCAN`) +- **Index Used:** From IXSCAN stage's `indexName` or "None" +- **Examined/Returned Ratio:** Calculated as `DocsExamined : Returned` (e.g., `5,000 : 1`) +- **In-Memory Sort:** Detected from presence of SORT stage +- **Performance Rating:** Calculated based on examined/returned ratio: + - Good: < 10:1 + - Fair: 10:1 to 100:1 + - Poor: > 100:1 + +### 3.3. Execution details (what we extract) + +- **Execution Time** — server-reported `executionTimeMillis` (prefer this over client time). +- **nReturned** — actual output at the root (and per shard, when available). +- **totalDocsExamined / totalKeysExamined** — totals (and per shard). +- **DocsExamined / Returned ratio** — efficiency signal (warn > 100, danger > 1000). +- **Per-stage counters** — from `executionStages`, including `keysExamined`, `docsExamined`, `nReturned` at each stage. +- **Sort & memory** — if a `SORT` stage indicates in-memory work or spill, surface it. +- **Covering** — confirm **no `FETCH`** in the executed path when index-only. +- **Sharded attribution** — a **per-shard overview** row (keys, docs, returned, time) with badges, plus aggregated totals. + +**PENDING/EVALUATING**: The following features from the original design are not yet in the UI mock: + +- Per-shard breakdown visualization (sharded queries) +- Rejected plans count display +- Detailed per-stage counters view (currently shows high-level stage flow) +- Badges for specific issues (COLLSCAN, Blocked sort, Inefficient, Spilled, Unbounded bounds, Fetch heavy) + +### 3.3. Non-sharded example (executionStats) + +**Execution summary (snippet)** + +```json +{ + "executionStats": { + "nReturned": 100, + "executionTimeMillis": 120, + "totalKeysExamined": 100, + "totalDocsExamined": 100, + "executionStages": { + "stage": "PROJECTION", + "nReturned": 100, + "inputStage": { + "stage": "FETCH", + "nReturned": 100, + "docsExamined": 100, + "inputStage": { + "stage": "IXSCAN", + "indexName": "status_1", + "keysExamined": 100, + "nReturned": 100 + } + } + } + } +} +``` + +**UI from this execution** + +- **Execution Time:** `120 ms` +- **nReturned / Keys / Docs:** `100 / 100 / 100` +- **Docs/Returned:** `1 : 1` (Not covering because `FETCH` exists). +- **Plan confirmation:** `IXSCAN(status_1) → FETCH → PROJECTION` with per-stage counters. +- **Sort path:** no `SORT` node → no blocked sort. + +### 3.4. Sharded example (executionStats) + +**Merged + per-shard stats (snippet)** + +```json +{ + "executionStats": { + "nReturned": 50, + "executionTimeMillis": 1400, + "totalKeysExamined": 8140, + "totalDocsExamined": 9900, + "executionStages": { + "stage": "SHARD_MERGE", + "nReturned": 50, + "inputStages": [ + { + "shardName": "shardA", + "executionStages": { + "stage": "FETCH", + "nReturned": 30, + "docsExamined": 7500, + "inputStage": { + "stage": "IXSCAN", + "indexName": "status_1", + "keysExamined": 6200 + } + } + }, + { + "shardName": "shardB", + "executionStages": { + "stage": "SORT", + "inMemorySort": true, + "nReturned": 20, + "inputStage": { + "stage": "COLLSCAN", + "docsExamined": 2400 + } + } + } + ] + } + } +} +``` + +**UI from this execution** + +- **Execution Time:** `1.4 s` +- **nReturned / Keys / Docs (Σ):** `50 / 8,140 / 9,900` +- **Docs/Returned:** `198 : 1` **(warn)** +- **Per-shard overview:** + - **shardA:** keys `6,200`, docs `7,500`, returned `30` + - **shardB:** keys `0`, docs `2,400`, returned `20`, **COLLSCAN**, **Blocked sort** +- **Merge Summary:** `SHARD_MERGE` (final merge). +- **Attribution:** surface shardB as the bottleneck (sort rows by worst efficiency). + +### 3.5. Answers to common UI questions (executionStats) + +- **Is the winning plan still the whole plan?** + Yes. `executionStages` is the executed **plan tree** with per-stage counters. Render as the same sequential spine (plus optional details toggle). + +- **How many plans were considered? Do we know why rejected?** + `executionStats` covers the **winning plan** only. Use Stage 1’s `rejectedPlans` to show candidate count. If you ever run `allPlansExecution`, you can show per-candidate runtime stats; otherwise there’s **no rejection reason** here. + +- **How to confirm that an index was used?** + Look for **`IXSCAN`** in `executionStages` with a concrete `indexName` and non-zero `keysExamined`. Aggregate across shards where present. + +- **How to confirm a blocked/in-memory sort?** + Presence of a `SORT` stage with indicators like `inMemorySort` or memory metrics confirms sorting outside index order. Badge **Blocked sort** and display any memory/spill hints provided. + +- **How to confirm index-only (no `FETCH`)?** + Verify the executed path **does not contain `FETCH`** and that the projection uses only indexed fields. If true, mark **Index-only** (covering). If a `FETCH` appears, full documents were read. + +- **How to attribute work in sharded scenarios?** + Use per-shard `executionStages` to populate a **per-shard overview list** (keys, docs, returned, time) and compute **aggregated totals** for the Summary Bar. + +### 3.6. Quick Actions + +> **Added in implementation**: A card with action buttons appears after Stage 2 completes. + +**Available actions:** + +- **Export Optimization Opportunities** — Export AI suggestions and recommendations +- **Export Execution Plan Details** — Export the execution statistics +- **View Raw Explain Output** — Show the raw explain command output + +### 3.7. Call to Action + +> **[Button] Get AI Suggestions** +> +> _The collected, non-sensitive query shape and execution statistics will be sent to an AI service to generate performance recommendations. This may take 10-20 seconds._ + +--- + +## 4. Stage 3: AI-Powered Recommendations + +After Stage 2 completes, users can optionally request AI-powered analysis of their query performance. + +### 4.1. Optimization Opportunities Section + +The "Optimization Opportunities" section displays AI-generated suggestions as animated cards: + +**AI Suggestion Cards** include: + +- **Title** — Brief description of the optimization (e.g., "Create Index") +- **Priority Badge** — Optional badge for high-priority issues (e.g., "HIGH PRIORITY") +- **Explanation** — Detailed reasoning based on execution stats +- **Recommended Action** — Specific index definition or query modification +- **Code Snippet** — Copy-able command (e.g., `createIndex` command) +- **Risks** — Potential downsides or considerations +- **Action Buttons** — "Apply", "Copy", "Learn More" + +**Example AI Suggestions:** + +1. **Create Index** (High Priority) + - Identifies COLLSCAN with poor selectivity + - Recommends specific index definition + - Shows before/after execution plan comparison + +2. **No Index Changes Recommended** + - Explains when existing strategy is optimal + - Provides context on selectivity and performance + +3. **Understanding Your Query Execution Plan** + - Educational content explaining how the query executes + - Visual breakdown of execution stages + - Helps users understand the impact of optimizations + +### 4.2. Performance Tips Card + +An optional educational card with general DocumentDB performance best practices: + +- **Use Covered Queries** — Return results from index without fetching documents +- **Optimize Index Strategy** — Compound index best practices +- **Limit Returned Fields** — Use projection to reduce data transfer +- **Monitor Index Usage** — Identify and remove unused indexes + +**Interaction:** + +- Can be dismissed by user +- Appears during AI processing to provide context +- Re-appears if AI suggestions are requested again + +### 4.3. Animation and Loading States + +**Loading experience:** + +- Shows loading indicator on "Get AI Suggestions" button +- Displays Performance Tips card while waiting +- Stagger-animates AI suggestion cards (1 second between each) +- Smooth transitions using CollapseRelaxed animation + +### 4.4. Data Sent to AI Service + +**PENDING/EVALUATING**: Document the exact data structure sent to AI service, including: + +- Query shape (without literal values) +- Execution statistics +- Collection schema information +- Index definitions + +--- + +## 5. Tech Background: Paging and Query Scope + +The current paging implementation in the extension relies on `skip` and `limit` to display results in pages. This approach is practical for some scenarios. For instance, the MongoDB RU (Request Unit) implementation has a cursor that expires after 60 seconds, making it risky to maintain a long-lived cursor for paging. Using `skip` and `limit` provides a stateless and reliable way to handle pagination in such environments. + +However, this presents a challenge for the Query Insights tab. The `explain` plan reflects the query with `skip` and `limit`, which analyzes the performance of fetching a single page, not the overall query. For meaningful performance analysis, the insights should be based on the entire query scope, without the paging modifiers. + +To address this, we should consider one of the following solutions: + +1. **Rebuild Paging Entirely**: We could move to a cursor-based paging system. In this model, we would initiate a cursor for the base query (without `skip` or `limit`) and fetch documents page by page. This way, the `explain` plan would analyze the performance of the full query, providing a more accurate picture. +2. **Run an Unbounded Query for Analysis**: Alternatively, when the performance tab is activated, we could run a separate, unbounded query (without `skip` or `limit`) specifically for `explain("executionStats")`. This would allow us to gather performance metrics for the full query scope while keeping the existing `skip`/`limit` paging for the results view. + +The goal is to ensure that the Query Insights tab always reflects the performance of the "full result" scope, giving users accurate and actionable recommendations. + +--- + +## 6. Failure Scenarios + +If the **API** cannot produce an explain plan for the executed command (e.g., commands that include write stages), show **Not available (`n/a`)** with a brief reason. The Summary Bar still shows **client timing** and **docs returned**; other metrics remain `n/a`. + +--- + +## 7. Appendix — What the UI renders at a glance + +**Updated based on implementation:** + +- **Metrics Row (top, always):** + Individual metric cards for Execution Time, Documents Returned, Keys Examined, Docs Examined + +- **Query Efficiency Analysis Card:** + Execution Strategy, Index Used, Examined/Returned Ratio, In-Memory Sort, Performance Rating (with visual indicator) + +- **Query Plan Summary:** + Sequential stage flow (e.g., IXSCAN → FETCH → PROJECTION) with expandable details for each stage + +- **Optimization Opportunities:** + - GetPerformanceInsightsCard (Stage 2) with loading state + - Animated AI suggestion cards (Stage 3) + - Performance tips card (dismissible) + +- **Quick Actions (Stage 2+):** + Export Optimization Opportunities, Export Execution Plan Details, View Raw Explain Output + +**PENDING/EVALUATING - Not yet in UI mock:** + +- **Per-shard overview list (when sharded):** + For each shard: plan summary, nReturned, keys, docs, time, badges; sorted by worst efficiency. + +- **Per-shard details (expand):** + Linear stage list (breadcrumb rail) with per-stage counters. `$or` appears as a single `OR (n)` item with a flyout of clause mini-paths and clause metrics. Optional "View as tree" toggle for complex shapes. + +- **Badges:** + `COLLSCAN`, `Blocked sort`, `Inefficient (>100:1)`, `Spilled`, `Unbounded bounds`, `Fetch heavy`, `Index-only` (positive). + +- **Rejected plans count** — Not currently displayed in UI diff --git a/docs/design-documents/query-execution-error-handling.md b/docs/design-documents/query-execution-error-handling.md new file mode 100644 index 000000000..68704b4b4 --- /dev/null +++ b/docs/design-documents/query-execution-error-handling.md @@ -0,0 +1,864 @@ +# Query Execution Error Handling in Explain Plans + +## Overview + +This document describes how MongoDB/DocumentDB reports query execution failures in explain plans, how to detect them, and how to surface these errors in the Query Insights UX. + +## Background + +When a query fails during execution (e.g., due to sort memory limits, timeout, resource constraints), MongoDB API still returns an explain plan with `executionStats`, but includes error indicators that must be checked to avoid showing misleading performance metrics. + +### Example: Sort Memory Limit Exceeded + +```javascript +db.movies + .find({ 'imdb.rating': { $ne: null } }) + .sort({ 'imdb.rating': -1, 'imdb.votes': -1 }) + .projection({ title: 1, year: 1, 'imdb.rating': 1 }) + .explain('executionStats'); +``` + +This query fails with: + +> "Sort exceeded memory limit of 33554432 bytes, but did not opt in to external sorting." + +Yet the explain plan contains seemingly valid metrics (`totalDocsExamined: 18830`, `executionTimeMillis: 48`), which could mislead analysis tools into reporting performance issues rather than execution failures. + +## Error States in Explain Plans + +### 1. Top-Level Error Indicators + +MongoDB reports execution errors at the `executionStats` level: + +```typescript +{ + "executionStats": { + "executionSuccess": false, // Primary indicator + "failed": true, // Secondary indicator + "errorMessage": "Sort exceeded memory limit...", + "errorCode": 292, // MongoDB error code + "nReturned": 0, // No results due to failure + "executionTimeMillis": 48, // Time before failure + "totalKeysExamined": 0, + "totalDocsExamined": 18830, // Docs examined before failure + "executionStages": { ... } + } +} +``` + +**Key Fields:** + +- `executionSuccess: boolean` - **Primary check** - `false` indicates query failed +- `failed: boolean` - Secondary indicator (may be present instead of executionSuccess) +- `errorMessage: string` - Human-readable error description from MongoDB +- `errorCode: number` - MongoDB error code (e.g., 292 = sort memory limit) +- `nReturned: number` - Usually 0 for failed queries +- Partial metrics (`totalDocsExamined`, `executionTimeMillis`) - Valid up to failure point + +### 2. Stage-Level Error Propagation + +The `failed: true` flag propagates through execution stages to indicate where the failure occurred: + +```typescript +{ + "executionStages": { + "stage": "PROJECTION_DEFAULT", + "failed": true, // Failed because input stage failed + "nReturned": 0, + "inputStage": { + "stage": "SORT", + "failed": true, // This stage caused the failure + "nReturned": 0, + "sortPattern": { "imdb.rating": -1, "imdb.votes": -1 }, + "memLimit": 33554432, + "inputStage": { + "stage": "COLLSCAN", + // No 'failed' field - this stage completed successfully + "nReturned": 18830, + "docsExamined": 18830 + } + } + } +} +``` + +**Stage Error Pattern:** + +- Failed stages have `failed: true` and `nReturned: 0` +- Ancestor stages inherit `failed: true` +- Descendant stages that completed successfully have no `failed` field +- The deepest stage with `failed: true` is usually the root cause + +### 3. Common Error Codes + +| Error Code | Error Name | Description | Common Causes | +| ---------- | ------------------------------------------ | ----------------------------------------------------- | ---------------------------------------------- | +| 292 | `QueryExceededMemoryLimitNoDiskUseAllowed` | Sort/group exceeded memory limit without allowDiskUse | Large in-memory sorts, no index for sort order | +| 16389 | `PlanExecutorAlwaysFails` | Query planner determined query will always fail | Invalid query structure | +| 50 | `MaxTimeMSExpired` | Query exceeded maxTimeMS limit | Slow query, low timeout threshold | +| 96 | `OperationFailed` | Generic operation failure | Various causes | + +## Current Code Analysis + +### Gap: No Error Detection in ExplainPlanAnalyzer + +The current `ExplainPlanAnalyzer.analyzeExecutionStats()` method does not check for execution errors: + +```typescript +// Current implementation (src/documentdb/queryInsights/ExplainPlanAnalyzer.ts) +public static analyzeExecutionStats(explainResult: Document): ExecutionStatsAnalysis { + const explainPlan = new ExplainPlan(explainResult as any); + + // Extracts metrics WITHOUT checking executionSuccess + const executionTimeMillis = explainPlan.executionTimeMillis ?? 0; + const totalDocsExamined = explainPlan.totalDocsExamined ?? 0; + const nReturned = explainPlan.nReturned ?? 0; + + // Calculates misleading metrics when query failed + const efficiencyRatio = this.calculateEfficiencyRatio(nReturned, totalDocsExamined); + // Returns 0 / 18830 = 0.0, interpreted as "very inefficient" rather than "failed" + + return { + executionTimeMillis, + totalDocsExamined, + nReturned, + efficiencyRatio, + performanceRating: this.calculatePerformanceRating(...), // Misleading rating + // ... missing error state + }; +} +``` + +### Why This Is Problematic + +1. **Misleading Performance Metrics**: A failed query with `nReturned: 0` and `totalDocsExamined: 18830` yields `efficiencyRatio: 0.0`, which appears as "very low efficiency" rather than "execution failed" + +2. **Incorrect Diagnostics**: Performance diagnostics focus on optimization opportunities rather than explaining the actual failure + +3. **Hidden Errors**: Users see performance issues without knowing the query didn't complete + +4. **Confusing AI Recommendations**: Index Advisor tries to optimize a query that fundamentally needs `allowDiskUse: true` or better sort support + +## Proposed Solution + +### 1. Enhanced Error Detection + +Add error state extraction to `ExecutionStatsAnalysis`: + +```typescript +// Enhanced interface +export interface ExecutionStatsAnalysis { + // Existing fields... + executionTimeMillis: number; + totalDocsExamined: number; + totalKeysExamined: number; + nReturned: number; + efficiencyRatio: number; + + // NEW: Error state fields + executionError?: { + failed: true; // Discriminator for error state + executionSuccess: false; // From executionStats.executionSuccess + errorMessage: string; // From executionStats.errorMessage + errorCode?: number; // From executionStats.errorCode + failedStage?: { + // Stage that caused failure + stage: string; // e.g., "SORT" + details?: Record; // Stage-specific info + }; + partialStats: { + // Metrics up to failure point + docsExamined: number; + executionTimeMs: number; + }; + }; + + // Existing fields... + usedIndexes: string[]; + performanceRating: PerformanceRating; // Only meaningful when no error + rawStats: Document; +} +``` + +### 2. Error Extraction Logic + +```typescript +// Enhanced analyzer method +public static analyzeExecutionStats(explainResult: Document): ExecutionStatsAnalysis { + const explainPlan = new ExplainPlan(explainResult as any); + + // STEP 1: Check for execution errors FIRST + const executionStats = explainResult.executionStats as Document | undefined; + const executionError = this.extractExecutionError(executionStats, explainResult); + + // STEP 2: Extract metrics (same as before) + const executionTimeMillis = explainPlan.executionTimeMillis ?? 0; + const totalDocsExamined = explainPlan.totalDocsExamined ?? 0; + const totalKeysExamined = explainPlan.totalKeysExamined ?? 0; + const nReturned = explainPlan.nReturned ?? 0; + + // STEP 3: Calculate efficiency (still useful for partial execution analysis) + const efficiencyRatio = this.calculateEfficiencyRatio(nReturned, totalDocsExamined); + + // ... extract other fields ... + + return { + executionTimeMillis, + totalDocsExamined, + totalKeysExamined, + nReturned, + efficiencyRatio, + executionError, // Include error state + // ... other fields ... + performanceRating: executionError + ? this.createFailedQueryRating(executionError) + : this.calculatePerformanceRating(...), + rawStats: explainResult, + }; +} + +/** + * Extracts execution error information from explain plan + * Returns undefined if query executed successfully + */ +private static extractExecutionError( + executionStats: Document | undefined, + fullExplainResult: Document +): ExecutionStatsAnalysis['executionError'] | undefined { + if (!executionStats) { + return undefined; + } + + // Check primary indicator + const executionSuccess = executionStats.executionSuccess as boolean | undefined; + const failed = executionStats.failed as boolean | undefined; + + // Query succeeded + if (executionSuccess !== false && failed !== true) { + return undefined; + } + + // Query failed - extract error details + const errorMessage = executionStats.errorMessage as string | undefined; + const errorCode = executionStats.errorCode as number | undefined; + + // Find which stage failed + const failedStage = this.findFailedStage( + executionStats.executionStages as Document | undefined + ); + + return { + failed: true, + executionSuccess: false, + errorMessage: errorMessage || 'Query execution failed (no error message provided)', + errorCode, + failedStage, + partialStats: { + docsExamined: (executionStats.totalDocsExamined as number) ?? 0, + executionTimeMs: (executionStats.executionTimeMillis as number) ?? 0, + }, + }; +} + +/** + * Finds the stage where execution failed by traversing the stage tree + * Returns the deepest stage with failed: true + */ +private static findFailedStage( + executionStages: Document | undefined +): { stage: string; details?: Record } | undefined { + if (!executionStages) { + return undefined; + } + + const findFailedInStage = (stage: Document): { stage: string; details?: Record } | undefined => { + const stageName = stage.stage as string | undefined; + const stageFailed = stage.failed as boolean | undefined; + + if (!stageName) { + return undefined; + } + + // Check input stages first (depth-first to find root cause) + if (stage.inputStage) { + const childResult = findFailedInStage(stage.inputStage as Document); + if (childResult) { + return childResult; // Return deepest failed stage + } + } + + if (stage.inputStages && Array.isArray(stage.inputStages)) { + for (const inputStage of stage.inputStages) { + const childResult = findFailedInStage(inputStage as Document); + if (childResult) { + return childResult; + } + } + } + + // If this stage failed and no child failed, this is the root cause + if (stageFailed) { + return { + stage: stageName, + details: this.extractStageErrorDetails(stageName, stage), + }; + } + + return undefined; + }; + + return findFailedInStage(executionStages); +} + +/** + * Extracts relevant error details from a failed stage + */ +private static extractStageErrorDetails( + stageName: string, + stage: Document +): Record | undefined { + switch (stageName) { + case 'SORT': + return { + memLimit: stage.memLimit, + sortPattern: stage.sortPattern, + usedDisk: stage.usedDisk, + }; + case 'GROUP': + return { + maxMemoryUsageBytes: stage.maxMemoryUsageBytes, + }; + default: + return undefined; + } +} + +/** + * Creates a performance rating for a failed query + * This provides clear diagnostics explaining the failure + */ +private static createFailedQueryRating( + error: NonNullable +): PerformanceRating { + const diagnostics: PerformanceDiagnostic[] = []; + + // Primary diagnostic: Query failed + diagnostics.push({ + type: 'negative', + message: 'Query execution failed', + details: `${error.errorMessage}\n\nThe query did not complete successfully. Performance metrics shown are partial and measured up to the failure point.`, + }); + + // Stage-specific diagnostics + if (error.failedStage) { + const stageDiagnostic = this.createStageFailureDiagnostic(error.failedStage, error.errorCode); + if (stageDiagnostic) { + diagnostics.push(stageDiagnostic); + } + } + + return { + score: 'poor', + diagnostics, + }; +} + +/** + * Creates stage-specific diagnostic with actionable guidance + */ +private static createStageFailureDiagnostic( + failedStage: { stage: string; details?: Record }, + errorCode?: number +): PerformanceDiagnostic | undefined { + const { stage, details } = failedStage; + + // Sort memory limit exceeded (Error 292) + if (stage === 'SORT' && errorCode === 292) { + const memLimit = details?.memLimit as number | undefined; + const sortPattern = details?.sortPattern as Document | undefined; + const memLimitMB = memLimit ? (memLimit / (1024 * 1024)).toFixed(1) : 'unknown'; + + return { + type: 'negative', + message: 'Sort exceeded memory limit', + details: `The SORT stage exceeded the ${memLimitMB}MB memory limit.\n\n` + + `**Solutions:**\n` + + `1. Add .allowDiskUse(true) to allow disk-based sorting for large result sets\n` + + `2. Create an index matching the sort pattern: ${JSON.stringify(sortPattern)}\n` + + `3. Add filters to reduce the number of documents being sorted\n` + + `4. Increase server memory limit (requires server configuration)`, + }; + } + + // Generic stage failure + return { + type: 'negative', + message: `${stage} stage failed`, + details: `The ${stage} stage could not complete execution.\n\nReview the error message and query structure for potential issues.`, + }; +} +``` + +### 3. UI/UX Integration + +#### A. Query Insights Display (Stage 2) + +**Current Behavior:** + +- Shows performance rating (poor) +- Shows efficiency ratio (0%) +- Shows "Full collection scan" diagnostic +- User doesn't know query actually failed + +**Proposed Behavior:** + +```typescript +// In collectionViewRouter.ts - getQueryInsightsStage2 +getQueryInsightsStage2: publicProcedure.use(trpcToTelemetry).query(async ({ ctx }) => { + // ... existing code to get analyzed ... + + // Check for execution error BEFORE transformation + if (analyzed.executionError) { + // Return a properly structured QueryInsightsStage2Response + // This maintains UI compatibility while embedding error information + return { + // Standard Stage2Response fields with error indicators + executionTimeMs: analyzed.executionTimeMillis, + totalKeysExamined: analyzed.totalKeysExamined, + totalDocsExamined: analyzed.totalDocsExamined, + documentsReturned: analyzed.nReturned, + examinedToReturnedRatio: /* calculated */, + keysToDocsRatio: /* calculated */, + + // Error information in standard fields + executionStrategy: `Failed: ${analyzed.executionError.failedStage?.stage}`, + concerns: [ + `⚠️ Query Execution Failed: ${analyzed.executionError.errorMessage}`, + `Failed Stage: ${analyzed.executionError.failedStage?.stage}`, + `Error Code: ${analyzed.executionError.errorCode}`, + ], + + // Performance rating with error diagnostics + efficiencyAnalysis: { + performanceRating: analyzed.performanceRating, // Contains error diagnostics + // ... other fields + }, + + // ... remaining Stage2Response fields + }; + } + + // Normal successful execution path + return transformStage2Response(analyzed); +}); +``` + +**Note:** The implementation returns a standard `QueryInsightsStage2Response` for both successful and failed queries. This approach: + +- ✅ Prevents UI TypeErrors by maintaining consistent response shape +- ✅ Embeds error information in existing fields (`concerns`, `executionStrategy`) +- ✅ Uses performance diagnostics to explain the failure +- ✅ Preserves partial execution metrics +- ✅ Requires no UI changes to handle error state (graceful degradation) + +**UI Components:** + +1. **Error Banner** (Top of Query Insights, in the metrics column, just below the metrics, using a card matching the layouts we have for cards like ai insights, or ai card) + + ``` + ⚠️ Query Execution Failed + + Sort exceeded memory limit of 32.0MB, but did not opt in to external sorting. + + The query examined 18,830 documents before failing after 48ms. + + [View Solutions] [See Raw Explain Plan] + ``` + +2. **Solutions Expandable Section** + + ``` + 💡 Solutions + + The SORT stage failed due to memory limits. Try these approaches: + + 1. Enable disk-based sorting + db.movies.find({ "imdb.rating": { $ne: null } }) + .sort({ "imdb.rating": -1, "imdb.votes": -1 }) + .allowDiskUse(true) + + Note: DiskUse is currently unsupported in the DocumentDB for VS Code extension. + + 2. Create an index to avoid in-memory sorting: + db.movies.createIndex({ "imdb.rating": -1, "imdb.votes": -1 }) + + 3. Add filters to reduce documents sorted: + Add .find() filters to limit documents before sorting + ``` + +3. **Execution Stage Visualization** (with failure indicator) + + ``` + PROJECTION_DEFAULT ❌ Failed (propagated from SORT) + ↓ + SORT ❌ Failed (memory limit exceeded) + ↓ + COLLSCAN ✓ Completed (18,830 docs examined) + ``` + +4. **Partial Metrics Section** + + ``` + Partial Execution Stats + (Measured up to failure point) + + Documents Examined: 18,830 + Execution Time: 48ms + Stage Failed: SORT + ``` + +#### B. Performance Rating Badge + +**Current:** Shows "Poor" (misleading) + +**Proposed:** Shows "Failed" with distinct styling + +```typescript +// In React component +{analyzed.executionError ? ( + + Failed + +) : ( + + {performanceRating.score} + +)} +``` + +#### C. Index Advisor Integration + +When query fails, Index Advisor should: + +1. Detect error state from explain plan +2. Provide error-specific recommendations +3. NOT run general index optimization (query didn't complete) + +```typescript +// In QueryInsightsAIService.ts +async getOptimizationRecommendations(explainResult: Document): Promise { + // Check for execution error first + const executionStats = explainResult.executionStats as Document | undefined; + const failed = executionStats?.executionSuccess === false || executionStats?.failed === true; + + if (failed) { + // Return error-specific recommendations instead of general optimization + return this.generateFailureResolutions(explainResult); + } + + // Normal optimization path + return this.generateIndexRecommendations(explainResult); +} + +private async generateFailureResolutions(explainResult: Document): Promise { + const errorCode = (explainResult.executionStats as Document)?.errorCode as number | undefined; + + // Provide specific solutions based on error code + // Don't call LLM for common errors - use predefined solutions + + return { + analysis: "Query execution failed. See recommendations below to resolve the error.", + improvements: [], // No index changes for failed queries + verification: [], + educationalContent: this.getFailureEducationalContent(errorCode), + }; +} +``` + +### 4. Code Infrastructure Changes + +#### Files to Modify: + +1. **`src/documentdb/queryInsights/ExplainPlanAnalyzer.ts`** + - Add `executionError` field to `ExecutionStatsAnalysis` interface + - Add `extractExecutionError()` method + - Add `findFailedStage()` method + - Add `createFailedQueryRating()` method + - Add `createStageFailureDiagnostic()` method + +2. **`src/webviews/documentdb/collectionView/collectionViewRouter.ts`** + - Check for `analyzed.executionError` in `getQueryInsightsStage2` + - Return error-specific response shape when error detected + - Include partial stats for context + +3. **`src/webviews/documentdb/collectionView/types/queryInsights.ts`** + - Add error state types for UI + - Define `QueryExecutionError` interface + - Update `QueryInsightsStage2Response` union type + +4. **`src/services/ai/QueryInsightsAIService.ts`** + - Add error detection before calling Index Advisor + - Implement `generateFailureResolutions()` for common errors + - Skip LLM for well-known error patterns (e.g., error 292) + +5. **React Components** (collectionView webview) + - Add `ExecutionErrorBanner` component + - Add `SolutionsPanel` component + - Update `PerformanceBadge` to show "Failed" state + - Update stage visualization to highlight failed stages + +## Error Code Reference + +Common MongoDB error codes relevant to query execution: + +| Code | Constant | Description | Suggested Fix | +| ----- | ------------------------------------------ | ----------------------------------------------- | ------------------------------------ | +| 292 | `QueryExceededMemoryLimitNoDiskUseAllowed` | Sort/group exceeded memory without allowDiskUse | Enable allowDiskUse or add index | +| 50 | `MaxTimeMSExpired` | Query timeout | Optimize query or increase maxTimeMS | +| 96 | `OperationFailed` | Generic failure | Check logs and query structure | +| 16389 | `PlanExecutorAlwaysFails` | Query will always fail | Fix query syntax/logic | + +## Testing Strategy + +### Unit Tests + +```typescript +describe('ExplainPlanAnalyzer.analyzeExecutionStats', () => { + it('should detect sort memory limit exceeded error', () => { + const explainResult = { + executionStats: { + executionSuccess: false, + failed: true, + errorMessage: 'Sort exceeded memory limit...', + errorCode: 292, + nReturned: 0, + totalDocsExamined: 18830, + executionStages: { + stage: 'SORT', + failed: true, + memLimit: 33554432, + inputStage: { + stage: 'COLLSCAN', + nReturned: 18830, + }, + }, + }, + }; + + const analyzed = ExplainPlanAnalyzer.analyzeExecutionStats(explainResult); + + expect(analyzed.executionError).toBeDefined(); + expect(analyzed.executionError?.failed).toBe(true); + expect(analyzed.executionError?.errorCode).toBe(292); + expect(analyzed.executionError?.failedStage?.stage).toBe('SORT'); + expect(analyzed.performanceRating.score).toBe('poor'); + expect(analyzed.performanceRating.diagnostics[0].message).toContain('failed'); + }); + + it('should not detect error for successful execution', () => { + const explainResult = { + executionStats: { + executionSuccess: true, + nReturned: 100, + totalDocsExamined: 100, + executionStages: { + stage: 'IXSCAN', + nReturned: 100, + }, + }, + }; + + const analyzed = ExplainPlanAnalyzer.analyzeExecutionStats(explainResult); + + expect(analyzed.executionError).toBeUndefined(); + expect(analyzed.performanceRating.score).not.toBe('poor'); // Should be good/excellent + }); +}); +``` + +### Integration Tests + +Test with real explain plans from MongoDB: + +1. Sort memory limit errors +2. MaxTimeMS exceeded +3. Generic operation failures +4. Successful executions (regression test) + +### Debug Files + +Add error examples to debug files: + +- `resources/debug/examples/failed-sort-stage2.json` +- `resources/debug/examples/maxtime-exceeded-stage2.json` + +## Implementation Priority + +### Phase 1: Detection and Analysis (High Priority) ✅ COMPLETED + +- [x] Add error detection to `ExplainPlanAnalyzer` +- [x] Add error fields to `ExecutionStatsAnalysis` interface +- [x] Implement `extractExecutionError()` method +- [x] Implement `findFailedStage()` method +- [x] Implement `extractStageErrorDetails()` method +- [x] Implement `createFailedQueryRating()` method +- [x] Implement `createStageFailureDiagnostic()` method +- [ ] Add unit tests for error detection (deferred to later) + +**Files Modified:** + +- `src/documentdb/queryInsights/ExplainPlanAnalyzer.ts` + - Added `QueryExecutionError` interface + - Updated `ExecutionStatsAnalysis` interface with `executionError` field + - Added error detection in `analyzeExecutionStats()` method + - Implemented all error extraction and diagnostic methods + +### Phase 2: UI Display (High Priority) ✅ COMPLETED + +- [x] Add error state to router response types +- [x] Add error detection in router's `getQueryInsightsStage2` endpoint +- [x] Return error-specific response when query fails +- [x] **Fix**: Error response now returns a proper `QueryInsightsStage2Response` structure to prevent UI errors +- [x] **UI Enhancement**: Failed stages now display with warning-colored badges in stage visualization +- [ ] Implement `ExecutionErrorBanner` component (UI layer - deferred) +- [ ] Update performance badge to show "Failed" state (UI layer - deferred) + +**Files Modified:** + +- `src/webviews/documentdb/collectionView/types/queryInsights.ts` + - Added `QueryExecutionError` interface + - Added `QueryInsightsErrorResponse` interface (deprecated - using Stage2Response instead) +- `src/webviews/documentdb/collectionView/collectionViewRouter.ts` + - Updated `getQueryInsightsStage2` to check for execution errors + - Returns properly structured `QueryInsightsStage2Response` when query fails + - Error details embedded in `concerns`, `executionStrategy`, and `performanceRating` + - **Extracts stage information** even for failed queries using `extractStagesFromDocument()` + - **Enhances ALL failed stages** with failure indicators (not just root cause) + - **Implemented `extractFailedStageNames()`** helper to find all stages with `failed: true` + - **Uses Map to avoid duplicate properties** when adding failure indicators + - **Distinguishes root cause from propagated failures**: Only root cause gets error code and error message + - Maintains UI compatibility by returning same response shape for both success and failure +- `src/documentdb/queryInsights/transformations.ts` + - Exported `extractStagesFromDocument()` function for reuse in error handling +- `src/webviews/documentdb/collectionView/components/queryInsightsTab/components/queryPlanSummary/StageDetailCard.tsx` + - Added `hasFailed` prop to interface + - Badge color changes to 'warning' when stage has failed + - **Implemented badge value truncation**: Values over 50 characters are truncated with ellipsis + - **Added Fluent UI Tooltip**: Truncated values show full text on hover + - Prevents layout issues from long property values (error messages, patterns, etc.) +- `src/webviews/documentdb/collectionView/components/queryInsightsTab/components/queryPlanSummary/QueryPlanSummary.tsx` + - Detects 'Failed' property in extended stage info + - Passes `hasFailed` prop to `StageDetailCard` for both sharded and non-sharded queries + - Failed stages display with warning-colored badges for visual indication + - **Stage overview badges** (horizontal flow with arrows) also display with warning color when failed + - Applied to both sharded and non-sharded query views in the summary section + +### Phase 3: Solutions and Guidance (Medium Priority) ✅ COMPLETED + +- [x] Implement `createStageFailureDiagnostic()` with solutions +- [x] Create error resolution tips helper function +- [ ] Add `SolutionsPanel` component (UI layer - deferred) +- [ ] Create educational content for common errors (content - deferred) +- [ ] Add error-specific telemetry (deferred) + +**Files Modified:** + +- `src/documentdb/queryInsights/ExplainPlanAnalyzer.ts` + - `createStageFailureDiagnostic()` includes actionable solutions +- `src/webviews/documentdb/collectionView/collectionViewRouter.ts` + - ~~Added `getErrorResolutionTips()` helper function~~ (removed - AI provides recommendations) +- `src/webviews/documentdb/collectionView/components/queryInsightsTab/components/summaryCard/custom/PerformanceRatingCell.tsx` + - Added info icon to performance diagnostic badges to indicate tooltip availability + - Improves discoverability of detailed diagnostic information + +### Phase 4: Index Advisor Integration (Medium Priority) ✅ COMPLETED + +- [x] Add error detection to Stage 3 (AI recommendations) +- [x] **Client-side immediate error display** - Error card shown instantly using cached Stage 2 data +- [x] **AI analysis still executes** - Runs in background even for errors, provides additional insights +- [x] **Simplified architecture** - Error info prepared in Stage 2, reused on client side (no server-side error handling in Stage 3) +- [ ] Add error code to telemetry (deferred) + +**Files Modified:** + +- `src/webviews/documentdb/collectionView/collectionViewRouter.ts` + - Stage 3 endpoint simplified - no special error handling needed + - Always runs AI analysis (even for errors) +- `src/webviews/documentdb/collectionView/components/queryInsightsTab/QueryInsightsTab.tsx` + - `handleGetAISuggestions()` checks Stage 2 data for execution errors + - Immediately shows error card using cached Stage 2 concerns/diagnostics + - Continues AI analysis in background, merges results with error card + - No delayed tips for error state (immediate feedback) + - Updated `getQueryInsightsStage3` to check for execution errors + - Returns error tips instead of AI recommendations when query fails + +## Implementation Status + +### ✅ Completed (Backend/Infrastructure) + +All backend detection and error handling logic has been implemented: + +1. **Error Detection**: Full error extraction from MongoDB explain plans +2. **Error Analysis**: Stage-by-stage failure detection and diagnostic generation +3. **Router Integration**: Error-aware response generation for all Query Insights stages +4. **Type Safety**: Complete TypeScript interfaces for error states +5. **Error Guidance**: Actionable resolution tips based on error codes + +### 🔄 Deferred (UI Components & Testing) + +The following items are deferred as requested: + +1. **React UI Components**: Error banners, badges, and visualizations +2. **Unit Tests**: Comprehensive test coverage for error detection +3. **Integration Tests**: Real MongoDB error scenario testing +4. **Debug Files**: Example error explain plans for testing +5. **Telemetry**: Error tracking and analytics + +### 📝 Implementation Summary + +The error handling infrastructure is complete and functional: + +- **Stage 2**: Detects query execution errors and returns a properly structured `QueryInsightsStage2Response` with: + - Error information embedded in `concerns` array + - Failed stage indicated in `executionStrategy` field + - Performance diagnostics with error details + - Partial metrics (docs examined, execution time before failure) + - **Full stage details** extracted from execution plan (even for failed queries) + - **Enhanced stage properties** with failure indicators (`Failed: true`, error code, error message) + - Same response structure as successful queries (ensures UI compatibility) +- **Stage 3**: Checks for errors and returns resolution tips instead of AI recommendations when query fails +- **Error Types**: Comprehensive support for common error codes (292 - sort memory limit, 50 - timeout, etc.) +- **Diagnostics**: Clear, actionable error messages with specific solutions + +The system now properly: + +- ✅ Detects when `executionSuccess: false` or `failed: true` +- ✅ Extracts error messages and codes +- ✅ Identifies which stage failed (root cause) +- ✅ **Marks ALL failed stages** in the execution tree (not just root cause) +- ✅ **Uses Map-based deduplication** to prevent duplicate properties +- ✅ **Distinguishes root cause from propagated failures** (only root cause shows error details) +- ✅ Provides stage-specific error details +- ✅ Generates actionable resolution tips +- ✅ Avoids showing misleading performance metrics for failed queries +- ✅ Returns UI-compatible response structure (prevents TypeError in React components) + +### Next Steps (When UI Implementation Begins) + +1. Create React components to display error states in the Query Insights panel +2. Add error indicators to the execution stage visualization +3. Implement telemetry for tracking error types and resolutions +4. Add comprehensive test coverage +5. Create debug files with example error scenarios + +## Summary + +MongoDB explain plans contain rich error information when queries fail, but this is currently not detected or surfaced in Query Insights. By checking `executionSuccess`, `errorMessage`, and stage-level `failed` flags, we can: + +1. **Detect failures** before showing misleading performance metrics +2. **Explain errors** in user-friendly terms with actionable solutions +3. **Highlight failed stages** in execution plan visualization +4. **Provide targeted fixes** (e.g., allowDiskUse, index creation) +5. **Improve telemetry** by tracking query failure types + +This enhancement will prevent user confusion and provide a better debugging experience when queries don't complete successfully. diff --git a/docs/design-documents/query-insights-router-plan.md b/docs/design-documents/query-insights-router-plan.md new file mode 100644 index 000000000..59d0e580b --- /dev/null +++ b/docs/design-documents/query-insights-router-plan.md @@ -0,0 +1,5068 @@ +# Query Insights Router Implementation Plan + +## Overview + +This document outlines the plan for implementing three-stage query insights in the `collectionViewRouter.ts` file. The implementation will support progressive data loading for query performance analysis and AI-powered optimization recommendations. + +> **📝 Document Update Note**: This document originally contained multiple versions of the performance rating algorithm that evolved during the design process. These have been consolidated into a single authoritative version in the **"Performance Rating Thresholds"** section. The final implementation uses **efficiency ratio** (returned/examined, where higher is better) rather than the inverse metric used in early iterations. + +--- + +## Architecture Overview + +### Design Document Reference + +This implementation plan is based on the design document: **performance-advisor.md** + +The Query Insights feature provides progressive performance analysis through three stages, aligned with the UI design: + +1. **Stage 1: Initial Performance View** — Fast, immediate metrics using `explain("queryPlanner")` +2. **Stage 2: Detailed Execution Analysis** — Authoritative metrics via `explain("executionStats")` +3. **Stage 3: AI-Powered Recommendations** — Optimization suggestions from AI service + +### Router Context + +All calls to the router share this context (defined in `collectionViewRouter.ts`): + +```typescript +export type RouterContext = BaseRouterContext & { + sessionId: string; // Tied to the query and results set + clusterId: string; // Identifies the DocumentDB cluster/connection + databaseName: string; // Target database + collectionName: string; // Target collection +}; +``` + +**Key Insight**: The `sessionId` is tied to both the query and its results set. This means: + +- Each query execution creates a new `sessionId` +- Stage 1, 2, and 3 calls for the same query share the same `sessionId` +- The backend can cache query metadata, execution stats, and results using `sessionId` +- No need to pass query parameters repeatedly if we leverage `sessionId` for lookup + +### Data Flow + +``` +User runs query → Stage 1 (immediate) → Stage 2 (on-demand) → Stage 3 (AI analysis) + ↓ ↓ ↓ + Basic metrics Execution stats Optimization recommendations +``` + +### Stage Responsibilities + +- **Stage 1**: Initial view with cheap data + query planner (no re-execution) +- **Stage 2**: Detailed execution analysis with authoritative runtime metrics +- **Stage 3**: AI-powered advisor with actionable optimization recommendations + +--- + +## DocumentDB Explain Plan Parsing with @mongodb-js/explain-plan-helper + +### Overview + +For robust parsing of DocumentDB explain plans, we use the [`@mongodb-js/explain-plan-helper`](https://www.npmjs.com/package/@mongodb-js/explain-plan-helper) package. This battle-tested library is maintained by MongoDB and used in MongoDB Compass, providing reliable parsing across different MongoDB versions and platforms including DocumentDB. + +### Why Use This Library? + +1. **Handles MongoDB Version Differences**: The explain format has evolved across MongoDB versions. This library normalizes these differences automatically. +2. **Comprehensive Edge Case Coverage**: + - Sharded queries with per-shard execution stats + - Multiple input stages (e.g., `$or` queries) + - Nested and recursive stage structures + - Different verbosity levels (`queryPlanner`, `executionStats`, `allPlansExecution`) +3. **Type Safety**: Provides TypeScript definitions for all explain structures +4. **Battle-Tested**: Used in production by MongoDB Compass +5. **Convenience Methods**: Pre-built helpers for common checks: + - `isCollectionScan` - Detects full collection scans + - `isIndexScan` - Detects index usage + - `isCovered` - Detects covered queries (index-only, no FETCH) + - `inMemorySort` - Detects in-memory sorting + +### Installation + +```bash +npm install @mongodb-js/explain-plan-helper +``` + +### Core API + +The package exports two main classes: + +#### 1. ExplainPlan Class + +The main entry point for parsing explain output: + +```typescript +import { ExplainPlan } from '@mongodb-js/explain-plan-helper'; + +// Parse explain output +const explainPlan = new ExplainPlan(explainResult); + +// High-level metrics (available with executionStats verbosity) +const executionTimeMillis = explainPlan.executionTimeMillis; // Server execution time +const nReturned = explainPlan.nReturned; // Documents returned +const totalKeysExamined = explainPlan.totalKeysExamined; // Keys scanned +const totalDocsExamined = explainPlan.totalDocsExamined; // Documents examined + +// Query characteristics (boolean flags) +const isCollectionScan = explainPlan.isCollectionScan; // Full collection scan? +const isIndexScan = explainPlan.isIndexScan; // Uses index? +const isCoveredQuery = explainPlan.isCovered; // Index-only (no FETCH)? +const inMemorySort = explainPlan.inMemorySort; // In-memory sort? + +// Metadata +const namespace = explainPlan.namespace; // "database.collection" + +// Execution stage tree (detailed stage-by-stage breakdown) +const executionStages = explainPlan.executionStages; // Stage object (tree root) +``` + +#### 2. Stage Interface + +Represents individual execution stages in a tree structure: + +```typescript +interface Stage { + // Stage identification + stage: string; // Stage type (IXSCAN, FETCH, SORT, COLLSCAN, etc.) + name: string; // Human-readable name + + // Execution metrics + nReturned: number; // Documents returned by this stage + executionTimeMillis?: number; // Time spent in this stage + executionTimeMillisEstimate?: number; // Estimated time + + // Stage-specific properties + indexName?: string; // For IXSCAN stages + indexBounds?: any; // Index bounds used + keysExamined?: number; // Keys examined (IXSCAN) + docsExamined?: number; // Documents examined (FETCH, COLLSCAN) + + // Child stages (tree structure) + inputStage?: Stage; // Single input stage + inputStages?: Stage[]; // Multiple input stages (e.g., for $or queries) + shards?: Stage[]; // For sharded queries + + // Shard information + shardName?: string; // Shard identifier + isShard: boolean; // Whether this represents a shard +} +``` + +### Usage Examples + +#### Basic Usage (Stage 1 - queryPlanner) + +```typescript +import { ExplainPlan } from '@mongodb-js/explain-plan-helper'; + +const explainPlan = new ExplainPlan(queryPlannerResult); + +// Check query characteristics +const usesIndex = explainPlan.isIndexScan; +const hasInMemorySort = explainPlan.inMemorySort; +const namespace = explainPlan.namespace; +``` + +#### Detailed Analysis (Stage 2 - executionStats) + +```typescript +const explainPlan = new ExplainPlan(executionStatsResult); + +// Get execution metrics +const metrics = { + executionTimeMs: explainPlan.executionTimeMillis, + totalKeysExamined: explainPlan.totalKeysExamined, + totalDocsExamined: explainPlan.totalDocsExamined, + documentsReturned: explainPlan.nReturned, + + // Efficiency calculation + efficiency: explainPlan.totalDocsExamined / explainPlan.nReturned, + + // Query characteristics + hadCollectionScan: explainPlan.isCollectionScan, + hadInMemorySort: explainPlan.inMemorySort, + isCoveredQuery: explainPlan.isCovered, +}; +``` + +#### Traversing Stage Trees + +```typescript +function extractStageInfo(explainResult: unknown): StageInfo[] { + const explainPlan = new ExplainPlan(explainResult); + const stages: StageInfo[] = []; + + function traverseStage(stage: Stage | undefined): void { + if (!stage) return; + + stages.push({ + stage: stage.stage, + name: stage.name, + nReturned: stage.nReturned, + executionTimeMs: stage.executionTimeMillis ?? stage.executionTimeMillisEstimate, + indexName: stage.indexName, + keysExamined: stage.keysExamined, + docsExamined: stage.docsExamined, + }); + + // Traverse child stages recursively + if (stage.inputStage) { + traverseStage(stage.inputStage); + } + + if (stage.inputStages) { + stage.inputStages.forEach(traverseStage); + } + + if (stage.shards) { + stage.shards.forEach(traverseStage); + } + } + + traverseStage(explainPlan.executionStages); + return stages; +} +``` + +#### Handling Sharded Queries + +```typescript +const explainPlan = new ExplainPlan(shardedExplainResult); + +// Check if query is sharded +const isSharded = explainPlan.executionStages?.shards !== undefined; + +if (isSharded) { + const shards = explainPlan.executionStages.shards; + + shards.forEach((shard) => { + console.log(`Shard: ${shard.shardName}`); + console.log(` Keys examined: ${shard.totalKeysExamined}`); + console.log(` Docs examined: ${shard.totalDocsExamined}`); + console.log(` Docs returned: ${shard.nReturned}`); + }); +} +``` + +### Platform Compatibility + +The library works with any MongoDB-compatible explain output, including: + +- MongoDB (all versions) +- DocumentDB (uses `explainVersion: 2`) +- Azure Cosmos DB for MongoDB + +**DocumentDB Detection** (optional): + +```typescript +function isDocumentDB(explainOutput: any): boolean { + return explainOutput.explainVersion === 2; +} +``` + +--- + +## Infrastructure: Explain Plan Utilities + +### Purpose + +Before implementing the router endpoints (Stages 1, 2, 3), we need a set of reusable utility functions for extracting data from explain plans. These utilities will: + +1. **Enable Independent Testing**: Test data extraction with various explain plans without instantiating the full extension environment +2. **Provide Consistent Parsing**: Centralize explain plan parsing logic using `@mongodb-js/explain-plan-helper` +3. **Support Incremental Development**: Start with basic extraction for Stage 1, expand as we implement Stages 2 and 3 +4. **Handle Edge Cases**: Manage sharded queries, missing fields, and platform differences in one place + +### Implementation Location + +**File**: `src/documentdb/utils/explainPlanUtils.ts` (new file) + +This utility module will be expanded as we progress through stage implementations. + +### Initial Utility Functions + +#### 1. Basic Explain Plan Parser + +```typescript +import { ExplainPlan, type Stage } from '@mongodb-js/explain-plan-helper'; + +/** + * Parses explain output and provides convenient access to explain data + */ +export class ExplainPlanParser { + private readonly explainPlan: ExplainPlan; + + constructor(explainOutput: unknown) { + this.explainPlan = new ExplainPlan(explainOutput); + } + + // High-level metrics + getExecutionTimeMs(): number | undefined { + return this.explainPlan.executionTimeMillis; + } + + getDocumentsReturned(): number { + return this.explainPlan.nReturned; + } + + getTotalKeysExamined(): number { + return this.explainPlan.totalKeysExamined; + } + + getTotalDocsExamined(): number { + return this.explainPlan.totalDocsExamined; + } + + getNamespace(): string { + return this.explainPlan.namespace; + } + + // Query characteristics + isCollectionScan(): boolean { + return this.explainPlan.isCollectionScan; + } + + isIndexScan(): boolean { + return this.explainPlan.isIndexScan; + } + + isCoveredQuery(): boolean { + return this.explainPlan.isCovered; + } + + hasInMemorySort(): boolean { + return this.explainPlan.inMemorySort; + } + + // Stage tree access + getExecutionStages(): Stage | undefined { + return this.explainPlan.executionStages; + } + + // Platform detection + isSharded(): boolean { + return this.explainPlan.executionStages?.shards !== undefined; + } +} +``` + +#### 2. Stage Tree Traversal Utilities + +```typescript +/** + * Flattens the stage tree into a linear array for UI display + */ +export function flattenStageTree(rootStage: Stage | undefined): StageInfo[] { + if (!rootStage) return []; + + const stages: StageInfo[] = []; + + function traverse(stage: Stage): void { + stages.push({ + stage: stage.stage, + name: stage.name, + nReturned: stage.nReturned, + executionTimeMs: stage.executionTimeMillis ?? stage.executionTimeMillisEstimate, + indexName: stage.indexName, + keysExamined: stage.keysExamined, + docsExamined: stage.docsExamined, + }); + + // Traverse children + if (stage.inputStage) { + traverse(stage.inputStage); + } + + if (stage.inputStages) { + stage.inputStages.forEach(traverse); + } + + if (stage.shards) { + stage.shards.forEach(traverse); + } + } + + traverse(rootStage); + return stages; +} + +/** + * Information extracted from a single stage + */ +export interface StageInfo { + stage: string; + name: string; + nReturned: number; + executionTimeMs?: number; + indexName?: string; + keysExamined?: number; + docsExamined?: number; +} +``` + +#### 3. Index Detection Utilities + +```typescript +/** + * Finds all indexes used in the query plan + */ +export function findUsedIndexes(rootStage: Stage | undefined): string[] { + if (!rootStage) return []; + + const indexes = new Set(); + + function traverse(stage: Stage): void { + if (stage.stage === 'IXSCAN' && stage.indexName) { + indexes.add(stage.indexName); + } + + if (stage.inputStage) traverse(stage.inputStage); + if (stage.inputStages) stage.inputStages.forEach(traverse); + if (stage.shards) stage.shards.forEach(traverse); + } + + traverse(rootStage); + return Array.from(indexes); +} + +/** + * Checks if the query uses a specific index + */ +export function usesIndex(rootStage: Stage | undefined, indexName: string): boolean { + return findUsedIndexes(rootStage).includes(indexName); +} +``` + +#### 4. Efficiency Calculation Utilities + +```typescript +/** + * Calculates the examined-to-returned ratio (inverse of efficiency ratio) + * Higher values indicate inefficiency - the query examines many documents to return few + * + * Note: The performance rating algorithm uses efficiencyRatio (returned/examined) instead, + * which is more intuitive (higher = better). This function is kept for backwards compatibility + * and specific use cases where the inverse ratio is more meaningful. + * + * @param docsExamined - Number of documents examined + * @param docsReturned - Number of documents returned + * @returns Examined-to-returned ratio (where lower is better) + */ +export function calculateExaminedToReturnedRatio(docsExamined: number, docsReturned: number): number { + if (docsReturned === 0) return docsExamined > 0 ? Infinity : 0; + return docsExamined / docsReturned; +} + +/** + * Calculates index selectivity (keys examined / docs examined) + */ +export function calculateIndexSelectivity(keysExamined: number, docsExamined: number): number | null { + if (docsExamined === 0) return null; + return keysExamined / docsExamined; +} + +/** + * Calculates performance rating based on execution metrics + * + * This is the authoritative implementation used in ExplainPlanAnalyzer.ts. + * See src/documentdb/queryInsights/ExplainPlanAnalyzer.ts for the actual code. + * + * Rating criteria: + * - Excellent: High efficiency (>=50%), indexed, no in-memory sort, fast (<100ms) + * - Good: Moderate efficiency (>=10%), indexed or fast (<500ms) + * - Fair: Low efficiency (>=1%) + * - Poor: Very low efficiency (<1%) or collection scan with low efficiency + * + * @param executionTimeMs - Execution time in milliseconds + * @param efficiencyRatio - Ratio of documents returned to documents examined (0.0 to 1.0+) + * @param hasInMemorySort - Whether query performs in-memory sorting + * @param isIndexScan - Whether query uses index scan + * @param isCollectionScan - Whether query performs collection scan + * @returns Performance rating + */ +export function calculatePerformanceRating( + executionTimeMs: number, + efficiencyRatio: number, + hasInMemorySort: boolean, + isIndexScan: boolean, + isCollectionScan: boolean, +): 'excellent' | 'good' | 'fair' | 'poor' { + // Poor: Collection scan with very low efficiency + if (isCollectionScan && efficiencyRatio < 0.01) { + return 'poor'; + } + + // Excellent: High efficiency, uses index, no blocking sort, fast execution + if (efficiencyRatio >= 0.5 && isIndexScan && !hasInMemorySort && executionTimeMs < 100) { + return 'excellent'; + } + + // Good: Moderate efficiency with index usage or fast execution + if (efficiencyRatio >= 0.1 && (isIndexScan || executionTimeMs < 500)) { + return 'good'; + } + + // Fair: Low efficiency but acceptable + if (efficiencyRatio >= 0.01) { + return 'fair'; + } + + return 'poor'; +} + +/** + * Calculates the efficiency ratio (documents returned / documents examined) + * A ratio close to 1.0 indicates high efficiency - the query examines only the documents it returns + * + * @param returned - Number of documents returned + * @param examined - Number of documents examined + * @returns Efficiency ratio (0.0 to 1.0+, where higher is better) + */ +export function calculateEfficiencyRatio(returned: number, examined: number): number { + if (examined === 0) { + return returned === 0 ? 1.0 : 0.0; + } + return returned / examined; +} +``` + +#### 5. Sharded Query Utilities + +```typescript +/** + * Aggregates metrics across shards + */ +export function aggregateShardMetrics(rootStage: Stage | undefined): { + totalKeysExamined: number; + totalDocsExamined: number; + totalReturned: number; + shardCount: number; +} { + if (!rootStage?.shards) { + return { + totalKeysExamined: 0, + totalDocsExamined: 0, + totalReturned: 0, + shardCount: 0, + }; + } + + return rootStage.shards.reduce( + (acc, shard) => ({ + totalKeysExamined: acc.totalKeysExamined + (shard.keysExamined ?? 0), + totalDocsExamined: acc.totalDocsExamined + (shard.docsExamined ?? 0), + totalReturned: acc.totalReturned + shard.nReturned, + shardCount: acc.shardCount + 1, + }), + { totalKeysExamined: 0, totalDocsExamined: 0, totalReturned: 0, shardCount: 0 }, + ); +} +``` + +### Testing Strategy + +These utilities can be tested independently with mock explain outputs: + +```typescript +// Example test structure +describe('ExplainPlanUtils', () => { + describe('ExplainPlanParser', () => { + it('should parse queryPlanner output', () => { + const mockExplain = { + queryPlanner: { + /* ... */ + }, + // No executionStats + }; + + const parser = new ExplainPlanParser(mockExplain); + expect(parser.getNamespace()).toBe('testdb.testcoll'); + }); + + it('should parse executionStats output', () => { + const mockExplain = { + queryPlanner: { + /* ... */ + }, + executionStats: { + /* ... */ + }, + }; + + const parser = new ExplainPlanParser(mockExplain); + expect(parser.getExecutionTimeMs()).toBe(120); + expect(parser.getTotalDocsExamined()).toBe(1000); + }); + }); + + describe('Stage Tree Utilities', () => { + it('should flatten nested stage tree', () => { + const mockStage: Stage = { + stage: 'FETCH', + inputStage: { + stage: 'IXSCAN', + indexName: 'user_id_1', + }, + }; + + const flattened = flattenStageTree(mockStage); + expect(flattened).toHaveLength(2); + expect(flattened[0].stage).toBe('FETCH'); + expect(flattened[1].stage).toBe('IXSCAN'); + }); + }); + + describe('Efficiency Calculations', () => { + it('should calculate examined-to-returned ratio', () => { + const ratio = calculateExaminedToReturnedRatio(1000, 10); + expect(ratio).toBe(100); + }); + + it('should rate performance as poor for high ratio', () => { + const rating = calculatePerformanceRating({ + examinedToReturnedRatio: 500, + hadCollectionScan: true, + hadInMemorySort: false, + indexUsed: false, + }); + + expect(rating.score).toBe('poor'); + expect(rating.concerns).toContain('Full collection scan performed'); + }); + }); +}); +``` + +### Expansion Plan + +As we implement each stage, we'll add more utilities to this module: + +- **Stage 1**: Basic parsing, index detection, stage tree flattening +- **Stage 2**: Performance rating, efficiency calculations, execution strategy determination +- **Stage 3**: Query shape extraction for AI, collection stats integration + +This modular approach allows us to: + +1. Test utilities in isolation with various explain plan formats +2. Reuse utilities across different parts of the codebase +3. Maintain a single source of truth for explain plan parsing logic +4. Easily add support for new explain plan features + +--- + +## Stage 1: Initial Performance View (Cheap Data + Query Plan) + +### Purpose + +**Design Goal** (from performance-advisor.md): Populated as soon as the query finishes, using fast signals plus `explain("queryPlanner")`. No full re-execution. + +Provides immediate, low-cost metrics and query plan visualization. + +### Paging Limitation and Query Insights + +**Current Paging Implementation**: +The extension currently uses `skip` and `limit` for result paging, which is sufficient for data exploration but problematic for query insights. The `explain` plan with `skip` and `limit` only analyzes the performance of fetching a single page, not the overall query performance. + +**Impact on Query Insights**: +For meaningful performance analysis, insights should reflect the "full query" scope without paging modifiers. However, rebuilding the entire paging system to use cursors is out of scope for the upcoming release. + +**Stage 1 Solution**: +We'll implement a dedicated data collection function in `ClusterSession` that: + +1. Detects when a new query is executed (via existing `resetCachesIfQueryChanged` logic) +2. On first call with a new query, automatically runs `explain("queryPlanner")` **without** `skip` and `limit` +3. Caches the planner output in `_currentQueryPlannerInfo` for subsequent Stage 1 requests +4. Returns cached data on subsequent calls until the query changes + +This approach: + +- ✅ Provides accurate query insights for the full query scope +- ✅ Runs only once per unique query (cached until query changes) +- ✅ Doesn't require rebuilding the paging system +- ✅ Keeps existing `skip`/`limit` paging for the Results view unchanged + +**Note**: Optimizing the paging implementation (e.g., cursor-based paging) is planned for a future release but not in scope for query insights MVP. + +### Data Sources + +- Query execution timer (client-side) +- Result set from the query +- Query planner output from `explain("queryPlanner")` **without skip/limit modifiers** + +### Router Endpoint + +**Name**: `getQueryInsightsStage1` + +**Type**: `query` (read operation) + +**Input Schema**: + +```typescript +z.object({ + // Empty - relies entirely on RouterContext.sessionId + // The sessionId in context identifies the query and results set +}); +``` + +**Context Requirements**: + +- `sessionId`: Used to retrieve cached query planner info and execution time +- `databaseName` & `collectionName`: Used for display and validation + +**Output Schema**: + +```typescript +{ + executionTime: number; // Milliseconds (client-side measurement) + documentsReturned: number; // Count of documents in result set + // Note: keysExamined and docsExamined not available until Stage 2 + stages: Array<{ + // Flattened stage hierarchy for UI display + stage: string; // "IXSCAN" | "FETCH" | "PROJECTION" | "SORT" | "COLLSCAN" + name: string; // Human-readable stage name + nReturned: number; // Documents returned by this stage + indexName?: string; // For IXSCAN stages + indexBounds?: string; // Stringified bounds for IXSCAN + keysExamined?: number; // Keys examined (if available) + docsExamined?: number; // Docs examined (if available) + }>; + efficiencyAnalysis: { + executionStrategy: string; // e.g., "Index Scan", "Collection Scan" + indexUsed: string | null; // Index name or null + hasInMemorySort: boolean; // Whether SORT stage detected + // performanceRating not available in Stage 1 (requires execution metrics) + } +} +``` + +**Design Rationale**: + +The `stages` array provides all necessary information for UI visualization without including the raw `queryPlannerInfo.winningPlan` structure. This approach: + +- ✅ **Reduces payload size**: Eliminates ~5-10KB of raw MongoDB metadata not used by UI +- ✅ **Simplifies frontend**: UI consumes flat array instead of nested tree +- ✅ **Maintains flexibility**: Can add fields to `stages` array as needed +- ✅ **Performance**: Smaller JSON payloads improve network performance + +If advanced users need the raw plan, they can access it in Stage 2's `rawExecutionStats` which includes the complete explain output. + +### Implementation Notes + +**Design Document Alignment**: + +1. **Metrics Row** (design doc 2.1): Display individual metric cards + - Execution Time: Tracked by ClusterSession during query execution + - Documents Returned: Show "n/a" (not available until Stage 2 with executionStats) + - Keys Examined: Show "n/a" (not available until Stage 2) + - Docs Examined: Show "n/a" (not available until Stage 2) + +2. **Query Plan Summary** (design doc 2.2): Fast planner-only view + - Extract full logical plan tree from `explain("queryPlanner")` + - Include rejected plans count + - No runtime stats (Stage 2 provides those) + +3. **Query Efficiency Analysis Card** (design doc 2.3): Partial data + - Execution Strategy: From top-level stage + - Index Used: From IXSCAN stage if present + - In-Memory Sort: Detect SORT stage + - Performance Rating: Not available (requires execution stats from Stage 2) + +**Data Collection in ClusterSession**: + +When Stage 1 is requested, the `ClusterSession` class handles data collection: + +1. **Execution Time Tracking**: ClusterSession automatically tracks query execution time during `runFindQueryWithCache()`: + - Measures time before/after calling `_client.runFindQuery()` + - Stores in `_lastExecutionTimeMs` private property + - Available via `getLastExecutionTimeMs()` method + - Reset when query changes (in `resetCachesIfQueryChanged()`) + +2. **New Query Detection**: The existing `resetCachesIfQueryChanged()` method detects when the query text changes + +3. **Automatic explain("queryPlanner") Call**: On the first Stage 1 request after a new query: + - Extract the base query (filter, projection, sort) from the request parameters + - **Remove `skip` and `limit` modifiers** to analyze the full query scope (not just one page) + - Execute `explain("queryPlanner")` with the clean query + - Persist results in `_queryPlannerCache` + +4. **Caching**: Subsequent Stage 1 requests return cached `_queryPlannerCache` until query changes + +5. **Cache Invalidation**: When `resetCachesIfQueryChanged()` detects a query change, all caches are cleared + +This approach ensures: + +- ✅ Query insights reflect the full query performance (not just one page) +- ✅ Only one `explain("queryPlanner")` call per unique query +- ✅ Automatic cache management tied to query lifecycle +- ✅ Execution time tracked server-side (consistent, not affected by network latency) +- ✅ Existing `skip`/`limit` paging for Results view remains unchanged + +**Technical Details**: + +1. **Execution Time**: Measured server-side by ClusterSession during `runFindQueryWithCache()` execution +2. **Documents Returned**: **NOT AVAILABLE in Stage 1** - `explain("queryPlanner")` does not execute the query, so document count is unknown. This metric shows as 0 in Stage 1 and becomes available in Stage 2 with `explain("executionStats")`. +3. **QueryPlanner Info**: Obtained via ClusterSession's `getQueryPlannerInfo()` method (strips skip/limit, calls explain) +4. **Stages List**: Recursively traverse `winningPlan` to extract all stages for UI cards + +**Why Documents Returned is Not Available in Stage 1**: + +The `explain("queryPlanner")` command analyzes the query plan but **does not execute the query**. Therefore: + +- ✅ Stage 1 is fast (no query execution) +- ❌ No document count available (would require query execution) +- ✅ Shows 0 as placeholder in Stage 1 UI +- ✅ Stage 2 provides actual count via `explain("executionStats")` which executes the winning plan + +### Extracting Data from queryPlanner Output + +The `explain("queryPlanner")` output structure is consistent across DocumentDB platforms. This implementation will focus on the fields that are reliably available. + +#### Common Fields in DocumentDB + +**Available in DocumentDB (using MongoDB API):** + +1. **`queryPlanner.namespace`** (string) + - Format: `"database.collection"` + - Example: `"StoreData.stores"`, `"demoDatabase.movies"`, `"sample_airbnb.listingsAndReviews"` + +2. **`queryPlanner.winningPlan.stage`** (string) + - Top-level stage type: `"COLLSCAN"`, `"FETCH"`, `"SORT"`, `"IXSCAN"`, etc. + - Present in all explain outputs + +3. **`queryPlanner.winningPlan.inputStage`** (object, when present) + - Nested stage information + - Contains: `stage`, and potentially `indexName`, `runtimeFilterSet`, etc. + - Can be nested multiple levels deep + +4. **`estimatedTotalKeysExamined`** (number) + - Available at stage level + - Indicates estimated number of keys/documents to examine + - Example: `41505`, `20752`, `2` + +5. **`runtimeFilterSet`** (array, when present) + - Shows filter predicates applied during scan + - Example: `[{ "$gt": { "year": 1900 } }]`, `[{ "$eq": { "storeFeatures": 38 } }]` + +6. **`sortKeysCount`** (number, for SORT stages) + - Indicates number of sort fields + - Example: `1` + +#### Fields Used in This Implementation + +For this iteration, we will extract and use the following fields that are consistently available in DocumentDB: + +1. **`queryPlanner.namespace`** - Database and collection name +2. **`queryPlanner.winningPlan.stage`** - Top-level execution stage +3. **`queryPlanner.winningPlan.inputStage`** - Nested stage information (when present) +4. **`estimatedTotalKeysExamined`** - Estimated keys/documents to examine +5. **`runtimeFilterSet`** - Runtime filter predicates +6. **`sortKeysCount`** - Sort field count (for SORT stages) + +These fields provide sufficient information for Stage 1 insights. + +#### Extraction Strategy for Stage 1 + +```typescript +interface Stage1QueryPlannerExtraction { + // Common fields + namespace: string; // Always available + topLevelStage: string; // winningPlan.stage + + // Estimated metrics + estimatedTotalKeysExamined?: number; // At stage level + + // Runtime filters + hasRuntimeFilters: boolean; // Check for runtimeFilterSet + runtimeFilterCount?: number; // Number of runtime filters + + // Index usage indicators (detected from stage tree) + usesIndex: boolean; // True if IXSCAN stage found + indexName?: string; // From IXSCAN stage (if available) + + // Sort indicators + hasSortStage: boolean; // True if SORT stage found + sortKeysCount?: number; // Number of sort fields + + // Full stage tree (for UI display) + stageTree: StageNode[]; // Flattened hierarchy +} + +interface StageNode { + stage: string; // Stage type + indexName?: string; // For IXSCAN (if available) + estimatedKeys?: number; // estimatedTotalKeysExamined + sortKeysCount?: number; // For SORT stages + runtimeFilters?: string; // Stringified filter predicates +} + +// Extraction function +function extractStage1Data(explainOutput: unknown): Stage1QueryPlannerExtraction { + const qp = explainOutput.queryPlanner; + + return { + namespace: qp.namespace, + topLevelStage: qp.winningPlan.stage, + + // Estimated metrics + estimatedTotalKeysExamined: qp.winningPlan.estimatedTotalKeysExamined, + + // Runtime filters + hasRuntimeFilters: !!qp.winningPlan.runtimeFilterSet, + runtimeFilterCount: qp.winningPlan.runtimeFilterSet?.length, + + // Index detection + usesIndex: hasIndexScan(qp.winningPlan), + indexName: findIndexName(qp.winningPlan), + + // Sort detection + hasSortStage: qp.winningPlan.stage === 'SORT', + sortKeysCount: qp.winningPlan.sortKeysCount, + + // Build stage tree + stageTree: flattenStageTree(qp.winningPlan), + }; +} + +// Helper: recursively check for IXSCAN stage +function hasIndexScan(stage: any): boolean { + if (stage.stage === 'IXSCAN') return true; + if (stage.inputStage) return hasIndexScan(stage.inputStage); + return false; +} + +// Helper: find index name in stage tree +function findIndexName(stage: any): string | undefined { + if (stage.stage === 'IXSCAN') return stage.indexName; + if (stage.inputStage) return findIndexName(stage.inputStage); + return undefined; +} + +// Helper: flatten stage tree for UI display +function flattenStageTree(stage: any, depth = 0): StageNode[] { + const nodes: StageNode[] = []; + + const node: StageNode = { + stage: stage.stage, + }; + + // Add stage-specific fields (common across platforms) + if (stage.indexName) node.indexName = stage.indexName; + if (stage.estimatedTotalKeysExamined) node.estimatedKeys = stage.estimatedTotalKeysExamined; + if (stage.sortKeysCount) node.sortKeysCount = stage.sortKeysCount; + if (stage.runtimeFilterSet) node.runtimeFilters = JSON.stringify(stage.runtimeFilterSet); + + nodes.push(node); + + // Recurse into inputStage + if (stage.inputStage) { + nodes.push(...flattenStageTree(stage.inputStage, depth + 1)); + } + + return nodes; +} +``` + +**Note**: The above extraction functions are simplified examples. In the actual implementation, we use the utilities from `src/documentdb/utils/explainPlanUtils.ts` which leverage `@mongodb-js/explain-plan-helper` for robust parsing (see Infrastructure section above). + +#### Platform Detection Strategy + +DocumentDB explain output uses `explainVersion: 2`: + +```typescript +function detectDocumentDBPlatform(explainOutput: any): 'documentdb' | 'unknown' { + // DocumentDB uses explainVersion: 2 + if (explainOutput.explainVersion === 2) { + return 'documentdb'; + } + + return 'unknown'; +} +``` + +### ClusterSession Extensions for Stage 1 + +**Important**: ClusterSession uses QueryInsightsApis but doesn't instantiate it. The QueryInsightsApis instance is provided by ClustersClient (see "ClustersClient Extensions" section below). + +Add to `ClusterSession` class: + +```typescript +export class ClusterSession { + // Existing properties... + private _currentQueryPlannerInfo?: unknown; + private _currentExecutionTime?: number; + private _currentDocumentsReturned?: number; + + // Query Insights APIs are accessed via this._client.queryInsightsApis + // (instantiated in ClustersClient, not here) + + constructor(/* existing parameters */) { + // Existing initialization... + // No QueryInsightsApis instantiation here - that's ClustersClient's responsibility + } + + // Update resetCachesIfQueryChanged to clear explain caches + private resetCachesIfQueryChanged(query: string) { + if (this._currentQueryText.localeCompare(query.trim(), undefined, { sensitivity: 'base' }) === 0) { + return; + } + + // Clear all caches + this._currentJsonSchema = {}; + this._currentRawDocuments = []; + this._currentQueryPlannerInfo = undefined; + this._currentExecutionTime = undefined; + this._currentDocumentsReturned = undefined; + + this._currentQueryText = query.trim(); + } + + // NEW: Get query planner info (Stage 1) + // This method handles the "clean query" execution for insights (without skip/limit) + public async getQueryPlannerInfo(databaseName: string, collectionName: string): Promise { + if (this._currentQueryPlannerInfo) { + return this._currentQueryPlannerInfo; + } + + // Extract base query components from current query + // Note: This assumes the query is stored in a parseable format in ClusterSession + const baseQuery = this.extractBaseQuery(); // Returns { filter, projection, sort } without skip/limit + + // Run explain("queryPlanner") with clean query (no skip/limit) + // This provides insights for the full query scope, not just one page + // Access QueryInsightsApis through ClustersClient + this._currentQueryPlannerInfo = await this._client.queryInsightsApis.explainFind( + databaseName, + collectionName, + baseQuery.filter, + { + verbosity: 'queryPlanner', + sort: baseQuery.sort, + projection: baseQuery.projection, + // Intentionally omit skip and limit for full query insights + }, + ); + + return this._currentQueryPlannerInfo; + } + + // NEW: Extract base query without paging modifiers + private extractBaseQuery(): { filter?: unknown; projection?: unknown; sort?: unknown } { + // Implementation extracts filter, projection, sort from current query + // Strips skip and limit for accurate full-query analysis + // Details depend on how query is stored in ClusterSession + return { + filter: this._currentFilter, + projection: this._currentProjection, + sort: this._currentSort, + // skip and limit intentionally omitted + }; + } + + // NEW: Store query metadata + public setQueryMetadata(executionTime: number, documentsReturned: number): void { + this._currentExecutionTime = executionTime; + this._currentDocumentsReturned = documentsReturned; + } + + // NEW: Get query metadata + public getQueryMetadata(): { executionTime?: number; documentsReturned?: number } { + return { + executionTime: this._currentExecutionTime, + documentsReturned: this._currentDocumentsReturned, + }; + } +} +``` + +### ClustersClient Extensions for Stage 1 + +**Architecture Pattern**: Follow the `LlmEnhancedFeatureApis.ts` pattern + +QueryInsightsApis is instantiated in `ClustersClient`, similar to how `llmEnhancedFeatureApis` is instantiated. This follows the established pattern: + +1. ClustersClient owns the MongoClient instance +2. Feature-specific API classes (like QueryInsightsApis) are instantiated in ClustersClient +3. These APIs are exposed as public properties for use by ClusterSession and other consumers + +**Implementation in ClustersClient**: + +```typescript +import { QueryInsightsApis } from './QueryInsightsApis'; + +export class ClustersClient { + private readonly _mongoClient: MongoClient; + + // Existing feature APIs + public readonly llmEnhancedFeatureApis: ReturnType; + + // NEW: Query Insights APIs + public readonly queryInsightsApis: QueryInsightsApis; + + constructor(/* existing parameters */) { + // Existing initialization... + this._mongoClient = new MongoClient(/* ... */); + + // Initialize feature APIs + this.llmEnhancedFeatureApis = llmEnhancedFeatureApis(this._mongoClient); + + // NEW: Initialize Query Insights APIs + this.queryInsightsApis = new QueryInsightsApis(this._mongoClient); + } + + // ... rest of the class +} +``` + +**QueryInsightsApis Implementation**: `src/documentdb/QueryInsightsApis.ts` (already exists, no changes needed) + +The QueryInsightsApis class already follows the correct pattern: + +```typescript +import { type Document, type Filter, type MongoClient, type Sort } from 'mongodb'; + +/** + * Options for explain operations on find queries + */ +export interface ExplainFindOptions { + // Query filter + filter?: Filter; + // Sort specification + sort?: Sort; + // Projection specification + projection?: Document; + // Number of documents to skip (omit for Stage 1 insights) + skip?: number; + // Maximum number of documents to return (omit for Stage 1 insights) + limit?: number; +} + +/** + * Explain verbosity levels + */ +export type ExplainVerbosity = 'queryPlanner' | 'executionStats' | 'allPlansExecution'; + +/** + * Explain result from MongoDB/DocumentDB + */ +export interface ExplainResult { + // Query planner information + queryPlanner: { + // MongoDB/DocumentDB version + mongodbVersion?: string; + // Namespace (database.collection) + namespace: string; + // Whether index filter was set + indexFilterSet: boolean; + // Parsed query + parsedQuery?: Document; + // Winning plan + winningPlan: Document; + // Rejected plans + rejectedPlans?: Document[]; + }; + // Execution statistics (only with executionStats or allPlansExecution) + executionStats?: { + // Execution success status + executionSuccess: boolean; + // Number of documents returned + nReturned: number; + // Execution time in milliseconds + executionTimeMillis: number; + // Total number of keys examined + totalKeysExamined: number; + // Total number of documents examined + totalDocsExamined: number; + // Detailed execution stages + executionStages: Document; + }; + // Server information + serverInfo?: { + host: string; + port: number; + version: string; + }; + // DocumentDB platform indicator + explainVersion?: number; + // Operation status + ok: number; +} + +/** + * Query Insights APIs for explain operations + * Follows the architecture pattern established in LlmEnhancedFeatureApis.ts + */ +export class QueryInsightsApis { + constructor(private readonly mongoClient: MongoClient) {} + + /** + * Explain a find query with specified verbosity + * @param databaseName - Name of the database + * @param collectionName - Name of the collection + * @param options - Query options including filter, sort, projection, skip, and limit + * @param verbosity - Explain verbosity level (queryPlanner, executionStats, or allPlansExecution) + * @returns Detailed explain result + */ + async explainFind( + databaseName: string, + collectionName: string, + options: ExplainFindOptions = {}, + verbosity: ExplainVerbosity = 'queryPlanner', + ): Promise { + const db = this.mongoClient.db(databaseName); + + const { filter = {}, sort, projection, skip, limit } = options; + + const findCmd: Document = { + find: collectionName, + filter, + }; + + // Add optional fields if they are defined + if (sort !== undefined) { + findCmd.sort = sort; + } + + if (projection !== undefined) { + findCmd.projection = projection; + } + + if (skip !== undefined && skip >= 0) { + findCmd.skip = skip; + } + + if (limit !== undefined && limit >= 0) { + findCmd.limit = limit; + } + + const command: Document = { + explain: findCmd, + verbosity, + }; + + const explainResult = await db.command(command); + + return explainResult as ExplainResult; + } +} +``` + +**Usage in ClusterSession**: + +```typescript +// In ClusterSession constructor or initialization +this._queryInsightsApis = new QueryInsightsApis(this._client._mongoClient); + +// When calling explain for Stage 1 (without skip/limit) +const explainResult = await this._queryInsightsApis.explainFind( + databaseName, + collectionName, + { + filter: baseQuery.filter, + sort: baseQuery.sort, + projection: baseQuery.projection, + // Intentionally omit skip and limit for full query insights + }, + 'queryPlanner', +); +``` + +### Mock Data Structure + +```typescript +// Example mock response (Stage 1) +{ + executionTime: 23.433235, // ms (client-side measurement) + documentsReturned: 2, + stages: [ + { + stage: "IXSCAN", + name: "Index Scan", + nReturned: 2, + indexName: "user_id_1", + indexBounds: "user_id: [1234, 1234]" + }, + { + stage: "FETCH", + name: "Fetch", + nReturned: 2 + }, + { + stage: "PROJECTION", + name: "Projection", + nReturned: 2 + } + ], + efficiencyAnalysis: { + executionStrategy: "Index Scan + Fetch", + indexUsed: "user_id_1", + hasInMemorySort: false + } +} +``` + +--- + +## Stage 2: Detailed Execution Analysis (executionStats) + +### Purpose + +**Design Goal** (from performance-advisor.md): Run `explain("executionStats")` to gather authoritative counts and timing. Execute the winning plan to completion and return authoritative runtime metrics. + +Provides comprehensive execution metrics by re-running the query with `executionStats` mode. This reveals actual performance characteristics and enables accurate performance rating. + +### Data Sources + +- MongoDB API `explain("executionStats")` command +- Execution statistics from all stages +- Index usage metrics + +### Router Endpoint + +**Name**: `getQueryInsightsStage1` + +**Type**: `query` (read operation) + +**Input Schema**: + +```typescript +z.object({ + // Empty - relies on RouterContext.sessionId to retrieve query details + // The query parameters are already cached from the initial query execution +}); +``` + +**Context Requirements**: + +- `sessionId`: Used to retrieve cached query details and re-run with executionStats +- `clusterId`: Identifies the DocumentDB cluster/connection to use +- `databaseName` & `collectionName`: Target collection for explain command + +**Implementation Flow**: + +1. Retrieve query details from session cache using `sessionId` +2. Re-run query with `explain("executionStats")` +3. Cache execution stats in session for potential Stage 2 use +4. Transform and return detailed metrics + +**Output Schema**: + +```typescript +{ + // Execution-level metrics + executionTimeMs: number; // Server-reported execution time + totalKeysExamined: number; // Total index keys scanned + totalDocsExamined: number; // Total documents examined + documentsReturned: number; // Final result count + + // Derived efficiency metrics + examinedToReturnedRatio: number; // docsExamined / docsReturned (efficiency indicator) + keysToDocsRatio: number | null; // keysExamined / docsExamined (index selectivity) + + // Execution strategy analysis + executionStrategy: string; // e.g., "Index Scan + Fetch", "Collection Scan", "Covered Query" + indexUsed: boolean; // Whether any index was used + usedIndexNames: string[]; // List of index names utilized + hadInMemorySort: boolean; // Whether sorting happened in memory (expensive) + hadCollectionScan: boolean; // Whether full collection scan occurred + + // Performance rating + performanceRating: { + score: 'excellent' | 'good' | 'fair' | 'poor'; + reasons: string[]; // Array of reasons for the rating + concerns: string[]; // Performance concerns identified + }; + + // Detailed stage breakdown + stages: Array<{ + stage: string; + indexName?: string; + keysExamined?: number; + docsExamined?: number; + nReturned?: number; + executionTimeMs?: number; + indexBounds?: string; + sortPattern?: Record; + isBlocking?: boolean; // For SORT stages + }>; + + // Raw executionStats (for debugging/advanced users) + rawExecutionStats: Record; +} +``` + +### Implementation Notes + +**Design Document Alignment**: + +1. **Metrics Row Update** (design doc 3.1): Replace "n/a" with authoritative values + - Execution Time: Server-reported `executionTimeMillis` (prefer over client timing) + - Documents Returned: From `nReturned` + - Keys Examined: From `totalKeysExamined` + - Docs Examined: From `totalDocsExamined` + +2. **Query Efficiency Analysis Card** (design doc 3.2): Now fully populated + - Execution Strategy: Extracted from top-level stage + - Index Used: From IXSCAN stage's `indexName` + - Examined/Returned Ratio: Calculated and formatted + - In-Memory Sort: Detected from SORT stage + - Performance Rating: Calculated based on ratio thresholds + +3. **Execution Details** (design doc 3.3): Extract comprehensive metrics + - Per-stage counters (keysExamined, docsExamined, nReturned) + - Sort & memory indicators + - Covering query detection (no FETCH in executed path) + - Sharded attribution (when applicable) + +4. **Quick Actions** (design doc 3.6): Enable after Stage 2 completes + - Export capabilities + - View raw explain output + +**Technical Implementation**: + +1. **Execution Strategy Determination**: + - "Covered Query": IXSCAN with no FETCH stage (index-only) + - "Index Scan + Fetch": IXSCAN followed by FETCH + - "Collection Scan": COLLSCAN stage present + - "In-Memory Sort": SORT stage with `isBlocking: true` + +2. **Performance Rating Algorithm**: + + The performance rating uses the **efficiency ratio** (returned/examined) where higher values indicate better performance. This is the authoritative algorithm implemented in `src/documentdb/queryInsights/ExplainPlanAnalyzer.ts`. + + ```typescript + /** + * Rating criteria: + * - Excellent: High efficiency (>=50%), indexed, no in-memory sort, fast (<100ms) + * - Good: Moderate efficiency (>=10%), indexed or fast (<500ms) + * - Fair: Low efficiency (>=1%) + * - Poor: Very low efficiency (<1%) or collection scan with low efficiency + */ + function calculatePerformanceRating( + executionTimeMs: number, + efficiencyRatio: number, + hasInMemorySort: boolean, + isIndexScan: boolean, + isCollectionScan: boolean, + ): 'excellent' | 'good' | 'fair' | 'poor' { + // Poor: Collection scan with very low efficiency + if (isCollectionScan && efficiencyRatio < 0.01) { + return 'poor'; + } + + // Excellent: High efficiency, uses index, no blocking sort, fast execution + if (efficiencyRatio >= 0.5 && isIndexScan && !hasInMemorySort && executionTimeMs < 100) { + return 'excellent'; + } + + // Good: Moderate efficiency with index usage or fast execution + if (efficiencyRatio >= 0.1 && (isIndexScan || executionTimeMs < 500)) { + return 'good'; + } + + // Fair: Low efficiency but acceptable + if (efficiencyRatio >= 0.01) { + return 'fair'; + } + + return 'poor'; + } + + function calculateEfficiencyRatio(returned: number, examined: number): number { + if (examined === 0) return returned === 0 ? 1.0 : 0.0; + return returned / examined; + } + ``` + + **Key Metrics**: + - **Efficiency Ratio**: `returned / examined` (range: 0.0 to 1.0+, higher is better) + - **Execution Time**: Server-reported milliseconds + - **Index Usage**: Whether any index was used (IXSCAN stage) + - **Collection Scan**: Whether full collection scan occurred (COLLSCAN stage) + - **In-Memory Sort**: Whether blocking sort happened (SORT stage) + + **Thresholds**: + - `efficiencyRatio >= 0.5` (50%+) → Excellent potential + - `efficiencyRatio >= 0.1` (10%+) → Good potential + - `efficiencyRatio >= 0.01` (1%+) → Fair potential + - `efficiencyRatio < 0.01` (<1%) → Poor + +3. **Stages Extraction**: Recursively traverse `executionStats.executionStages` tree + +4. **Using @mongodb-js/explain-plan-helper for Stage 2**: + + ```typescript + import { ExplainPlan } from '@mongodb-js/explain-plan-helper'; + + function analyzeExecutionStats(explainResult: unknown) { + const plan = new ExplainPlan(explainResult); + + // Get high-level metrics directly + const metrics = { + executionTimeMs: plan.executionTimeMillis, + totalKeysExamined: plan.totalKeysExamined, + totalDocsExamined: plan.totalDocsExamined, + documentsReturned: plan.nReturned, + + // Derived metrics + examinedToReturnedRatio: plan.totalDocsExamined / plan.nReturned, + + // Query characteristics + hadCollectionScan: plan.isCollectionScan, + hadInMemorySort: plan.inMemorySort, + indexUsed: plan.isIndexScan, + isCoveredQuery: plan.isCovered, + }; + + // Calculate performance rating using the metrics + const performanceRating = calculatePerformanceRating(metrics); + + return { ...metrics, performanceRating }; + } + ``` + + This approach leverages the library's pre-built analysis rather than manually parsing the execution tree. + +5. **Extended Stage Information Extraction** (for Query Plan Overview): + + We can extract stage-specific details for UI display: + + ```typescript + import type { Stage } from '@mongodb-js/explain-plan-helper'; + + /** + * Extended information for a single stage (for UI display) + */ + export interface ExtendedStageInfo { + stageId: string; + stageName: string; + properties: Record; + } + + /** + * Extracts extended stage information for query plan overview visualization + * + * @param stage - Stage from ExplainPlan.executionStages + * @param stageId - Unique identifier for the stage + * @returns Extended information with properties for UI display + */ + function extractExtendedStageInfo(stage: Stage, stageId: string): ExtendedStageInfo { + const stageName = stage.stage || stage.shardName || 'UNKNOWN'; + const properties = extractStageProperties(stageName, stage); + + return { + stageId, + stageName, + properties, + }; + } + + /** + * Extracts properties for a specific stage type + * Maps stage type to relevant properties for UI display + * + * Stage-specific properties: + * - IXSCAN/EXPRESS_IXSCAN: Index name, multi-key indicator, bounds, keys examined + * - PROJECTION: Transform specification + * - COLLSCAN: Documents examined, scan direction + * - FETCH: Documents examined + * - SORT: Sort pattern, memory usage, disk spill indicator + * - LIMIT/SKIP: Limit/skip amounts + * - TEXT stages: Search string, parsed query + * - GEO_NEAR: Key pattern, index info + * - COUNT/DISTINCT: Index usage, keys examined + * - IDHACK: Keys/docs examined + * - SHARDING_FILTER: Chunks skipped + * - SHARD_MERGE/SINGLE_SHARD: Shard count + * - DELETE/UPDATE: Documents modified + */ + function extractStageProperties( + stageName: string, + stage: Stage, + ): Record { + switch (stageName) { + case 'IXSCAN': + case 'EXPRESS_IXSCAN': + return { + 'Index Name': stage.indexName, + 'Multi Key Index': stage.isMultiKey, + 'Index Bounds': stage.indexBounds ? JSON.stringify(stage.indexBounds) : undefined, + 'Keys Examined': stage.keysExamined, + }; + + case 'PROJECTION': + case 'PROJECTION_SIMPLE': + case 'PROJECTION_DEFAULT': + case 'PROJECTION_COVERED': + return { + 'Transform by': stage.transformBy ? JSON.stringify(stage.transformBy) : undefined, + }; + + case 'COLLSCAN': + return { + 'Documents Examined': stage.docsExamined, + Direction: stage.direction, // forward or backward + }; + + case 'FETCH': + return { + 'Documents Examined': stage.docsExamined, + }; + + case 'SORT': + case 'SORT_KEY_GENERATOR': + return { + 'Sort Pattern': stage.sortPattern ? JSON.stringify(stage.sortPattern) : undefined, + 'Memory Limit': stage.memLimit, + 'Memory Usage': stage.memUsage, + 'Spilled to Disk': stage.usedDisk, + }; + + case 'LIMIT': + return { + 'Limit Amount': stage.limitAmount, + }; + + case 'SKIP': + return { + 'Skip Amount': stage.skipAmount, + }; + + case 'TEXT': + case 'TEXT_MATCH': + case 'TEXT_OR': + return { + 'Search String': stage.searchString, + 'Parsed Text Query': stage.parsedTextQuery ? JSON.stringify(stage.parsedTextQuery) : undefined, + }; + + case 'GEO_NEAR_2D': + case 'GEO_NEAR_2DSPHERE': + return { + 'Key Pattern': stage.keyPattern ? JSON.stringify(stage.keyPattern) : undefined, + 'Index Name': stage.indexName, + 'Index Version': stage.indexVersion, + }; + + case 'COUNT': + case 'COUNT_SCAN': + return { + 'Index Name': stage.indexName, + 'Keys Examined': stage.keysExamined, + }; + + case 'DISTINCT_SCAN': + return { + 'Index Name': stage.indexName, + 'Index Bounds': stage.indexBounds ? JSON.stringify(stage.indexBounds) : undefined, + 'Keys Examined': stage.keysExamined, + }; + + case 'IDHACK': + return { + 'Keys Examined': stage.keysExamined, + 'Documents Examined': stage.docsExamined, + }; + + case 'SHARDING_FILTER': + return { + 'Chunks Skipped': stage.chunkSkips, + }; + + case 'CACHED_PLAN': + return { + Cached: true, + }; + + case 'SUBPLAN': + return { + 'Subplan Type': stage.subplanType, + }; + + case 'SHARD_MERGE': + case 'SINGLE_SHARD': + return { + 'Shard Count': stage.shards?.length, + }; + + case 'BATCHED_DELETE': + return { + 'Batch Size': stage.batchSize, + 'Documents Deleted': stage.nWouldDelete, + }; + + case 'DELETE': + case 'UPDATE': + return { + 'Documents Modified': stage.nWouldModify || stage.nWouldDelete, + }; + + default: + // Unknown stage type - return empty properties + return {}; + } + } + + /** + * Recursively extracts extended stage info from the execution stage tree + * This creates a flat list of all stages with their properties for UI display + * + * @param executionStages - Root stage from ExplainPlan + * @returns Array of ExtendedStageInfo for all stages in the tree + */ + function extractAllExtendedStageInfo(executionStages: Stage | undefined): ExtendedStageInfo[] { + if (!executionStages) return []; + + const allStageInfo: ExtendedStageInfo[] = []; + let stageIdCounter = 0; + + function traverse(stage: Stage): void { + const stageId = `stage-${stageIdCounter++}`; + allStageInfo.push(extractExtendedStageInfo(stage, stageId)); + + // Traverse child stages (single input) + if (stage.inputStage) { + traverse(stage.inputStage); + } + + // Traverse child stages (multiple inputs, e.g., $or queries) + if (stage.inputStages) { + stage.inputStages.forEach(traverse); + } + + // Traverse shard stages (sharded queries) + if (stage.shards) { + stage.shards.forEach(traverse); + } + } + + traverse(executionStages); + return allStageInfo; + } + + /** + * Example usage in Stage 2 analysis: + */ + function analyzeExecutionStatsWithExtendedInfo(explainResult: unknown) { + const plan = new ExplainPlan(explainResult); + + // ... existing metrics extraction ... + + // Extract extended stage information for query plan overview + const extendedStageInfo = extractAllExtendedStageInfo(plan.executionStages); + + return { + // ... existing metrics ... + extendedStageInfo, // Add to Stage 2 output for query plan visualization + }; + } + ``` + + **Purpose**: The `extendedStageInfo` provides rich, stage-specific metadata for the Query Plan Overview UI component. Each stage type has relevant properties extracted (e.g., index names for IXSCAN, document counts for COLLSCAN, memory usage for SORT). + + **UI Usage**: In the Query Plan Overview, each stage can display its properties as key-value pairs, making it easy for users to understand what each stage is doing without inspecting raw JSON. + +### ClusterSession Extensions for Stage 2 (previously labeled as Stage 1) + +Add to `ClusterSession` class: + +```typescript +export class ClusterSession { + // Existing properties... + private _currentExecutionStats?: unknown; + + // Update resetCachesIfQueryChanged to clear execution stats + private resetCachesIfQueryChanged(query: string) { + if (this._currentQueryText.localeCompare(query.trim(), undefined, { sensitivity: 'base' }) === 0) { + return; + } + + // Clear all caches including execution stats + this._currentJsonSchema = {}; + this._currentRawDocuments = []; + this._currentQueryPlannerInfo = undefined; + this._currentExecutionStats = undefined; + this._currentExecutionTime = undefined; + this._currentDocumentsReturned = undefined; + + this._currentQueryText = query.trim(); + } + + // NEW: Get execution stats (Stage 2) + public async getExecutionStats(databaseName: string, collectionName: string): Promise { + if (this._currentExecutionStats) { + return this._currentExecutionStats; + } + + // Extract base query without paging modifiers + const baseQuery = this.extractBaseQuery(); + + // Run explain("executionStats") - actually executes the query + // Using QueryInsightsApis (follows LlmEnhancedFeatureApis pattern) + this._currentExecutionStats = await this._queryInsightsApis.explainFind( + databaseName, + collectionName, + { + filter: baseQuery.filter, + sort: baseQuery.sort, + projection: baseQuery.projection, + // Intentionally omit skip and limit for full query insights + }, + 'executionStats', + ); + + return this._currentExecutionStats; + } +} +``` + +**Note**: The `QueryInsightsApis.explainFind()` method added in Stage 1 is reused here with different verbosity level (`executionStats` instead of `queryPlanner`). + +### Mock Data Structure + +```typescript +// Example mock response +{ + executionTimeMs: 2.333, + totalKeysExamined: 2, + totalDocsExamined: 10000, + documentsReturned: 2, + examinedToReturnedRatio: 5000, // 10000 / 2 + keysToDocsRatio: 0.0002, // 2 / 10000 + executionStrategy: "Index Scan + Full Collection Scan", + indexUsed: true, + usedIndexNames: ["user_id_1"], + hadInMemorySort: false, + hadCollectionScan: true, + performanceRating: { + score: 'poor', + reasons: [], + concerns: [ + 'High examined-to-returned ratio (5000:1) indicates inefficient query', + 'Full collection scan performed after index lookup', + 'Only 0.02% of examined documents were returned' + ] + }, + stages: [ + { + stage: "IXSCAN", + indexName: "user_id_1", + keysExamined: 2, + nReturned: 2, + indexBounds: "user_id: [1234, 1234]" + }, + { + stage: "FETCH", + docsExamined: 10000, + nReturned: 2 + }, + { + stage: "PROJECTION", + nReturned: 2 + } + ], + rawExecutionStats: { /* full DocumentDB explain output */ } +} +``` + +--- + +## Stage 3: AI-Powered Recommendations + +### Purpose + +**Design Goal** (from performance-advisor.md): Send collected statistics (query shape + execution metrics) to an AI service for actionable optimization recommendations. This is an opt-in stage triggered by user action. + +Analyzes query performance using AI and provides actionable optimization recommendations, including index suggestions and educational content. + +### Data Sources + +- AI backend service (external) +- Collection statistics +- Index statistics +- Stage 1 execution stats + +### Router Endpoint + +**Name**: `getQueryInsightsStage3` + +**Type**: `query` (read operation, but triggers AI analysis) + +**Input Schema**: + +```typescript +z.object({ + // Empty - relies on RouterContext.sessionId + // Query details and execution stats are retrieved from session cache +}); +``` + +**Context Requirements**: + +- `sessionId`: Used to retrieve query details from session cache +- `clusterId`: DocumentDB connection identifier +- `databaseName` & `collectionName`: Target collection + +**Implementation Flow**: + +1. Retrieve query details from session cache using `sessionId` +2. Call AI backend with minimal payload (query, database, collection) +3. AI backend collects additional data (collection stats, index stats, execution stats) independently +4. Transform AI response for UI (formatted as animated suggestion cards) +5. Cache AI recommendations in session + +**Note**: The AI backend is responsible for collecting collection statistics, index information, and execution metrics. In future releases, the extension may provide this data directly to reduce backend workload, but this is not in scope for the upcoming release. + +**Backend AI Request Payload**: + +```typescript +{ + query: string; // The DocumentDB query + databaseName: string; // Database name + collectionName: string; // Collection name +} +``` + +**Backend AI Response**: + +The AI backend returns optimization recommendations. The response schema is defined in the tRPC router and automatically validated. + +```typescript +interface OptimizationRecommendations { + analysis: string; + improvements: Array<{ + action: 'create' | 'drop' | 'none' | 'modify'; + indexSpec: Record; + indexOptions?: Record; + mongoShell: string; + justification: string; + priority: 'high' | 'medium' | 'low'; + risks?: string; + }>; + verification: string; +} +``` + +**Router Output** (Transformed for UI): + +The router transforms the AI response into UI-friendly format with action buttons. Button payloads include all necessary context for performing actions (e.g., `clusterId`, `databaseName`, `collectionName`, plus action-specific data). + +Example transformation: + +```typescript +{ + analysisCard: { + type: 'analysis'; + content: string; // The overall analysis from AI + } + + improvementCards: Array<{ + type: 'improvement'; + cardId: string; // Unique identifier + + // Card header + title: string; // e.g., "Recommendation: Create Index" + priority: 'high' | 'medium' | 'low'; + + // Main content + description: string; // Justification field + recommendedIndex: string; // Stringified indexSpec, e.g., "{ user_id: 1 }" + recommendedIndexDetails: string; // Additional explanation about the index + + // Additional info + details: string; // Risks or additional considerations + mongoShellCommand: string; // The mongoShell command to execute + + // Action buttons with complete context for execution + primaryButton: { + label: string; // e.g., "Create Index" + actionId: string; // e.g., "createIndex" + payload: { + // All context needed to perform the action + clusterId: string; + databaseName: string; + collectionName: string; + action: 'create' | 'drop' | 'modify'; + indexSpec: Record; + indexOptions?: Record; + mongoShell: string; + }; + }; + + secondaryButton?: { + label: string; // e.g., "Learn More" + actionId: string; // e.g., "learnMore" + payload: { + topic: string; // e.g., "compound-indexes" + }; + }; + }>; + + verificationSteps: string; // How to verify improvements +} +``` + +### Transformation Logic + +```typescript +function transformAIResponseForUI(aiResponse: OptimizationRecommendations, context: RouterContext) { + const analysisCard = { + type: 'analysis', + content: aiResponse.analysis, + }; + + const improvementCards = aiResponse.improvements.map((improvement, index) => { + const actionVerb = { + create: 'Create', + drop: 'Drop', + modify: 'Modify', + none: 'No Action', + }[improvement.action]; + + const indexSpecStr = JSON.stringify(improvement.indexSpec, null, 2); + + return { + type: 'improvement', + cardId: `improvement-${index}`, + title: `Recommendation: ${actionVerb} Index`, + priority: improvement.priority, + description: improvement.justification, + recommendedIndex: indexSpecStr, + recommendedIndexDetails: generateIndexExplanation(improvement), + details: improvement.risks || 'Additional write and storage overhead for maintaining a new index.', + mongoShellCommand: improvement.mongoShell, + primaryButton: { + label: `${actionVerb} Index`, + actionId: + improvement.action === 'create' ? 'createIndex' : improvement.action === 'drop' ? 'dropIndex' : 'modifyIndex', + payload: { + // Include all context needed to execute the action + clusterId: context.clusterId, + databaseName: context.databaseName, + collectionName: context.collectionName, + action: improvement.action, + indexSpec: improvement.indexSpec, + indexOptions: improvement.indexOptions, + mongoShell: improvement.mongoShell, + }, + }, + secondaryButton: { + label: 'Learn More', + actionId: 'learnMore', + payload: { + topic: 'index-optimization', + }, + }, + }; + }); + + return { + analysisCard, + improvementCards, + verificationSteps: aiResponse.verification, + }; +} + +function generateIndexExplanation(improvement) { + const fields = Object.keys(improvement.indexSpec).join(', '); + + switch (improvement.action) { + case 'create': + return `An index on ${fields} would allow direct lookup of matching documents and eliminate full collection scans.`; + case 'drop': + return `This index on ${fields} is not being used and adds unnecessary overhead to write operations.`; + case 'modify': + return `Optimizing the index on ${fields} can improve query performance by better matching the query pattern.`; + default: + return 'No index changes needed at this time.'; + } +} +``` + +### Mock Data Structure + +```typescript +// Example mock response (transformed) +{ + analysisCard: { + type: 'analysis', + content: 'Your query performs a full collection scan after the index lookup, examining 10,000 documents to return only 2. This indicates the index is not selective enough or additional filtering is happening after the index stage.' + }, + + improvementCards: [ + { + type: 'improvement', + cardId: 'improvement-0', + title: 'Recommendation: Create Index', + priority: 'high', + description: 'COLLSCAN examined 10000 docs vs 2 returned (totalKeysExamined: 2). A compound index on { user_id: 1, status: 1 } will eliminate the full scan by supporting both the equality filter and the additional filtering condition.', + recommendedIndex: '{\n "user_id": 1,\n "status": 1\n}', + recommendedIndexDetails: 'An index on user_id, status would allow direct lookup of matching documents and eliminate full collection scans.', + details: 'Additional write and storage overhead for maintaining a new index. Index size estimated at ~50MB for current collection size.', + mongoShellCommand: 'db.users.createIndex({ user_id: 1, status: 1 })', + primaryButton: { + label: 'Create Index', + actionId: 'createIndex', + payload: { + action: 'create', + indexSpec: { user_id: 1, status: 1 }, + indexOptions: {}, + mongoShell: 'db.users.createIndex({ user_id: 1, status: 1 })' + } + }, + secondaryButton: { + label: 'Learn More', + actionId: 'learnMore', + payload: { + topic: 'compound-indexes' + } + } + } + ], + + verificationSteps: 'After creating the index, run the same query and verify that: 1) docsExamined equals documentsReturned, 2) the execution plan shows IXSCAN using the new index, 3) no COLLSCAN stage appears in the plan.', + + metadata: { + collectionName: 'users', + collectionStats: { count: 50000, size: 10485760 }, + indexStats: [ + { name: '_id_', key: { _id: 1 } }, + { name: 'user_id_1', key: { user_id: 1 } } + ], + executionStats: { /* ... */ }, + derived: { + totalKeysExamined: 2, + totalDocsExamined: 10000, + keysToDocsRatio: 0.0002, + usedIndex: 'user_id_1' + } + } +} +``` + +### ClusterSession Extensions for Stage 3 + +**Architecture Decision: Option 3 - Service-Specific Cache Methods** + +After evaluating multiple caching architecture options, we've chosen to follow the **existing pattern** established by `getQueryPlannerInfo()` and `getExecutionStats()`: + +**Rejected Options:** + +- ❌ **Option 1**: Self-contained service with internal caching - breaks session lifecycle, can't leverage query-based invalidation +- ❌ **Option 2**: Generic key/value store - loses type safety, unclear domain semantics + +**Selected Option 3: Follow Established Pattern** + +ClusterSession exposes typed, domain-specific cache methods that: + +- ✅ Are type-safe (no `unknown` in public API) +- ✅ Integrate with existing `resetCachesIfQueryChanged()` invalidation +- ✅ Keep services stateless (ClustersClient owns service instances) +- ✅ Match the architecture of QueryPlanner and ExecutionStats caching +- ✅ Are easy to test and understand + +**Cache Structure with Timestamps:** + +All Query Insights caches include timestamps for potential future features: + +```typescript +private _queryPlannerCache?: { result: Document; timestamp: number }; +private _executionStatsCache?: { result: Document; timestamp: number }; +private _aiRecommendationsCache?: { result: unknown; timestamp: number }; +``` + +**Why timestamps?** + +- **Current use**: None - cache invalidation is purely query-based via `resetCachesIfQueryChanged()` +- **Future use cases**: + - Time-based expiration (e.g., "re-run explain if > 5 minutes old") + - Performance monitoring (track how long cached data has been reused) + - Diagnostics (show users when explain was last collected) + - Staleness warnings for production monitoring scenarios +- **Cost**: Negligible (just a number per cache entry) +- **Benefit**: Enables future features without breaking changes to cache structure + +**Implementation:** + +Add to `ClusterSession` class: + +```typescript +export class ClusterSession { + // Existing properties... + /** + * Query Insights caching + * Note: QueryInsightsApis instance is accessed via this._client.queryInsightsApis + * + * Timestamps are included for potential future features: + * - Time-based cache invalidation (e.g., expire after N seconds) + * - Diagnostics (show when explain was collected) + * - Performance monitoring + * + * Currently, cache invalidation is purely query-based via resetCachesIfQueryChanged() + */ + private _queryPlannerCache?: { result: Document; timestamp: number }; + private _executionStatsCache?: { result: Document; timestamp: number }; + private _aiRecommendationsCache?: { result: unknown; timestamp: number }; + + /** + * Gets AI-powered query optimization recommendations + * Caches the result until the query changes + * + * This method follows the same pattern as getQueryPlannerInfo() and getExecutionStats(): + * - Check cache first + * - If not cached, call the AI service via ClustersClient + * - Cache the result with timestamp + * - Return typed recommendations + * + * @param databaseName - Database name + * @param collectionName - Collection name + * @param filter - Query filter + * @param executionStats - Execution statistics from Stage 2 + * @returns AI recommendations for query optimization + * + * @remarks + * This method will be implemented in Phase 3. The AI service is accessed via + * this._client.queryInsightsAIService (similar to queryInsightsApis pattern). + */ + public async getAIRecommendations( + databaseName: string, + collectionName: string, + filter: Document, + executionStats: Document, + ): Promise { + // Check cache + if (this._aiRecommendationsCache) { + return this._aiRecommendationsCache.result as AIRecommendation[]; + } + + // Call AI service via ClustersClient (following QueryInsightsApis pattern) + const recommendations = await this._client.queryInsightsAIService.generateRecommendations( + databaseName, + collectionName, + filter, + executionStats, + ); + + // Cache result with timestamp + this._aiRecommendationsCache = { + result: recommendations, + timestamp: Date.now(), + }; + + return recommendations; + } + + // Update clearQueryInsightsCaches to include AI recommendations + private clearQueryInsightsCaches(): void { + this._queryPlannerCache = undefined; + this._executionStatsCache = undefined; + this._aiRecommendationsCache = undefined; + } +} +``` + +**Type Definitions:** + +```typescript +interface AIRecommendation { + type: 'index' | 'query' | 'schema'; + priority: 'high' | 'medium' | 'low'; + title: string; + description: string; + impact: string; + implementation?: string; + verification?: string; +} +``` + +### Using LlmEnhancedFeatureApis for Stage 3 Collection Stats + +For Stage 3, we need collection and index statistics to send to the AI service. These methods already exist in `LlmEnhancedFeatureApis.ts`: + +**Collection Statistics**: Use `llmEnhancedFeatureApis.getCollectionStats()` + +```typescript +// In ClusterSession or router handler +const collectionStats = await this._llmEnhancedFeatureApis.getCollectionStats(databaseName, collectionName); + +// Returns CollectionStats interface: +// { +// ns: string; +// count: number; +// size: number; +// avgObjSize: number; +// storageSize: number; +// nindexes: number; +// totalIndexSize: number; +// indexSizes: Record; +// } +``` + +**Index Statistics**: Use `llmEnhancedFeatureApis.getIndexStats()` + +```typescript +// In ClusterSession or router handler +const indexStats = await this._llmEnhancedFeatureApis.getIndexStats(databaseName, collectionName); + +// Returns IndexStats[] interface: +// Array<{ +// name: string; +// key: Record; +// host: string; +// accesses: { +// ops: number; +// since: Date; +// }; +// }> +``` + +**Note**: No new methods need to be added to ClustersClient for Stage 3. The required functionality already exists in `LlmEnhancedFeatureApis.ts`. + +### Transformation Logic for AI Response + +```typescript +function transformAIResponseForUI(aiResponse: OptimizationRecommendations) { + const analysisCard = { + type: 'analysis', + content: aiResponse.analysis, + }; + + const improvementCards = aiResponse.improvements.map((improvement, index) => { + const actionVerb = { + create: 'Create', + drop: 'Drop', + modify: 'Modify', + none: 'No Action', + }[improvement.action]; + + const indexSpecStr = JSON.stringify(improvement.indexSpec, null, 2); + + return { + type: 'improvement', + cardId: `improvement-${index}`, + title: `Recommendation: ${actionVerb} Index`, + priority: improvement.priority, + description: improvement.justification, + recommendedIndex: indexSpecStr, + recommendedIndexDetails: generateIndexExplanation(improvement), + details: improvement.risks || 'Additional write and storage overhead for maintaining a new index.', + mongoShellCommand: improvement.mongoShell, + primaryButton: { + label: `${actionVerb} Index`, + actionId: + improvement.action === 'create' ? 'createIndex' : improvement.action === 'drop' ? 'dropIndex' : 'modifyIndex', + payload: { + action: improvement.action, + indexSpec: improvement.indexSpec, + indexOptions: improvement.indexOptions, + mongoShell: improvement.mongoShell, + }, + }, + secondaryButton: { + label: 'Learn More', + actionId: 'learnMore', + payload: { + topic: 'index-optimization', + }, + }, + }; + }); + + return { + analysisCard, + improvementCards, + verificationSteps: aiResponse.verification, + metadata: aiResponse.metadata, + }; +} + +function generateIndexExplanation(improvement) { + const fields = Object.keys(improvement.indexSpec).join(', '); + + switch (improvement.action) { + case 'create': + return `An index on ${fields} would allow direct lookup of matching documents and eliminate full collection scans.`; + case 'drop': + return `This index on ${fields} is not being used and adds unnecessary overhead to write operations.`; + case 'modify': + return `Optimizing the index on ${fields} can improve query performance by better matching the query pattern.`; + default: + return 'No index changes needed at this time.'; + } +} +``` + +--- + +## Implementation Details + +### ClusterSession Integration + +The `ClusterSession` class (from `src/documentdb/ClusterSession.ts`) will be the primary source for gathering query insights data. Key points: + +**Why ClusterSession?** + +- Already encapsulates the DocumentDB client connection +- Contains cached query results (`_currentRawDocuments`) +- Tracks JSON schema for the current query (`_currentJsonSchema`) +- **Automatically resets caches when query changes** via `resetCachesIfQueryChanged()` +- Provides a natural place to store explain plan results alongside query data + +**Cache Lifecycle Alignment**: + +The existing `resetCachesIfQueryChanged()` method in ClusterSession already invalidates caches when the query text changes. We extend this to also clear query insights caches (explained in each stage section above). + +**ClusterSession Extensions Summary**: + +The extensions to `ClusterSession` are documented in each stage section: + +- **Stage 1**: Adds `getQueryPlannerInfo()`, `setQueryMetadata()`, `getQueryMetadata()`, and initializes `QueryInsightsApis` +- **Stage 2**: Adds `getExecutionStats()` +- **Stage 3**: Adds `cacheAIRecommendations()`, `getCachedAIRecommendations()` + +All methods leverage the existing cache invalidation mechanism via `resetCachesIfQueryChanged()`. + +**QueryInsightsApis Class** (new file: `src/documentdb/client/QueryInsightsApis.ts`): + +Following the `LlmEnhancedFeatureApis.ts` pattern, explain-related functionality is implemented in a dedicated class: + +- Takes `MongoClient` in constructor +- Implements `explainFind()` with proper TypeScript interfaces +- Supports all explain verbosity levels: 'queryPlanner', 'executionStats', 'allPlansExecution' +- Handles filter, sort, projection, skip, and limit parameters +- Returns properly typed `ExplainResult` interface + +**Benefits of This Architecture**: + +1. ✅ **Consistent with existing patterns** (follows `LlmEnhancedFeatureApis.ts`) +2. ✅ **Type safety** with TypeScript interfaces for all inputs/outputs +3. ✅ **Separation of concerns** (explain logic separate from ClusterSession) +4. ✅ **Testability** (QueryInsightsApis can be unit tested independently) +5. ✅ **Reusability** across different contexts if needed + +**Benefits of Using ClusterSession**: + +1. ✅ **Automatic cache invalidation** when query changes (already implemented) +2. ✅ **Single source of truth** for query-related data +3. ✅ **Natural lifecycle management** tied to the session +4. ✅ **Access to DocumentDB client** for explain commands +5. ✅ **Schema tracking** already in place for enriched insights +6. ✅ **Consistent with existing architecture** (no new abstraction layers needed) + +### Router File Structure + +```typescript +// src/webviews/documentdb/collectionView/collectionViewRouter.ts + +export const collectionsViewRouter = router({ + // ... existing endpoints ... + + /** + * Stage 1: Initial Performance View + * + * Returns immediately available information after query execution. + * Uses sessionId from context to retrieve ClusterSession and cached data. + * Corresponds to design doc section 2: "Initial Performance View (Cheap Data + Query Plan)" + * + * Context required: sessionId, databaseName, collectionName + */ + getQueryInsightsStage1: publicProcedure + .use(trpcToTelemetry) + .input(z.object({})) // Empty - uses RouterContext + .query(async ({ ctx }) => { + const { sessionId, databaseName, collectionName } = ctx; + + // Get ClusterSession (contains all cached query data) + const clusterSession = ClusterSession.getSession(sessionId); + + // Get cached metadata (execution time, documents returned) + const metadata = clusterSession.getQueryMetadata(); + + // Get or fetch query planner info (cached after first call) + const queryPlannerInfo = await clusterSession.getQueryPlannerInfo(databaseName, collectionName); + + // Transform and return Stage 1 data + return transformStage1Data(metadata, queryPlannerInfo); + }), + + /** + * Stage 2: Detailed Execution Analysis + * + * Re-runs query with explain("executionStats") using ClusterSession. + * Results are cached in ClusterSession and cleared when query changes. + * Corresponds to design doc section 3: "Detailed Execution Analysis (executionStats)" + * + * Context required: sessionId, clusterId, databaseName, collectionName + */ + getQueryInsightsStage1: publicProcedure + .use(trpcToTelemetry) + .input(z.object({})) // Empty - uses RouterContext + .query(async ({ ctx }) => { + const { sessionId, databaseName, collectionName } = ctx; + + // Get ClusterSession + const clusterSession = ClusterSession.getSession(sessionId); + + // Get execution stats (cached if already fetched, otherwise runs explain) + const executionStats = await clusterSession.getExecutionStats(databaseName, collectionName); + + // Transform and return Stage 2 data with performance analysis + return transformStage2Data(executionStats); + }), + + /** + * Stage 3: AI-Powered Recommendations + * + * Analyzes query performance using AI backend. + * Leverages ClusterSession for: + * - Cached execution stats (from Stage 2) + * - JSON schema information + * - Query metadata + * Corresponds to design doc section 4: "AI-Powered Recommendations" + * + * Context required: sessionId, clusterId, databaseName, collectionName + */ + getQueryInsightsStage2: publicProcedure + .use(trpcToTelemetry) + .input(z.object({})) // Empty - uses RouterContext + .query(async ({ ctx }) => { + const { sessionId, databaseName, collectionName } = ctx; + + // Get ClusterSession + const clusterSession = ClusterSession.getSession(sessionId); + + // Check for cached AI recommendations + const cached = clusterSession.getCachedAIRecommendations(); + if (cached) { + return cached; + } + + // Get execution stats (from cache or fetch) + const executionStats = await clusterSession.getExecutionStats(databaseName, collectionName); + + // Get collection stats from client + const client = clusterSession.getClient(); + const collectionStats = await client.getCollectionStats(databaseName, collectionName); + const indexStats = await client.getIndexStats(databaseName, collectionName); + + // Get current schema (already tracked by ClusterSession) + const schema = clusterSession.getCurrentSchema(); + + // Call AI backend + const aiResponse = await callAIBackend({ + sessionId, + databaseName, + collectionName, + collectionStats, + indexStats, + executionStats, + schema, + derived: calculateDerivedMetrics(executionStats), + }); + + // Transform response for UI + const transformed = transformAIResponseForUI(aiResponse); + + // Cache in ClusterSession (cleared on query change) + clusterSession.cacheAIRecommendations(transformed); + + return transformed; + }), + + /** + * Helper endpoint: Store Query Metadata + * + * Called after query execution to store metadata in ClusterSession. + * ClusterSession handles cache invalidation when query changes. + */ + storeQueryMetadata: publicProcedure + .use(trpcToTelemetry) + .input( + z.object({ + executionTime: z.number(), + documentsReturned: z.number(), + }), + ) + .mutation(async ({ input, ctx }) => { + const { sessionId } = ctx; + + // Get ClusterSession + const clusterSession = ClusterSession.getSession(sessionId); + + // Store metadata (will be cleared if query changes) + clusterSession.setQueryMetadata(input.executionTime, input.documentsReturned); + + return { success: true }; + }), +}); +``` + +### Mock Data Strategy + +For initial implementation, create helper functions that return realistic mock data matching the design document examples: + +```typescript +// Mock data helpers (temporary, for development) +function getMockStage1Data() { + return { + executionTime: 23.433235, + documentsReturned: 2, + queryPlannerInfo: { + winningPlan: { + stage: 'FETCH', + inputStage: { + stage: 'IXSCAN', + indexName: 'user_id_1', + }, + }, + rejectedPlans: [], + namespace: 'mydb.users', + indexFilterSet: false, + parsedQuery: { user_id: { $eq: 1234 } }, + plannerVersion: 1, + }, + stages: [ + { stage: 'IXSCAN', indexName: 'user_id_1', indexBounds: 'user_id: [1234, 1234]' }, + { stage: 'FETCH' }, + { stage: 'PROJECTION' }, + ], + }; +} + +function getMockStage1Data() { + return { + executionTimeMs: 2.333, + totalKeysExamined: 2, + totalDocsExamined: 10000, + documentsReturned: 2, + examinedToReturnedRatio: 5000, + keysToDocsRatio: 0.0002, + executionStrategy: 'Index Scan + Full Collection Scan', + indexUsed: true, + usedIndexNames: ['user_id_1'], + hadInMemorySort: false, + hadCollectionScan: true, + performanceRating: { + score: 'poor', + reasons: [], + concerns: [ + 'High examined-to-returned ratio (5000:1) indicates inefficient query', + 'Full collection scan performed after index lookup', + 'Only 0.02% of examined documents were returned', + ], + }, + stages: [ + { + stage: 'IXSCAN', + indexName: 'user_id_1', + keysExamined: 2, + nReturned: 2, + indexBounds: 'user_id: [1234, 1234]', + }, + { + stage: 'FETCH', + docsExamined: 10000, + nReturned: 2, + }, + { + stage: 'PROJECTION', + nReturned: 2, + }, + ], + rawExecutionStats: {}, + }; +} + +function getMockStage2Data() { + return { + analysisCard: { + type: 'analysis', + content: + 'Your query performs a full collection scan after the index lookup, examining 10,000 documents to return only 2. This indicates the index is not selective enough or additional filtering is happening after the index stage.', + }, + improvementCards: [ + { + type: 'improvement', + cardId: 'improvement-0', + title: 'Recommendation: Create Index', + priority: 'high', + description: + 'COLLSCAN examined 10000 docs vs 2 returned (totalKeysExamined: 2). A compound index on { user_id: 1, status: 1 } will eliminate the full scan.', + recommendedIndex: '{\n "user_id": 1,\n "status": 1\n}', + recommendedIndexDetails: 'An index on user_id, status would allow direct lookup of matching documents.', + details: 'Additional write and storage overhead for maintaining a new index.', + mongoShellCommand: 'db.users.createIndex({ user_id: 1, status: 1 })', + primaryButton: { + label: 'Create Index', + actionId: 'createIndex', + payload: { + action: 'create', + indexSpec: { user_id: 1, status: 1 }, + indexOptions: {}, + mongoShell: 'db.users.createIndex({ user_id: 1, status: 1 })', + }, + }, + secondaryButton: { + label: 'Learn More', + actionId: 'learnMore', + payload: { topic: 'compound-indexes' }, + }, + }, + ], + verificationSteps: 'After creating the index, verify that docsExamined equals documentsReturned.', + metadata: { + collectionName: 'users', + collectionStats: {}, + indexStats: [], + executionStats: {}, + derived: { + totalKeysExamined: 2, + totalDocsExamined: 10000, + keysToDocsRatio: 0.0002, + usedIndex: 'user_id_1', + }, + }, + }; +} +``` + +--- + +## Query Execution Integration + +### Session Initialization Flow + +When a user executes a query in the collection view, the following sequence occurs: + +```typescript +// In the webview (frontend) +async function executeQuery(queryParams) { + // 1. Measure execution time + const startTime = performance.now(); + const results = await trpc.executeQuery.query(queryParams); + const executionTime = performance.now() - startTime; + + // 2. Store metadata in ClusterSession (for query insights) + // Note: sessionId is already in RouterContext, no need to generate new one + await trpc.storeQueryMetadata.mutate({ + executionTime, + documentsReturned: results.length, + }); + + return results; +} + +// When user requests insights (Stage 1 loads automatically) +async function loadStage1Insights() { + // sessionId is automatically available in RouterContext + // ClusterSession already has the query and results cached + // On first call with new query, this triggers explain("queryPlanner") without skip/limit + const insights = await trpc.getQueryInsightsStage1.query({}); + // Display metrics row with initial values + // Show query plan summary + return insights; +} +``` + +### ClusterSession Lifecycle + +The ClusterSession is created when the collection view opens and persists until the view closes: + +```typescript +// When collection view initializes (already implemented) +const sessionId = await ClusterSession.initNewSession(credentialId); + +// This sessionId is then passed in RouterContext for all subsequent calls +// No need to create separate query sessions - ClusterSession handles everything +``` + +### RouterContext Population + +The `RouterContext` is populated at the router middleware level: + +```typescript +// In collectionViewRouter.ts +const withSessionContext = middleware(({ ctx, next }) => { + // sessionId, clusterId, databaseName, collectionName + // are already in the context from the webview's connection state + return next({ + ctx: { + ...ctx, + // Validate required fields + sessionId: nonNullValue(ctx.sessionId, 'ctx.sessionId', 'collectionViewRouter.ts'), + clusterId: nonNullValue(ctx.clusterId, 'ctx.clusterId', 'collectionViewRouter.ts'), + databaseName: nonNullValue(ctx.databaseName, 'ctx.databaseName', 'collectionViewRouter.ts'), + collectionName: nonNullValue(ctx.collectionName, 'ctx.collectionName', 'collectionViewRouter.ts'), + }, + }); +}); + +// Apply to insights endpoints +export const collectionsViewRouter = router({ + getQueryInsightsStage1: publicProcedure.use(withSessionContext).query(...), + getQueryInsightsStage2: publicProcedure.use(withSessionContext).query(...), + getQueryInsightsStage3: publicProcedure.use(withSessionContext).query(...) + .use(withSessionContext) + .use(trpcToTelemetry) + .input(z.object({})) + .query(async ({ ctx }) => { + // ctx now has typed sessionId, clusterId, etc. + // Retrieve ClusterSession which contains all query data + const clusterSession = ClusterSession.getSession(ctx.sessionId); + }), +}); +``` + +### Key Differences from Original Plan + +**Original Plan**: Create separate query sessions with unique IDs for each query execution +**Updated Plan**: Reuse existing ClusterSession which already manages query lifecycle + +**Benefits**: + +- ✅ No need to generate new session IDs for each query +- ✅ No separate session cache to maintain +- ✅ Automatic cache invalidation already implemented +- ✅ Simpler architecture with fewer moving parts + +--- + +## Additional Considerations + +### Payload Strategy for Button Actions + +The payload field in buttons allows the frontend to remain stateless: + +**Pros**: + +- Frontend doesn't need to reconstruct context +- Backend controls the exact command to execute +- Easy to implement "copy command" functionality +- Simple retry logic + +**Cons**: + +- Larger response size +- Potential security concern if payload is not validated + +**Recommendation**: Use payload for now since this is a VS Code extension (trusted environment). Include validation when executing actions. + +### Error Handling + +Each stage should handle errors gracefully (aligned with design doc section 6): + +- **Stage 1**: Fallback to basic metrics only if explain fails; still show client timing and docs returned +- **Stage 2**: Show user-friendly error, suggest retrying; metrics from Stage 1 remain visible +- **Stage 3**: Indicate AI service unavailable (may take 10-20 seconds per design doc), allow retry; Stage 2 data remains visible + +### Session Management and Caching Strategy + +**ClusterSession-Based Architecture**: + +Instead of maintaining a separate `querySessionCache`, we leverage the existing `ClusterSession` infrastructure which already: + +- Manages DocumentDB client connections +- Caches query results and documents +- Tracks JSON schema for the current query +- **Automatically invalidates caches when query changes** (via `resetCachesIfQueryChanged`) + +**Session Lifecycle**: + +1. **Session Creation**: Session already exists (created when collection view opens) +2. **Query Execution**: When a query runs, ClusterSession caches results and resets on query change +3. **Metadata Storage**: After query execution, call `storeQueryMetadata` to save execution time/doc count +4. **Stage 1 Caching**: `explain("queryPlanner")` results cached in ClusterSession +5. **Stage 2 Caching**: `explain("executionStats")` results cached in ClusterSession +6. **Stage 3 Caching**: AI recommendations cached until query changes +7. **Automatic Invalidation**: All caches cleared when `resetCachesIfQueryChanged` detects query modification + +**Cache Invalidation Trigger**: +The existing `resetCachesIfQueryChanged` method in ClusterSession compares query text: + +- If query unchanged: Return cached data (no re-execution needed) +- If query changed: Clear ALL caches (documents, schema, explain plans, AI recommendations) + +**Benefits of ClusterSession-Based Approach**: + +- ✅ **No duplicate session management** - Reuses existing ClusterSession infrastructure +- ✅ **Automatic cache invalidation** - Query change detection already implemented +- ✅ **Consistent lifecycle** - Tied to collection view session +- ✅ **Access to DocumentDB client** - Direct access via `getClient()` +- ✅ **Schema integration** - AI can leverage tracked schema data +- ✅ **Memory efficient** - Single session object per collection view +- ✅ **Prevents inconsistencies** - All stages use same query from ClusterSession + +**No Need for Separate Query Session Cache** - The ClusterSession already provides: + +- Session ID management (`sessionId` in RouterContext) +- Query result caching (`_currentRawDocuments`) +- Automatic cache invalidation (`resetCachesIfQueryChanged`) +- Client connection management (`_client`) + +- ✅ Eliminates need to pass query parameters in Stage 1 & 2 requests +- ✅ Prevents inconsistencies (all stages use exact same query) +- ✅ Enables efficient caching without re-running expensive operations +- ✅ Provides traceability for debugging and telemetry +- ✅ Supports retry logic without client-side state management + +### Performance Rating Thresholds + +The performance rating algorithm uses **efficiency ratio** (documents returned ÷ documents examined) where higher values indicate better performance. This approach is more intuitive than the inverse ratio. + +**Rating Criteria** (from `ExplainPlanAnalyzer.ts`): + +```typescript +/** + * Excellent: efficiencyRatio >= 0.5 (50%+) + * AND isIndexScan = true + * AND hasInMemorySort = false + * AND executionTimeMs < 100 + * + * Good: efficiencyRatio >= 0.1 (10%+) + * AND (isIndexScan = true OR executionTimeMs < 500) + * + * Fair: efficiencyRatio >= 0.01 (1%+) + * + * Poor: efficiencyRatio < 0.01 (<1%) + * OR (isCollectionScan = true AND efficiencyRatio < 0.01) + */ +const PERFORMANCE_RATING_CRITERIA = { + EXCELLENT: { + minEfficiencyRatio: 0.5, // At least 50% of examined docs are returned + requiresIndex: true, // Must use index + allowsInMemorySort: false, // No blocking sorts + maxExecutionTimeMs: 100, // Fast execution + }, + GOOD: { + minEfficiencyRatio: 0.1, // At least 10% of examined docs are returned + requiresIndexOrFast: true, // Must use index OR execute quickly + maxExecutionTimeMsIfNoIndex: 500, + }, + FAIR: { + minEfficiencyRatio: 0.01, // At least 1% of examined docs are returned + }, + POOR: { + // Everything below fair threshold + // OR collection scan with very low efficiency + }, +}; +``` + +**Why Efficiency Ratio (not Examined-to-Returned)?** + +The efficiency ratio (returned ÷ examined) is preferred because: + +- **Intuitive**: Higher values = better performance (like a percentage) +- **Bounded**: Ranges from 0.0 to 1.0 for most queries (can exceed 1.0 with projections) +- **Readable**: "50% efficiency" is clearer than "examined/returned ratio of 2" + +The inverse metric (examined ÷ returned) was used in early design iterations but replaced for clarity. + +#### Performance Diagnostics Structure + +The `PerformanceRating` interface uses a **typed diagnostics array** instead of separate reasons/concerns arrays: + +```typescript +interface PerformanceDiagnostic { + type: 'positive' | 'negative' | 'neutral'; + message: string; +} + +interface PerformanceRating { + score: 'excellent' | 'good' | 'fair' | 'poor'; + diagnostics: PerformanceDiagnostic[]; +} +``` + +**Key Characteristics:** + +- **Consistent Assessments**: Every rating includes exactly 4 diagnostic messages: + 1. **Efficiency Ratio** - Percentage of examined documents returned + 2. **Execution Time** - Query runtime with appropriate units + 3. **Index Usage** - Whether indexes are used effectively + 4. **Sort Strategy** - Whether in-memory sorting is required + +- **Typed Messages**: Each diagnostic has a semantic type: + - `positive` - Good performance characteristics (fast execution, index usage) + - `negative` - Performance concerns (slow execution, collection scans, memory sorts) + - `neutral` - Informational metrics (moderate ratios, average performance) + +- **UI-Friendly**: The type field enables clear visual representation: + - ✓ (positive) - Green checkmark or success icon + - ⚠ (negative) - Yellow/red warning icon + - ● (neutral) - Gray/blue informational icon + +**Example Output:** + +```typescript +{ + score: 'good', + diagnostics: [ + { type: 'neutral', message: 'Moderate efficiency ratio: 15.2% of examined documents returned' }, + { type: 'positive', message: 'Fast execution time: 85.3ms' }, + { type: 'positive', message: 'Query uses index' }, + { type: 'negative', message: 'In-memory sort required - consider adding index for sort fields' } + ] +} +``` + +**Why Single Diagnostics Array?** + +Originally the design included separate `reasons[]` (positive attributes) and `concerns[]` (negative attributes) arrays. These were consolidated into a single typed array because: + +1. **Semantic Clarity**: The `type` field makes the intent explicit without relying on array names +2. **Consistent Ordering**: Always presents diagnostics in the same order (efficiency, time, index, sort) +3. **Type Safety**: TypeScript enforces that every diagnostic has a valid type +4. **Simpler Implementation**: Single array reduces duplication and simplifies transformation logic + +--- + +## TypeScript Types to Add + +Create a new types file: `src/webviews/documentdb/collectionView/types/queryInsights.ts` + +```typescript +export interface QueryInsightsStage1Response { + executionTime: number; + documentsReturned: number; + keysExamined: null; // Not available in Stage 1 + docsExamined: null; // Not available in Stage 1 + queryPlannerInfo: { + winningPlan: WinningPlan; + rejectedPlans: unknown[]; + namespace: string; + indexFilterSet: boolean; + parsedQuery: Record; + plannerVersion: number; + }; + stages: StageInfo[]; + efficiencyAnalysis: { + executionStrategy: string; + indexUsed: string | null; + hasInMemorySort: boolean; + // Performance rating not available in Stage 1 + }; +} + +export interface QueryInsightsStage2Response { + executionTimeMs: number; + totalKeysExamined: number; + totalDocsExamined: number; + documentsReturned: number; + examinedToReturnedRatio: number; + keysToDocsRatio: number | null; + executionStrategy: string; + indexUsed: boolean; + usedIndexNames: string[]; + hadInMemorySort: boolean; + hadCollectionScan: boolean; + isCoveringQuery: boolean; + concerns: string[]; + efficiencyAnalysis: { + executionStrategy: string; + indexUsed: string | null; + examinedReturnedRatio: string; + hasInMemorySort: boolean; + performanceRating: PerformanceRating; + }; + stages: DetailedStageInfo[]; + rawExecutionStats: Record; +} + +export interface QueryInsightsStage3Response { + analysisCard: AnalysisCard; + improvementCards: ImprovementCard[]; + performanceTips?: { + tips: Array<{ + title: string; + description: string; + }>; + dismissible: boolean; + }; + verificationSteps: string; + animation: { + staggerDelay: number; + showTipsDuringLoading: boolean; + }; + metadata: OptimizationMetadata; +} + +export interface PerformanceRating { + score: 'excellent' | 'good' | 'fair' | 'poor'; + reasons: string[]; + concerns: string[]; +} + +export interface ImprovementCard { + type: 'improvement'; + cardId: string; + title: string; + priority: 'high' | 'medium' | 'low'; + description: string; + recommendedIndex: string; + recommendedIndexDetails: string; + details: string; + mongoShellCommand: string; + primaryButton: ActionButton; + secondaryButton?: ActionButton; +} + +export interface ActionButton { + label: string; + actionId: string; + payload: unknown; +} + +// ... additional types +``` + +--- + +## Testing Strategy + +1. **Unit Tests**: Test transformation logic for AI response +2. **Integration Tests**: Test each stage endpoint with real DocumentDB connection +3. **E2E Tests**: Test full flow from UI to backend and back +4. **Mock Tests**: Verify mock data matches expected schemas + +--- + +## Migration Path + +### Phase 1: Extend ClusterSession & Mock Implementation + +- Extend `ClusterSession` class with query insights properties and methods: + - Add private properties for caching explain plans and AI recommendations + - Add methods for Stages 1, 2, and 3 (see each stage section for details) + - Update `resetCachesIfQueryChanged()` to clear new caches +- Add three query insights endpoints (Stage 1, 2, 3) to `collectionViewRouter.ts` +- Add `storeQueryMetadata` mutation endpoint +- Return mock data initially (aligned with design doc examples) +- Update UI to call new endpoints + +### Phase 2: Real Stage 1 Implementation + +**Goal**: Implement Initial Performance View (design doc section 2) + +- Implement actual DocumentDB `explain("queryPlanner")` in ClusterSession methods +- Add `explainQuery()` method to `ClustersClient` class +- Implement client-side timing capture in query execution flow +- Call `storeQueryMetadata` after each query execution +- Extract query plan tree and flatten for UI visualization +- Populate Metrics Row with initial values +- Display Query Plan Summary +- Test with real DocumentDB connections + +### Phase 3: Real Stage 2 Implementation + +**Goal**: Implement Detailed Execution Analysis (design doc section 3) + +- Implement `explain("executionStats")` execution +- Update Metrics Row with authoritative values +- Calculate performance rating (design doc 3.2 thresholds) +- Populate Query Efficiency Analysis Card +- Extract per-stage counters +- Enable Quick Actions (design doc 3.6) +- Test performance rating algorithm + +### Phase 4: AI Integration (Stage 3) + +**Goal**: Implement AI-Powered Recommendations (design doc section 4) + +- Connect to AI backend service +- Implement automatic Stage 2 execution if not cached (in Stage 3 endpoint) +- Implement response transformation (`transformAIResponseForUI`) +- Add error handling and fallbacks for AI service unavailability +- Cache AI recommendations in ClusterSession +- Add telemetry for AI requests + +### Phase 4: Button Actions & Index Management + +- Implement `createIndex` action handler in router +- Implement `dropIndex` action handler +- Implement `learnMore` navigation (documentation links) +- Test index creation/deletion workflows +- Add confirmation dialogs for destructive operations + +### Phase 5: Production Hardening + +- Add comprehensive error handling for all stages +- Implement telemetry for each stage (success/failure metrics) +- Add retry logic with exponential backoff for AI service +- Optimize ClusterSession cache memory usage +- Add security validation for action payloads (index creation/deletion) +- Performance testing with large result sets +- Add user feedback mechanisms (loading states, progress indicators) + +--- + +## Implementation Plan + +### File Structure + +This section outlines where new code will be placed following the project's architectural patterns: + +#### Backend Files (Extension Host) + +``` +src/ +├── documentdb/ +│ ├── client/ # 📁 NEW FOLDER +│ │ ├── ClusterSession.ts # ✏️ MODIFY: Add query insights caching +│ │ ├── ClustersClient.ts # ✏️ MODIFY: No changes needed +│ │ └── QueryInsightsApis.ts # 🆕 NEW: Explain query execution (follows LlmEnhancedFeatureApis pattern) +│ │ +│ └── queryInsights/ # 📁 NEW FOLDER +│ ├── ExplainPlanAnalyzer.ts # 🆕 NEW: Explain plan parsing & analysis +│ ├── StagePropertyExtractor.ts # 🆕 NEW: Extended stage info extraction +│ └── transformations.ts # 🆕 NEW: Router response transformations +│ +└── services/ + └── ai/ # 📁 NEW FOLDER + └── QueryInsightsAIService.ts # 🆕 NEW: AI service mock (8s delay) + +webviews/ +└── documentdb/ + └── collectionView/ + ├── collectionViewRouter.ts # ✏️ MODIFY: Add 3 tRPC endpoints + └── types/ + └── queryInsights.ts # 🆕 NEW: Frontend-facing types +``` + +#### Architectural Guidelines + +**Current Structure** (for upcoming release): + +All files remain in `src/documentdb/` for minimal disruption: + +- `ClustersClient.ts` - DocumentDB client wrapper with QueryInsightsApis instance +- `ClusterSession.ts` - Session state management, caching, uses client.queryInsightsApis +- `QueryInsightsApis.ts` - Explain command execution (follows LlmEnhancedFeatureApis pattern) + +**QueryInsightsApis Integration Pattern**: + +Following the `LlmEnhancedFeatureApis` pattern: + +1. **Instantiation**: QueryInsightsApis is created in `ClustersClient` constructor +2. **Exposure**: Available as `client.queryInsightsApis` public property +3. **Usage**: ClusterSession accesses via `this._client.queryInsightsApis` +4. **Ownership**: ClustersClient owns MongoClient, QueryInsightsApis wraps it + +```typescript +// In ClustersClient.ts +export class ClustersClient { + private readonly _mongoClient: MongoClient; + public readonly queryInsightsApis: QueryInsightsApis; + + constructor() { + this._mongoClient = new MongoClient(/* ... */); + this.queryInsightsApis = new QueryInsightsApis(this._mongoClient); + } +} + +// In ClusterSession.ts +export class ClusterSession { + constructor(private readonly _client: ClustersClient) {} + + async getQueryPlannerInfo() { + // Access via client property, not local instance + return await this._client.queryInsightsApis.explainFind(/* ... */); + } +} +``` + +**Future Structure** (post-release refactoring): + +A `src/documentdb/client/` subfolder may be created to organize client-related code: + +- `client/ClustersClient.ts` - Main client class +- `client/ClusterSession.ts` - Session management +- `client/QueryInsightsApis.ts` - Query insights APIs +- `client/CredentialCache.ts` - Credential management + +This refactoring is deferred to avoid widespread import changes during the current release cycle. + +**Other Folders** (unchanged): + +**`src/documentdb/queryInsights/` folder:** + +- Query analysis logic (explain plan parsing, metrics extraction) +- Transformation functions for router responses +- Backend types are generic - no webview-specific terminology + +**`src/services/ai/` folder:** + +- AI service integration +- Mock implementation with 8-second delay for realistic testing +- Returns mock data structure matching current webview expectations + +**`src/webviews/.../types/` folder:** + +- Frontend-facing TypeScript types +- Shared between router and React components +- tRPC infers types from router, so these are mainly for UI components + +--- + +### Implementation Steps + +#### Phase 1: Foundation & Types + +**1.1. Create Type Definitions** ✅ Complete + +**Status**: Types created and refined. Stage 1 and Stage 2 response types implemented in `src/webviews/documentdb/collectionView/types/queryInsights.ts`. + +**Completed Updates**: + +- ✅ Removed unnecessary null fields from Stage 1 response (`keysExamined`, `docsExamined`) +- ✅ Removed `queryPlannerInfo` from Stage 1 (data duplicated in `stages` array) +- ✅ Simplified response structure for better performance and clarity +- ✅ Added comprehensive JSDoc comments for all types +- ✅ Implemented `PerformanceDiagnostic` interface with typed diagnostics (positive/negative/neutral) +- ✅ Updated `PerformanceRating` to use `diagnostics[]` instead of separate `reasons[]`/`concerns[]` arrays + +**Implementation**: See `src/webviews/documentdb/collectionView/types/queryInsights.ts` + +--- + +#### Phase 2: Explain Plan Analysis (Stages 1 & 2) + +**2.1. Install Dependencies** ✅ Complete + +`@mongodb-js/explain-plan-helper` v1.x installed successfully. + +```bash +npm install @mongodb-js/explain-plan-helper +``` + +**2.2. Create ExplainPlanAnalyzer** ✅ Complete + +**📖 Before starting**: Review the entire design document, especially: + +- "DocumentDB Explain Plan Parsing" section for ExplainPlan API usage +- Stage 1 and Stage 2 sections for expected output formats +- **Performance Rating Algorithm** section (consolidated, authoritative version) + +The `ExplainPlanAnalyzer` class provides analysis for both `queryPlanner` and `executionStats` verbosity levels. + +**Key Implementation Notes**: + +1. **Performance Rating**: Uses the consolidated algorithm from the "Performance Rating Thresholds" section + - Based on **efficiency ratio** (returned/examined, range 0.0-1.0+, higher is better) + - Considers: execution time, index usage, collection scan, in-memory sort + - See `src/documentdb/queryInsights/ExplainPlanAnalyzer.ts` for implementation + +2. **Efficiency Calculation**: + + ```typescript + efficiencyRatio = returned / examined; // Higher is better + // vs the inverse: + examinedToReturnedRatio = examined / returned; // Lower is better (deprecated approach) + ``` + +3. **Library Integration**: Uses `@mongodb-js/explain-plan-helper` for robust parsing across MongoDB versions + +**Implementation**: See `src/documentdb/queryInsights/ExplainPlanAnalyzer.ts` + +**2.3. Create StagePropertyExtractor** ✅ Complete + +**Status**: Implemented with support for 20+ MongoDB stage types. + +**Implementation**: `src/documentdb/queryInsights/StagePropertyExtractor.ts` + +**Key Features**: + +- Recursive stage tree traversal +- Extracts stage-specific properties (index names, bounds, memory usage, etc.) +- Handles complex structures (inputStage, inputStages[], shards[]) +- Returns flattened list of ExtendedStageInfo for UI display + +**2.4. Create QueryInsightsApis and Integrate with ClustersClient** ✅ Complete + +**Status**: Implemented following LlmEnhancedFeatureApis pattern and integrated into ClustersClient. + +**Implementation**: `src/documentdb/client/QueryInsightsApis.ts` + +**Architecture** (corrected from original plan): + +- ✅ QueryInsightsApis instantiated in `ClustersClient` constructor +- ✅ Exposed as public property: `client.queryInsightsApis` +- ✅ ClusterSession accesses via `this._client.queryInsightsApis.explainFind()` +- ✅ Follows the same pattern as `llmEnhancedFeatureApis` + +**Location Update**: Moved to `src/documentdb/client/` subfolder to begin the client code organization transition. + +**2.5. Extend ClusterSession for Caching** ✅ Complete + +**Status**: Caching methods implemented. Architecture updated to use ClustersClient's QueryInsightsApis instance. + +**Implementation**: `src/documentdb/ClusterSession.ts` + +**Methods Added**: + +- `getQueryPlannerInfo()` - Gets/caches queryPlanner explain results +- `getExecutionStats()` - Gets/caches executionStats explain results +- `cacheAIRecommendations()` / `getCachedAIRecommendations()` - AI recommendation caching +- `clearQueryInsightsCaches()` - Cache invalidation (called by `resetCachesIfQueryChanged()`) + +**Architecture Note**: Uses `this._client.queryInsightsApis` instead of local instance (corrected from initial plan). + +```typescript +import type { Document } from 'mongodb'; +import type { ExtendedStageInfo } from '../../webviews/documentdb/collectionView/types/queryInsights'; + +export class StagePropertyExtractor { + /** + * Extracts extended properties for all stages in execution plan + */ + public static extractAllExtendedStageInfo(executionStages: Document): ExtendedStageInfo[] { + const stageInfoList: ExtendedStageInfo[] = []; + + this.traverseStages(executionStages, stageInfoList); + + return stageInfoList; + } + + /** + * Recursively traverses execution stages and extracts properties + */ + private static traverseStages(stage: Document, accumulator: ExtendedStageInfo[]): void { + if (!stage || !stage.stage) return; + + const properties = this.extractStageProperties(stage); + + accumulator.push({ + stageName: stage.stage, + properties, + }); + + // Recurse into child stages + if (stage.inputStage) { + this.traverseStages(stage.inputStage, accumulator); + } + if (stage.inputStages) { + stage.inputStages.forEach((childStage: Document) => { + this.traverseStages(childStage, accumulator); + }); + } + } + + /** + * Extracts stage-specific properties based on stage type + */ + private static extractStageProperties(stage: Document): Record { + const stageName = stage.stage; + const properties: Record = {}; + + switch (stageName) { + case 'IXSCAN': + if (stage.keyPattern) properties['Key Pattern'] = JSON.stringify(stage.keyPattern); + if (stage.indexName) properties['Index Name'] = stage.indexName; + if (stage.isMultiKey !== undefined) properties['Multi Key'] = stage.isMultiKey ? 'Yes' : 'No'; + if (stage.direction) properties['Direction'] = stage.direction; + if (stage.indexBounds) properties['Index Bounds'] = JSON.stringify(stage.indexBounds); + break; + + case 'COLLSCAN': + if (stage.direction) properties['Direction'] = stage.direction; + if (stage.filter) properties['Filter'] = JSON.stringify(stage.filter); + break; + + case 'FETCH': + if (stage.filter) properties['Filter'] = JSON.stringify(stage.filter); + break; + + case 'SORT': + if (stage.sortPattern) properties['Sort Pattern'] = JSON.stringify(stage.sortPattern); + if (stage.memLimit !== undefined) properties['Memory Limit'] = `${stage.memLimit} bytes`; + if (stage.type) properties['Type'] = stage.type; + break; + + // ... (add remaining 15+ stage types from design doc) + } + + return properties; + } +} +``` + +--- + +#### Phase 3: AI Service Integration + +**3.1. Create AI Service Client** ✅ Complete + +**📖 Before starting**: Review Stage 3 section for AI service payload structure and expected response format. + +Create `src/services/ai/QueryInsightsAIService.ts`: + +```typescript +/** + * AI service for query insights and optimization recommendations + * Currently a mock implementation with 8-second delay + * + * TODO: Replace with actual AI service integration later + */ +export class QueryInsightsAIService { + /** + * Gets optimization recommendations + * Currently returns mock data with 8s delay to simulate real AI processing + */ + public async getOptimizationRecommendations( + clusterId: string, + sessionId: string | undefined, + query: string, + databaseName: string, + collectionName: string, + ): Promise { + // Simulate 8-second AI processing time + await new Promise((resolve) => setTimeout(resolve, 8000)); + + // Return mock data matching current webview expectations + return { + analysis: + 'Your query performs a full collection scan after the index lookup, examining 10,000 documents to return only 2. This indicates the index is not selective enough or additional filtering is happening after the index stage.', + improvements: [ + { + action: 'create', + indexSpec: { user_id: 1, status: 1 }, + reason: + 'A compound index on user_id and status would allow DocumentDB to use a single index scan instead of scanning documents after the index lookup.', + impact: 'high', + }, + ], + verification: [ + 'After creating the index, run the same query and verify that:', + '1) docsExamined equals documentsReturned', + "2) the execution plan shows IXSCAN using 'user_id_1_status_1'", + '3) no COLLSCAN stage appears in the plan', + ], + }; + + /* TODO: Actual implementation will call AI service via HTTP/gRPC + // This will be implemented later when AI backend is ready: + // Use clusterId to get client access + // const client = ClustersClient.getClient(clusterId); + // + // Use sessionId to access cached query data if available + // const session = sessionId ? ClusterSession.getSession(sessionId) : null; + // + // const response = await fetch(AI_SERVICE_URL, { + // method: 'POST', + // headers: { 'Content-Type': 'application/json' }, + // body: JSON.stringify({ + // query, + // databaseName, + // collectionName, + // clusterId, + // sessionId + // }) + // }); + // + // return await response.json(); + */ + } +} +``` + +**3.2. Extend ClusterSession for AI Integration** ⬜ Not Started + +**📖 Before starting**: Review Stage 3 and "ClusterSession Extensions for Stage 3" sections for AI integration patterns. + +Modify `src/documentdb/client/ClusterSession.ts`: + +```typescript +/** + * Gets AI optimization recommendations + */ +public async getAIRecommendations( + query: string, + databaseName: string, + collectionName: string, +): Promise { + // Check cache first (no sessionId needed - this instance IS the session) + const cached = this.getCachedAIRecommendations(); + if (cached) { + return cached; + } + + // Call AI service with minimal payload + // Note: AI backend will independently collect additional data + const recommendations = await this.aiService.getOptimizationRecommendations( + query, + databaseName, + collectionName, + ); + + // Cache recommendations + this.cacheAIRecommendations(recommendations); + + return recommendations; +} +``` + +--- + +#### Phase 4: Router Implementation + +**4.1. Implement tRPC Endpoints** ✅ Complete + +**📖 Before starting**: Review entire design document, especially: + +- Stage 1, 2, and 3 sections for endpoint behavior +- "Router Context" section for available context fields +- "Router File Structure" section for patterns +- Note: Frontend-facing endpoint names use "Stage1", "Stage2", "Stage3" terminology + +Modify `webviews/documentdb/collectionView/collectionViewRouter.ts`: + +````typescript +// Add to router: + +/** + * Query Insights Stage 1 - Initial Performance View + * Returns fast metrics using explain("queryPlanner") + */ +getQueryInsightsStage1: protectedProcedure + .input(z.object({})) // Empty - uses sessionId from context + .query(async ({ ctx }) => { + const { sessionId, clusterId, databaseName, collectionName } = ctx; + + const clusterSession = await getClusterSession(clusterId); + + // Get query planner data (cached or execute explain) + // No sessionId parameter needed - ClusterSession instance IS the session + const explainResult = await clusterSession.getQueryPlannerInfo( + databaseName, + collectionName, + ); + + // Analyze and transform + const analyzed = ExplainPlanAnalyzer.analyzeQueryPlanner(explainResult); + return transformStage1Response(analyzed); + }), + +/** + * Query Insights Stage 2 - Detailed Execution Analysis + * Returns authoritative metrics using explain("executionStats") + */ +getQueryInsightsStage2: protectedProcedure + .input(z.object({})) // Empty - uses sessionId from context + .query(async ({ ctx }) => { + const { sessionId, clusterId, databaseName, collectionName } = ctx; + + const clusterSession = await getClusterSession(clusterId); + + // Get execution stats (cached or execute explain) + // No sessionId parameter needed - ClusterSession instance IS the session + const explainResult = await clusterSession.getExecutionStats( + databaseName, + collectionName, + ); + + // Analyze and transform + const analyzed = ExplainPlanAnalyzer.analyzeExecutionStats(explainResult); + + // Extract extended stage info + const executionStages = explainResult.executionStats?.executionStages; + if (executionStages) { + analyzed.extendedStageInfo = StagePropertyExtractor.extractAllExtendedStageInfo(executionStages); + } + + return transformStage2Response(analyzed); + }), + +/** + * Query Insights Stage 3 - AI-Powered Optimization Recommendations + * Returns actionable suggestions from AI service (8s delay) + */ +getQueryInsightsStage3: protectedProcedure + .input(z.object({})) // Empty - uses sessionId from context + .query(async ({ ctx }) => { + const { sessionId, clusterId, databaseName, collectionName } = ctx; + + const clusterSession = await getClusterSession(clusterId); + + // Get current query from session + const query = clusterSession.getCurrentQuery(); + + // Get AI recommendations (cached or call AI service with 8s delay) + // No sessionId parameter needed - ClusterSession instance IS the session + const aiRecommendations = await clusterSession.getAIRecommendations( + JSON.stringify(query), + databaseName, + collectionName, + ); + + // Transform to UI format (with button payloads) + return transformStage3Response(aiRecommendations, ctx); + }), +```**4.2. Implement Transformation Functions** ✅ Complete + +Create `src/documentdb/queryInsights/transformations.ts`: + +```typescript +import type { RouterContext } from '../../../webviews/documentdb/collectionView/collectionViewRouter'; + +/** + * Transforms query planner data to frontend response format + */ +export function transformQueryPlannerResponse(analyzed: unknown) { + // Implementation based on design doc + return analyzed; +} + +/** + * Transforms execution stats data to frontend response format + */ +export function transformExecutionStatsResponse(analyzed: unknown) { + // Implementation based on design doc + return analyzed; +} + +/** + * Transforms AI response to frontend format with button payloads + */ +export function transformAIResponse(aiResponse: any, ctx: RouterContext) { + const { clusterId, databaseName, collectionName } = ctx; + + // Build improvement cards with complete button payloads + const improvementCards = aiResponse.improvements.map((improvement: any) => { + return { + title: `${improvement.action} Index`, + description: improvement.reason, + impact: improvement.impact, + primaryButton: { + label: improvement.action === 'create' ? 'Create Index' : 'Drop Index', + action: improvement.action === 'create' ? 'createIndex' : 'dropIndex', + payload: { + clusterId, + databaseName, + collectionName, + indexSpec: improvement.indexSpec, + }, + }, + secondaryButton: { + label: 'Copy Command', + action: 'copyCommand', + payload: { + command: generateIndexCommand(improvement, databaseName, collectionName), + }, + }, + }; + }); + + return { + analysisCard: { + title: 'Query Analysis', + content: aiResponse.analysis, + }, + improvementCards, + verificationSteps: aiResponse.verification.map((step: string, index: number) => ({ + step: index + 1, + description: step, + })), + }; +} + +/** + * Generates MongoDB shell index command string + */ +function generateIndexCommand(improvement: any, databaseName: string, collectionName: string): string { + const indexSpecStr = JSON.stringify(improvement.indexSpec); + + if (improvement.action === 'create') { + return `db.getSiblingDB('${databaseName}').${collectionName}.createIndex(${indexSpecStr})`; + } else { + return `db.getSiblingDB('${databaseName}').${collectionName}.dropIndex(${indexSpecStr})`; + } +} +```` + +--- + +#### Phase 5: Frontend Integration + +**5.1. Update Query Execution Logic** 🔄 In Progress + +**📖 Before starting**: Review "Query Execution Integration" section and Stage 1 implementation notes for server-side metadata tracking approach. + +**Goal**: Orchestrate non-blocking Stage 1 data prefetch after query execution completes, and provide placeholder for query state indicator. + +**Implementation Location**: `src/webviews/documentdb/collectionView/CollectionView.tsx` + +**Architecture Overview**: + +``` +Query Execution → Results Return → Non-Blocking Stage 1 Prefetch → Cache Population + ↓ ↓ + Show Results Ready for Query Insights Tab + ↓ ↓ + User switches to Query Insights Tab ← Data already cached (fast) +``` + +**5.1.1. Non-Blocking Stage 1 Prefetch After Query Execution** ✅ Complete + +**Implementation Steps**: + +1. **Trigger Stage 1 Prefetch After Query Results Return**: + + ```typescript + // In CollectionView.tsx (or query execution handler): + const handleQueryExecution = async (query: string) => { + // Execute query and show results + const results = await trpcClient.mongoClusters.collectionView.runFindQuery.query({ + query, + skip: currentPage * pageSize, + limit: pageSize, + }); + + // Update results view + setQueryResults(results); + + // Non-blocking Stage 1 prefetch to populate ClusterSession cache + // DO NOT await - this runs in background + void prefetchQueryInsights(); + + return results; // Don't block on insights + }; + + const prefetchQueryInsights = () => { + void trpcClient.mongoClusters.collectionView.getQueryInsightsStage1 + .query() + .then((stage1Data) => { + // Stage 1 data is now cached in ClusterSession + // Update indicator that insights are ready + setQueryInsightsReady(true); + }) + .catch((error) => { + // Silent fail - user can still request insights manually via tab + console.warn('Stage 1 prefetch failed:', error); + setQueryInsightsReady(false); + }); + }; + ``` + +2. **Server-Side Execution Time Tracking**: + + No explicit mutation needed. The ClusterSession already tracks execution time during `runFindQueryWithCache()`: + + ```typescript + // In ClusterSession.runFindQueryWithCache() (already implemented): + const startTime = performance.now(); + const results = await this._client.runFindQuery(/* ... */); + const endTime = performance.now(); + this._lastExecutionTimeMs = endTime - startTime; // Cached until query changes + ``` + +**Behavior**: + +- ✅ Query results display immediately (not blocked by insights prefetch) +- ✅ Stage 1 data fetched in background after results return +- ✅ ClusterSession cache populated before user navigates to Query Insights tab +- ✅ Silent failure if prefetch fails - user can still manually request insights +- ✅ Execution time tracked server-side automatically (not affected by network latency) + +--- + +**5.1.2. Add Placeholder for Query State Indicator** ⬜ Not Started + +**Goal**: Provide UI state management for showing when query insights are ready (future enhancement: asterisk/badge on tab). + +**Implementation**: + +```typescript +// In CollectionView.tsx: +const [queryInsightsReady, setQueryInsightsReady] = useState(false); + +// Reset when query changes: +useEffect(() => { + setQueryInsightsReady(false); +}, [currentQuery]); + +// TODO: Use queryInsightsReady to add visual indicator on Query Insights tab +// Examples: +// - Asterisk badge: "Query Insights *" +// - Dot indicator: "Query Insights •" +// - Color change: Tab text color changes when ready +``` + +**Future Enhancement Placeholder**: + +```typescript +// In Tab component (when implemented): + setActiveTab('queryInsights')} +/> +``` + +**Behavior**: + +- ✅ State management ready for visual indicators +- ✅ Automatically resets when query changes +- ✅ Can be enhanced later without changing architecture + +--- + +**5.2. Implement Frontend Query Insights Panel** ✅ Complete + +**📖 Before starting**: Review Stage 1, 2, and 3 sections for UI component requirements, data flow patterns, and caching behavior. + +**Goal**: Implement progressive data loading in Query Insights tab with proper caching and tab-switching behavior. + +**Implementation Location**: `src/webviews/documentdb/collectionView/QueryInsightsTab.tsx` (new file or section in CollectionView.tsx) + +**Architecture Overview**: + +``` +User Activates Query Insights Tab + ↓ + Fetch Stage 1 (cached from prefetch - fast) + ↓ + Display Stage 1 Data (basic metrics + query plan) + ↓ + Auto-start Stage 2 Fetch (executionStats - ~2s) + ↓ + Update UI with Stage 2 Data (detailed metrics + performance rating) + ↓ + User Clicks "Get Performance Insights" Button + ↓ + Fetch Stage 3 (AI recommendations - ~8s) + ↓ + Display AI Recommendations + + Tab Switches → Fetches Continue in Background → Return to Tab → Data Already Loaded +``` + +**5.2.1. Stage 1: Initial View on Tab Activation** ✅ Complete + +**Goal**: Load and display Stage 1 data when user activates Query Insights tab. + +**⚠️ Architecture Note**: Due to component unmounting on tab switch (see 5.2.3), state must be stored in parent CollectionView.tsx. + +**Implementation** (see 5.2.3 for full parent state structure): + +```typescript +// In QueryInsightsTab.tsx: +interface QueryInsightsMainProps { + queryInsightsState: QueryInsightsState; + setQueryInsightsState: React.Dispatch>; +} + +export const QueryInsightsMain = ({ + queryInsightsState, + setQueryInsightsState, +}: QueryInsightsMainProps): JSX.Element => { + const { trpcClient } = useTrpcClient(); + + // Stage 1: Load on mount (only if not already loading/loaded) + useEffect(() => { + if (!queryInsightsState.stage1Data && !queryInsightsState.stage1Loading && !queryInsightsState.stage1Promise) { + setQueryInsightsState((prev) => ({ ...prev, stage1Loading: true })); + + const promise = trpcClient.mongoClusters.collectionView.getQueryInsightsStage1 + .query() + .then((data) => { + setQueryInsightsState((prev) => ({ + ...prev, + stage1Data: data, + stage1Loading: false, + stage1Promise: null, + })); + return data; + }) + .catch((error) => { + void trpcClient.common.displayErrorMessage.mutate({ + message: l10n.t('Failed to load query insights'), + modal: false, + cause: error instanceof Error ? error.message : String(error), + }); + setQueryInsightsState((prev) => ({ + ...prev, + stage1Error: error instanceof Error ? error.message : String(error), + stage1Loading: false, + stage1Promise: null, + })); + throw error; + }); + + setQueryInsightsState((prev) => ({ ...prev, stage1Promise: promise })); + } + }, []); // Empty deps - only run on mount + + // ... rest of component +}; +``` + +**Behavior**: + +- ✅ Loads Stage 1 data immediately when Query Insights tab is activated (component mounts) +- ✅ Data likely already cached from prefetch (fast response from ClusterSession cache) +- ✅ If promise already exists in parent state, doesn't start duplicate request +- ✅ Parent state preserves data/loading state when component unmounts (tab switch) +- ✅ Loading state shows skeleton UI while fetching + +--- + +**5.2.2. Stage 2: Automatic Progression After Stage 1** ✅ Complete + +**Goal**: Automatically start Stage 2 fetch after Stage 1 completes to populate detailed metrics. + +**⚠️ Architecture Note**: State stored in parent, promise tracked to prevent duplicates. + +**Implementation**: + +```typescript +// In QueryInsightsTab.tsx (continuation): + +// Stage 2: Auto-start after Stage 1 completes +useEffect(() => { + if ( + queryInsightsState.stage1Data && + !queryInsightsState.stage2Data && + !queryInsightsState.stage2Loading && + !queryInsightsState.stage2Promise + ) { + setQueryInsightsState((prev) => ({ ...prev, stage2Loading: true })); + + const promise = trpcClient.mongoClusters.collectionView.getQueryInsightsStage2 + .query() + .then((data) => { + setQueryInsightsState((prev) => ({ + ...prev, + stage2Data: data, + stage2Loading: false, + stage2Promise: null, + })); + return data; + }) + .catch((error) => { + void trpcClient.common.displayErrorMessage.mutate({ + message: l10n.t('Failed to load detailed execution analysis'), + modal: false, + cause: error instanceof Error ? error.message : String(error), + }); + setQueryInsightsState((prev) => ({ + ...prev, + stage2Error: error instanceof Error ? error.message : String(error), + stage2Loading: false, + stage2Promise: null, + })); + throw error; + }); + + setQueryInsightsState((prev) => ({ ...prev, stage2Promise: promise })); + } +}, [queryInsightsState.stage1Data]); +``` + +**Behavior**: + +- ✅ Automatically starts after Stage 1 completes +- ✅ Runs explain("executionStats") which executes the query +- ✅ Updates parent state with execution metrics (survives component unmount) +- ✅ Does NOT abort if user switches tabs (promise continues, stored in parent) +- ✅ Results available immediately when user returns to tab + +--- + +**5.2.3. Tab Switching Behavior (No Abort)** ✅ Complete + +**Goal**: Ensure ongoing fetches continue when user switches tabs, and data is preserved across tab switches. + +**⚠️ Critical Architecture Decision: Component Unmounting** + +Looking at the current `CollectionView.tsx` implementation: + +```typescript +{selectedTab === 'tab_result' && ( + // Results tab content +)} +{selectedTab === 'tab_queryInsights' && } +{selectedTab === 'tab_performance_mock' && } +``` + +**This means components ARE UNMOUNTED when switching tabs.** All component-local state (useState) is lost. + +**Solution: Lift State to Parent (CollectionView.tsx)** + +To preserve query insights data across tab switches, we need to: + +1. **Store query insights state in CollectionView.tsx** (parent component) +2. **Pass state down to QueryInsightsTab via props** +3. **Store in-flight promises in parent state to prevent abort** + +**Implementation**: + +```typescript +// In CollectionView.tsx (parent component): + +// Query Insights State (lifted to parent to survive tab unmounting) +interface QueryInsightsState { + stage1Data: QueryInsightsStage1Response | null; + stage1Loading: boolean; + stage1Error: string | null; + stage1Promise: Promise | null; // Track in-flight request + + stage2Data: QueryInsightsStage2Response | null; + stage2Loading: boolean; + stage2Error: string | null; + stage2Promise: Promise | null; + + stage3Data: QueryInsightsStage3Response | null; + stage3Loading: boolean; + stage3Error: string | null; + stage3Promise: Promise | null; +} + +const [queryInsightsState, setQueryInsightsState] = useState({ + stage1Data: null, + stage1Loading: false, + stage1Error: null, + stage1Promise: null, + + stage2Data: null, + stage2Loading: false, + stage2Error: null, + stage2Promise: null, + + stage3Data: null, + stage3Loading: false, + stage3Error: null, + stage3Promise: null, +}); + +// Reset query insights when query execution starts +// Note: Query execution already triggers when currentContext.activeQuery changes +useEffect(() => { + // Reset all query insights state - user is executing a new query + setQueryInsightsState({ + stage1Data: null, + stage1Loading: false, + stage1Error: null, + stage1Promise: null, + + stage2Data: null, + stage2Loading: false, + stage2Error: null, + stage2Promise: null, + + stage3Data: null, + stage3Loading: false, + stage3Error: null, + stage3Promise: null, + }); +}, [currentContext.activeQuery]); // Reset whenever query executes (even if same query text) + +// Pass state and updater functions to QueryInsightsTab +{selectedTab === 'tab_performance_main' && ( + +)} +``` + +**In QueryInsightsTab.tsx (child component):** + +```typescript +interface QueryInsightsMainProps { + queryInsightsState: QueryInsightsState; + setQueryInsightsState: React.Dispatch>; +} + +export const QueryInsightsMain = ({ + queryInsightsState, + setQueryInsightsState +}: QueryInsightsMainProps): JSX.Element => { + const { trpcClient } = useTrpcClient(); + + // Stage 1: Load when tab activates (only if not already loading/loaded) + useEffect(() => { + if (!queryInsightsState.stage1Data && + !queryInsightsState.stage1Loading && + !queryInsightsState.stage1Promise) { + + // Mark as loading + setQueryInsightsState(prev => ({ ...prev, stage1Loading: true })); + + // Create promise and store it + const promise = trpcClient.mongoClusters.collectionView.getQueryInsightsStage1 + .query() + .then((data) => { + setQueryInsightsState(prev => ({ + ...prev, + stage1Data: data, + stage1Loading: false, + stage1Promise: null, + })); + return data; + }) + .catch((error) => { + void trpcClient.common.displayErrorMessage.mutate({ + message: l10n.t('Failed to load query insights'), + modal: false, + cause: error instanceof Error ? error.message : String(error), + }); + setQueryInsightsState(prev => ({ + ...prev, + stage1Error: error instanceof Error ? error.message : String(error), + stage1Loading: false, + stage1Promise: null, + })); + throw error; + }); + + // Store promise reference + setQueryInsightsState(prev => ({ ...prev, stage1Promise: promise })); + } + }, []); // Empty deps - only run on mount + + // Stage 2: Auto-start after Stage 1 completes + useEffect(() => { + if (queryInsightsState.stage1Data && + !queryInsightsState.stage2Data && + !queryInsightsState.stage2Loading && + !queryInsightsState.stage2Promise) { + + setQueryInsightsState(prev => ({ ...prev, stage2Loading: true })); + + const promise = trpcClient.mongoClusters.collectionView.getQueryInsightsStage2 + .query() + .then((data) => { + setQueryInsightsState(prev => ({ + ...prev, + stage2Data: data, + stage2Loading: false, + stage2Promise: null, + })); + return data; + }) + .catch((error) => { + void trpcClient.common.displayErrorMessage.mutate({ + message: l10n.t('Failed to load detailed execution analysis'), + modal: false, + cause: error instanceof Error ? error.message : String(error), + }); + setQueryInsightsState(prev => ({ + ...prev, + stage2Error: error instanceof Error ? error.message : String(error), + stage2Loading: false, + stage2Promise: null, + })); + throw error; + }); + + setQueryInsightsState(prev => ({ ...prev, stage2Promise: promise })); + } + }, [queryInsightsState.stage1Data]); + + // Render with queryInsightsState data + return ( +
+ {queryInsightsState.stage1Loading && } + {queryInsightsState.stage1Data && ( + + )} + {/* ... rest of UI */} +
+ ); +}; +``` + +**Behavior with This Architecture**: + +- ✅ **Tab Switch Away**: Component unmounts, but state persists in parent +- ✅ **In-Flight Requests**: Promise stored in parent state, continues executing +- ✅ **Tab Switch Back**: Component remounts, immediately has access to parent state +- ✅ **Request Completes While Away**: State update happens in parent, visible when returning +- ✅ **Query Change**: Parent detects change and resets all query insights state + +**Key Architecture Points**: + +- **Parent State Storage**: CollectionView.tsx owns query insights state +- **Promise Tracking**: Store promise references to prevent duplicate requests +- **Component Unmounting**: QueryInsightsTab can unmount without losing data +- **Automatic Recovery**: When remounting, component checks parent state before fetching--- + +**5.2.4. Stage 3: AI Recommendations (User-Initiated)** ✅ Complete + +**Goal**: Allow user to request AI recommendations on demand with ~8s loading delay. + +**Implementation**: + +```typescript +const [stage3Data, setStage3Data] = useState(null); +const [stage3Loading, setStage3Loading] = useState(false); +const [stage3Error, setStage3Error] = useState(null); + +const handleGetAIRecommendations = () => { + setStage3Loading(true); + setStage3Error(null); + + void trpcClient.mongoClusters.collectionView.getQueryInsightsStage3 + .query() + .then((data) => { + setStage3Data(data); + setStage3Loading(false); + }) + .catch((error) => { + void trpcClient.common.displayErrorMessage.mutate({ + message: l10n.t('Failed to get AI recommendations'), + modal: false, + cause: error instanceof Error ? error.message : String(error), + }); + setStage3Error(error instanceof Error ? error.message : String(error)); + setStage3Loading(false); + }); +}; +``` + +**UI Integration**: + +```typescript +// In QueryInsightsTab.tsx: +{!stage3Data && !stage3Loading && ( + + {l10n.t('Get Performance Insights')} + +)} + +{stage3Loading && ( +
+ + {l10n.t('Analyzing query performance...')} +
+)} + +{stage3Data && ( + +)} +``` + +**Behavior**: + +- ✅ User must click button to trigger AI analysis +- ✅ Shows loading state for ~8 seconds (AI service delay) +- ✅ Continues even if user switches tabs +- ✅ Results persist in component state +- ✅ Button hidden after recommendations loaded + +--- + +**5.2.5. Two-Level Caching Strategy** ✅ Complete + +**Goal**: Document and validate the two-level caching architecture with component unmounting considerations. + +**Caching Levels**: + +1. **Backend Cache (ClusterSession)**: + + ```typescript + // In ClusterSession: + private _queryPlannerCache?: { result: Document; timestamp: number }; + private _executionStatsCache?: { result: Document; timestamp: number }; + private _aiRecommendationsCache?: { result: unknown; timestamp: number }; + private _lastExecutionTimeMs?: number; + + // Cleared when query text changes in resetCachesIfQueryChanged() + ``` + +2. **Frontend Cache (Parent Component State - CollectionView.tsx)**: + + ````typescript + // ⚠️ CRITICAL: Must be in parent (CollectionView.tsx) not child (QueryInsightsTab.tsx) + // Because QueryInsightsTab unmounts on tab switch + + interface QueryInsightsState { + stage1Data: QueryInsightsStage1Response | null; + stage1Loading: boolean; + stage1Promise: Promise | null; + + stage2Data: QueryInsightsStage2Response | null; + stage2Loading: boolean; + stage2Promise: Promise | null; + + stage3Data: QueryInsightsStage3Response | null; + stage3Loading: boolean; + stage3Promise: Promise | null; + } + + const [queryInsightsState, setQueryInsightsState] = useState({...}); + + // Reset when query executes (even if same query text) + useEffect(() => { + setQueryInsightsState({ /* reset all fields */ }); + }, [currentContext.activeQuery]); + ```**Cache Invalidation Flow**: + ```` + +```typescript +// In CollectionView.tsx: +useEffect(() => { + const currentQueryId = JSON.stringify({ + filter: currentContext.activeQuery.filter, + project: currentContext.activeQuery.project, + sort: currentContext.activeQuery.sort, + }); + + // Query changed - reset all query insights state + if (queryInsightsState.queryIdentifier !== currentQueryId) { + setQueryInsightsState({ + stage1Data: null, + stage1Loading: false, + stage1Promise: null, + + stage2Data: null, + stage2Loading: false, + stage2Promise: null, + + stage3Data: null, + stage3Loading: false, + stage3Promise: null, + }); +}, [currentContext.activeQuery]); // Reset whenever query executes (even if same query text) + +// Backend cache automatically cleared by ClusterSession.resetCachesIfQueryChanged() +``` + +**Why Promise Tracking is Critical**: + +```typescript +// Scenario: User switches tabs during Stage 1 fetch +// 1. User on Query Insights tab → Stage 1 request starts +// 2. User switches to Results tab → QueryInsightsTab unmounts +// 3. Stage 1 request completes while user on Results tab +// 4. State update happens in parent CollectionView.tsx +// 5. User returns to Query Insights tab → QueryInsightsTab remounts +// 6. Component checks parent state, sees stage1Data exists, doesn't re-fetch +// 7. If no stage1Data but stage1Promise exists → wait for existing promise + +// Without promise tracking: +if (!stage1Data && !stage1Loading) { + startStage1Fetch(); // ❌ Could start duplicate request if promise in flight +} + +// With promise tracking: +if (!stage1Data && !stage1Loading && !stage1Promise) { + startStage1Fetch(); // ✅ Only starts if no request in progress +} +``` + +**Key Behaviors**: + +- ✅ **Tab Switch Away → Component Unmounts**: State persists in parent CollectionView.tsx +- ✅ **In-Flight Request**: Promise stored in parent, continues executing even when component unmounted +- ✅ **Tab Switch Back → Component Remounts**: Immediately accesses parent state, no re-fetch needed +- ✅ **Request Completes While Away**: State update happens in parent, visible when returning +- ✅ **Query Execution**: Parent resets all query insights state (even if same query text re-executed) +- ✅ **Duplicate Prevention**: Promise tracking prevents multiple simultaneous requests + +**Testing Scenarios**: + +1. **Basic Flow**: Run query → Switch to Query Insights → Verify Stage 1 shows cached data +2. **Tab Switch During Load**: Activate Query Insights → Immediately switch to Results → Wait 2s → Return → Verify Stage 1 data visible +3. **Complete Stage 2 → Switch**: Complete Stage 2 → Switch to Results → Return → Verify Stage 2 data still visible +4. **AI During Tab Switch**: Request Stage 3 → Switch tabs during 8s delay → Return → Verify AI results show +5. **Query Re-execution Reset**: Complete all stages → Re-execute same query → Switch to Query Insights → Verify all state reset, new data fetched +6. **Duplicate Prevention**: Partially load Stage 1 → Switch away → Return before completion → Verify no duplicate request + +--- + +**5.2.6. UI Component Integration with Real Data** ✅ Complete + +**Goal**: Connect existing UI components to real Stage 1/2/3 data instead of mock values. + +**⚠️ Architecture Note**: Components receive data via props from parent CollectionView.tsx state. + +**Implementation**: + +```typescript +// In QueryInsightsTab.tsx: +const [stage1Data, setStage1Data] = useState(null); +const [stage1Loading, setStage1Loading] = useState(false); + +useEffect(() => { + // Fetch Stage 1 data when tab becomes active + if (isQueryInsightsTabActive && !stage1Data) { + setStage1Loading(true); + + void trpcClient.mongoClusters.collectionView.getQueryInsightsStage1 + .query() + .then((data) => { + setStage1Data(data); + setStage1Loading(false); + }) + .catch((error) => { + // Show error to user + void trpcClient.common.displayErrorMessage.mutate({ + message: l10n.t('Failed to load query insights'), + modal: false, + cause: error instanceof Error ? error.message : String(error), + }); + setStage1Loading(false); + }); + } +}, [isQueryInsightsTabActive]); +``` + +**Behavior**: + +- ✅ Loads Stage 1 data immediately when Query Insights tab is activated +- ✅ Data likely already cached from prefetch (fast response) +- ✅ If not cached, ClusterSession fetches from cache or generates new explain +- ✅ Stage 1 data persists in component state (no re-fetch on tab switch) + +**5.2.2. Stage 2: Automatic Progression After Stage 1** + +```typescript +const [stage2Data, setStage2Data] = useState(null); +const [stage2Loading, setStage2Loading] = useState(false); + +useEffect(() => { + // Start Stage 2 fetch immediately after Stage 1 completes + if (stage1Data && !stage2Data && !stage2Loading) { + setStage2Loading(true); + + void trpcClient.mongoClusters.collectionView.getQueryInsightsStage2 + .query() + .then((data) => { + setStage2Data(data); + setStage2Loading(false); + }) + .catch((error) => { + void trpcClient.common.displayErrorMessage.mutate({ + message: l10n.t('Failed to load detailed execution analysis'), + modal: false, + cause: error instanceof Error ? error.message : String(error), + }); + setStage2Loading(false); + }); + } +}, [stage1Data]); +``` + +**Behavior**: + +- ✅ Automatically starts after Stage 1 completes +- ✅ Runs explain("executionStats") which executes the query +- ✅ Updates UI with execution metrics (keysExamined, docsExamined, performance rating) +- ✅ Does NOT abort if user switches tabs (fetch continues in background) + +**5.2.3. Tab Switching Behavior** + +```typescript +// In CollectionView.tsx (tab management): +const [activeTab, setActiveTab] = useState<'results' | 'queryInsights' | 'schema'>('results'); + +const handleTabChange = (newTab: string) => { + setActiveTab(newTab); + // DO NOT abort ongoing Stage 1/2/3 fetches + // Fetches complete in background, results cached in component state +}; + +useEffect(() => { + if (activeTab === 'queryInsights') { + // Tab activated - trigger Stage 1 fetch if needed (see 5.2.1) + } + // When switching away, fetches continue in background +}, [activeTab]); +``` + +**Behavior**: + +- ✅ Fetches continue even when user switches to Results or other tabs +- ✅ When user returns to Query Insights tab, data is already loaded +- ✅ No need to re-fetch - component state preserves all Stage 1/2/3 data +- ✅ ClusterSession cache ensures consistent data if fetch completes after tab switch + +**5.2.4. Stage 3: AI Recommendations (User-Initiated)** + +```typescript +const [stage3Data, setStage3Data] = useState(null); +const [stage3Loading, setStage3Loading] = useState(false); + +const handleGetAIRecommendations = () => { + setStage3Loading(true); + + void trpcClient.mongoClusters.collectionView.getQueryInsightsStage3 + .query() + .then((data) => { + setStage3Data(data); + setStage3Loading(false); + }) + .catch((error) => { + void trpcClient.common.displayErrorMessage.mutate({ + message: l10n.t('Failed to get AI recommendations'), + modal: false, + cause: error instanceof Error ? error.message : String(error), + }); + setStage3Loading(false); + }); +}; +``` + +**Behavior**: + +- ✅ User clicks "Get Performance Insights" button +- ✅ Shows loading state for ~8 seconds (AI service delay) +- ✅ Continues even if user switches tabs +- ✅ Results persist in component state + +**5.2.5. Caching Strategy Summary** + +The caching happens at **two levels**: + +1. **Backend Cache (ClusterSession)**: + - Query planner info cached in `_queryPlannerCache` + - Execution stats cached in `_executionStatsCache` + - Cleared when query text changes + - Survives tab switches + +2. **Frontend Cache (Component State)**: + - `stage1Data`, `stage2Data`, `stage3Data` in React state + - Survives tab switches within same session + - Cleared when query changes (new execution → new sessionId → new data) + +**Key Behavior**: + +- ✅ If user switches tabs and returns, frontend state provides instant display +- ✅ If component unmounts and remounts (page navigation), backend cache prevents redundant explain calls +- ✅ If query changes, both caches reset automatically + +**5.2.6. UI Component Integration** + +Update existing mock components to use real data: + +```typescript +// Replace mock values with stage1Data/stage2Data: + + + + + +// Query Efficiency Analysis: + + + +// AI Recommendations: +{stage3Data?.improvementCards.map(card => ( + +))} +``` + +```typescript +// In QueryInsightsTab.tsx - Replace mock values with real data: + +// 1. Metrics Row (Stage 1 + Stage 2 data) + + + + + +// 2. Query Plan Overview (Stage 1 data) + + +// 3. Query Efficiency Analysis (Stage 2 data) + + +// 4. AI Recommendations (Stage 3 data) +{stage3Data && ( + + + + {stage3Data.improvementCards.map((card) => ( + + ))} + + + +)} + +// 5. Action Handlers +const handlePrimaryAction = (payload: ActionPayload) => { + if (payload.action === 'createIndex') { + void trpcClient.mongoClusters.collectionView.createIndex.mutate({ + indexSpec: payload.indexSpec, + }); + } else if (payload.action === 'dropIndex') { + void trpcClient.mongoClusters.collectionView.dropIndex.mutate({ + indexSpec: payload.indexSpec, + }); + } +}; + +const handleSecondaryAction = (payload: ActionPayload) => { + if (payload.action === 'copyCommand') { + void navigator.clipboard.writeText(payload.command); + } else if (payload.action === 'learnMore') { + void vscode.env.openExternal(vscode.Uri.parse(payload.url)); + } +}; +``` + +**Conditional Rendering Logic**: + +```typescript +// Stage 1: Always visible when tab is active +{stage1Loading && } +{stage1Data && } +{stage1Error && } + +// Stage 2: Shows "n/a" placeholders until loaded +{!stage2Data && !stage2Loading && } +{stage2Loading && } +{stage2Data && } + +// Stage 3: Shows button until loaded +{!stage3Data && !stage3Loading && } +{stage3Loading && } +{stage3Data && } +``` + +**Testing Checklist for UI Integration**: + +- [ ] Metrics show skeleton/loading states correctly +- [ ] Stage 1 data populates immediately when available +- [ ] Stage 2 metrics show "n/a" until loaded, then update +- [ ] Performance rating appears only after Stage 2 completes +- [ ] Query plan stages display correctly from Stage 1 data +- [ ] AI recommendations button triggers Stage 3 fetch +- [ ] AI loading state shows for ~8 seconds +- [ ] Improvement cards render with correct button payloads +- [ ] Primary actions (create/drop index) execute correctly +- [ ] Secondary actions (copy command, learn more) work correctly +- [ ] Tab switches don't interrupt data display +- [ ] Error states show user-friendly messages + +--- + +**Phase 5 Implementation Summary**: + +``` +5.1 Query Execution Logic: + ├─ 5.1.1 Non-blocking Stage 1 prefetch after results return + └─ 5.1.2 Placeholder for query state indicator (future: asterisk on tab) + +5.2 Frontend Query Insights Panel: + ├─ 5.2.1 Stage 1: Load on tab activation (cached from prefetch) + ├─ 5.2.2 Stage 2: Auto-start after Stage 1, populate detailed metrics + ├─ 5.2.3 Tab switching: No abort, preserve data in component state + ├─ 5.2.4 Stage 3: User-initiated, ~8s AI loading delay + ├─ 5.2.5 Two-level caching: ClusterSession + Component State + └─ 5.2.6 UI components: Replace mocks with real data +``` + +**Key Implementation Principles**: + +1. ✅ **Non-blocking**: Query results never wait for insights +2. ✅ **Progressive**: Stage 1 → Stage 2 → Stage 3 (each builds on previous) +3. ✅ **Cached**: Two-level caching prevents redundant fetches +4. ✅ **Resilient**: Fetches continue in background during tab switches +5. ✅ **User-Controlled**: Stage 3 AI analysis only on user request +6. ✅ **Automatic**: Stage 2 starts automatically after Stage 1 + +--- + +**Testing Checklist**: + +- [ ] Stage 1 loads when Query Insights tab is activated +- [ ] Stage 2 starts automatically after Stage 1 completes +- [ ] Switching to Results tab doesn't abort ongoing fetches +- [ ] Returning to Query Insights tab shows cached data +- [ ] Changing query clears both frontend and backend caches +- [ ] Stage 3 AI recommendations show after ~8s delay +- [ ] All loading states display skeleton/spinner appropriately +- [ ] Error states show user-friendly messages + +--- + +#### Phase 6: Testing & Validation + +**6.1. Unit Tests** ⬜ Not Started + +**📖 Before starting**: Review entire design document for edge cases and test scenarios mentioned in each stage section. + +- Test `ExplainPlanAnalyzer` with various explain outputs +- Test `StagePropertyExtractor` with different stage types +- Test `QueryInsightsAIService` with mock responses +- Test router transformation functions + +**6.2. Integration Tests** ⬜ Not Started + +**📖 Before starting**: Review "Implementation Details", "ClusterSession Integration", and router sections for integration patterns. + +- Test full Stage 1 flow (query → explain → transform → UI) +- Test full Stage 2 flow with execution stats +- Test full Stage 3 flow with AI service +- Test caching behavior in ClusterSession + +**6.3. End-to-End Tests** ⬜ Not Started + +**📖 Before starting**: Review all three stage sections for end-to-end behavior and performance expectations. + +- Test with real DocumentDB/MongoDB instance +- Test performance rating algorithm accuracy +- Test AI service integration +- Test error handling and edge cases + +--- + +#### Phase 7: Production Hardening + +**7.1. Error Handling** ⬜ Not Started + +**📖 Before starting**: Review "Additional Considerations" section for error handling strategies for each stage. + +- Add try-catch blocks for all explain operations +- Handle AI service timeouts and errors +- Add user-friendly error messages +- Implement retry logic with exponential backoff + +**7.2. Telemetry** ⬜ Not Started + +**📖 Before starting**: Review entire design document for telemetry requirements and success/failure metrics. + +- Add telemetry for Stage 1/2/3 success/failure +- Track AI service response times +- Monitor cache hit rates +- Track user interactions with recommendations + +**7.3. Performance Optimization** ⬜ Not Started + +**📖 Before starting**: Review "Session Management and Caching Strategy" and "Performance and Best Practices" sections. + +- Optimize ClusterSession cache memory usage +- Add cache TTL and eviction policies +- Optimize explain plan parsing performance +- Add lazy loading for Stage 2/3 data + +**7.4. Security** ⬜ Not Started + +**📖 Before starting**: Review "Security Guidelines" and button payload sections for security requirements. + +- Validate action payloads before execution +- Sanitize query strings sent to AI service +- Add rate limiting for AI service calls +- Validate index specifications before creation + +--- + +### Status Tracking + +#### Legend + +- ⬜ Not Started +- 🔄 In Progress +- ✅ Complete +- ⚠️ Blocked + +#### Progress Summary + +| Phase | Status | Progress | +| ------------------------- | -------------- | -------- | +| 1. Foundation & Types | ✅ Complete | 1/1 | +| 2. Explain Plan Analysis | ✅ Complete | 5/5 | +| 3. AI Service Integration | ✅ Complete | 1/1 | +| 4. Router Implementation | ✅ Complete | 2/2 | +| 5. Frontend Integration | 🔄 In Progress | 5/6 | +| 6. Testing & Validation | ⬜ Not Started | 0/3 | +| 7. Production Hardening | ⬜ Not Started | 0/4 | + +#### Detailed Status + +**Phase 1: Foundation & Types** + +- 1.1 Create Type Definitions: ✅ Complete + +**Phase 2: Explain Plan Analysis** + +- 2.1 Install Dependencies: ✅ Complete +- 2.2 Create ExplainPlanAnalyzer: ✅ Complete +- 2.3 Create StagePropertyExtractor: ✅ Complete +- 2.4 Create QueryInsightsApis and Integrate with ClustersClient: ✅ Complete +- 2.5 Extend ClusterSession for Caching: ✅ Complete + +**Phase 3: AI Service Integration** + +- 3.1 Create AI Service Client (mock with 8s delay): ✅ Complete +- 3.2 Extend ClusterSession for AI Integration: ⬜ Not Started (Deferred - not needed for Stage 3 endpoint) + +**Phase 4: Router Implementation** + +- 4.1 Implement tRPC Endpoints: ✅ Complete +- 4.2 Implement Transformation Functions: ✅ Complete + +**Phase 5: Frontend Integration** + +- 5.1 Update Query Execution Logic: 🔄 In Progress + - 5.1.1 Non-blocking Stage 1 Prefetch After Query Execution: ✅ Complete + - 5.1.2 Add Placeholder for Query State Indicator: ⬜ Not Started +- 5.2 Implement Frontend Query Insights Panel: ✅ Complete + - 5.2.1 Stage 1: Initial View on Tab Activation: ✅ Complete + - 5.2.2 Stage 2: Automatic Progression After Stage 1: ✅ Complete + - 5.2.3 Tab Switching Behavior (No Abort): ✅ Complete + - 5.2.4 Stage 3: AI Recommendations (User-Initiated): ✅ Complete + - 5.2.5 Two-Level Caching Strategy: ✅ Complete + - 5.2.6 UI Component Integration with Real Data: ✅ Complete + +**Phase 6: Testing & Validation** + +- 6.1 Unit Tests: ⬜ Not Started +- 6.2 Integration Tests: ⬜ Not Started +- 6.3 End-to-End Tests: ⬜ Not Started + +**Phase 7: Production Hardening** + +- 7.1 Error Handling: ⬜ Not Started +- 7.2 Telemetry: ⬜ Not Started +- 7.3 Performance Optimization: ⬜ Not Started +- 7.4 Security: ⬜ Not Started + +--- + +### Dependencies Between Steps + +``` +1.1 (Types) → 2.2, 2.3, 2.5, 3.1, 4.1, 4.2 +2.1 (Dependencies) → 2.2, 2.3 +2.2 (ExplainPlanAnalyzer) → 2.5, 4.1 +2.3 (StagePropertyExtractor) → 4.1 +2.4 (QueryInsightsApis) → 2.5 +2.5 (ClusterSession) → 4.1 +3.1 (AI Service Mock) → 3.2 +3.2 (AI in ClusterSession) → 4.1 +4.1 (Router Endpoints - Stage1/2/3) → 5.1, 5.2 +4.2 (Transformations - Stage1/2/3) → 4.1 +5.1 (Query Execution) → 5.2 +5.2 (Frontend Panel - Stage1/2/3 UI) → 6.2, 6.3 +``` + +**Note**: Frontend-facing functions use "Stage1", "Stage2", "Stage3" terminology for clarity. + +--- + +### Recommended Parallel Work Streams + +**Stream 1: Backend Foundation (Can work in parallel)** + +- 1.1 Create Type Definitions (frontend types + AI service types) +- 2.1 Install Dependencies (@mongodb-js/explain-plan-helper) + +**Stream 2: Explain Plan Analysis (After Stream 1 complete)** + +- 2.2 Create ExplainPlanAnalyzer +- 2.3 Create StagePropertyExtractor +- 2.4 Create QueryInsightsApis (follows LlmEnhancedFeatureApis pattern - NOT in ClustersClient) + +**Stream 3: Caching Layer (After Stream 2 complete)** + +- 2.5 Extend ClusterSession for Caching (moved to client/ folder, uses QueryInsightsApis) + +**Stream 4: AI Integration (Can start after 1.1, parallel to Stream 2)** + +- 3.1 Create AI Service Mock (8-second delay, returns webview mock data) +- 3.2 Extend ClusterSession for AI Integration + +**Stream 5: Transformations (Can work parallel to Streams 2-4)** + +- 4.2 Implement Transformation Functions (transformStage1/2/3Response - separate file in queryInsights/) + +**Stream 6: Router (After Streams 3, 4, and 5 complete)** + +- 4.1 Implement tRPC Endpoints (getQueryInsightsStage1/Stage2/Stage3 - no storeQueryMetadata) + +**Stream 7: Frontend (After Stream 6 complete)** + +- 5.1 Update Query Execution Logic (server-side metadata tracking) +- 5.2 Implement Frontend Query Insights Panel (Stage 1/2/3 UI components) + +**Stream 8: Quality Assurance (Can start incrementally)** + +- 6.1 Unit Tests (as each component completes) +- 6.2 Integration Tests (after Stream 6) +- 6.3 End-to-End Tests (after Stream 7) + +**Stream 9: Hardening (Final phase)** + +- 7.1-7.4 All production hardening tasks + +--- + +## Key Simplifications Summary + +1. **File Organization**: Moved `ClusterSession` and `ClustersClient` to `src/documentdb/client/` folder for better organization and future extensibility +2. **QueryInsightsApis Pattern**: Created `QueryInsightsApis.ts` following `LlmEnhancedFeatureApis` pattern - explain functionality is NOT in ClustersClient +3. **No Backend Cache Types**: Use simple Map structures with inline types +4. **No Collection/Index Stats Methods**: Not needed for MVP - AI backend handles data collection +5. **No storeQueryMetadata Endpoint**: Query metadata tracked server-side automatically +6. **Transformation Functions**: Separate file (`transformations.ts`) with Stage1/2/3 terminology +7. **AI Service**: Mock implementation with 8s delay, actual integration commented out for future +8. **Frontend-Facing Terminology**: Router endpoints and transformation functions use "Stage1", "Stage2", "Stage3" naming +9. **Backend Generic Types**: Backend code avoids webview-specific terminology +10. **Review Reminders**: Each implementation step includes 📖 reminder to review relevant design document sections + +--- + +**Important**: Before implementing any step, always review the entire design document. Each section contains critical implementation details, patterns, and architectural decisions that inform the implementation. diff --git a/docs/index-advisor-data-flow.md b/docs/index-advisor-data-flow.md new file mode 100644 index 000000000..519f89233 --- /dev/null +++ b/docs/index-advisor-data-flow.md @@ -0,0 +1,591 @@ +# Index Advisor Data Flow - Privacy Review Documentation + +## Overview + +This document describes the data flow for the MongoDB Index Advisor feature, with emphasis on customer data handling and privacy considerations. The Index Advisor feature uses GitHub Copilot's language models to analyze query execution plans and provide index optimization recommendations. + +## Current Implementation (v1.0) + +The Index Advisor supports two operational modes: + +1. **Standard Mode**: Fetches data from the database in real-time +2. **Preload Mode**: Uses pre-provided execution plan and statistics + +### Data Flow Diagram - Standard Mode + +``` +┌─────────────────┐ +│ User Input │ +│ (Find Query) │ +└────────┬────────┘ + │ + v +┌─────────────────────────────────────────────────────────┐ +│ Query Optimization Context │ +│ - Database name │ +│ - Collection name │ +│ - Query object (filter, sort, projection, etc.) │ +│ - Command type (Find/Aggregate/Count) │ +│ - Cluster ID (for database connection) │ +└────────┬────────────────────────────────────────────────┘ + │ + v +┌─────────────────────────────────────────────────────────┐ +│ Data Collection Process │ +│ 1. Execute query with explain() to get execution plan │ +│ 2. Fetch collection statistics (collStats) │ +│ 3. Fetch index statistics ($indexStats) │ +│ 4. Get cluster metadata (Azure/non-Azure info) │ +└────────┬────────────────────────────────────────────────┘ + │ + v +┌─────────────────────────────────────────────────────────┐ +│ Data Sanitization Process │ +│ 1. Remove constant values from query filters │ +│ 2. Replace literal values with placeholder │ +│ 3. Preserve field names and operators │ +│ 4. Sanitize all stages in execution plan │ +│ 5. Process command field, parsedQuery, and stages │ +└────────┬────────────────────────────────────────────────┘ + │ + v +┌─────────────────────────────────────────────────────────┐ +│ Data Sent to LLM (GitHub Copilot) │ +│ + Database name (metadata) │ +│ + Collection name (metadata) │ +│ + Collection statistics (counts, sizes) │ +│ + Index statistics (names, keys, usage stats) │ +│ + Sanitized execution plan (structure only) │ +│ + Cluster metadata (Azure type, API version) │ +│ - NO actual customer data values from queries │ +│ - NO literal filter values │ +└────────┬────────────────────────────────────────────────┘ + │ + v +┌─────────────────────────────────────────────────────────┐ +│ GitHub Copilot LLM Processing │ +│ - Analyzes execution plan structure │ +│ - Reviews collection and index statistics │ +│ - Generates index recommendations │ +└────────┬────────────────────────────────────────────────┘ + │ + v +┌─────────────────────────────────────────────────────────┐ +│ Response │ +│ - Index recommendations │ +│ - Performance optimization suggestions │ +│ - Explanation of recommendations │ +└────────┬────────────────────────────────────────────────┘ + │ + v +┌─────────────────┐ +│ Display to │ +│ User │ +└─────────────────┘ +``` + +### Data Flow Diagram - Preload Mode + +``` +┌─────────────────────────────────────┐ +│ User Input (Preload Mode) │ +│ - Pre-collected execution plan │ +│ - Pre-collected collection stats │ +│ - Pre-collected index stats │ +└────────┬────────────────────────────┘ + │ + v +┌─────────────────────────────────────────────────────────┐ +│ Query Optimization Context (Preload) │ +│ - Database name │ +│ - Collection name │ +│ - Command type (Find/Aggregate/Count) │ +│ - executionPlan (pre-provided) │ +│ - collectionStats (pre-provided) │ +│ - indexStats (pre-provided) │ +│ - NO cluster ID (database connection not needed) │ +└────────┬────────────────────────────────────────────────┘ + │ + v +┌─────────────────────────────────────────────────────────┐ +│ Data Sanitization Process │ +│ 1. Remove constant values from query filters │ +│ 2. Replace literal values with placeholder │ +│ 3. Preserve field names and operators │ +│ 4. Sanitize all stages in execution plan │ +│ 5. Process command field, parsedQuery, and stages │ +│ Note: Pre-loaded execution plan may already contain │ +│ customer query values that need sanitization │ +└────────┬────────────────────────────────────────────────┘ + │ + v +┌─────────────────────────────────────────────────────────┐ +│ Data Sent to LLM (GitHub Copilot) │ +│ + Database name (metadata) │ +│ + Collection name (metadata) │ +│ + Collection statistics (from pre-loaded data) │ +│ + Index statistics (from pre-loaded data) │ +│ + Sanitized execution plan (structure only) │ +│ + Minimal cluster metadata (isAzure: false) │ +│ - NO actual customer data values from queries │ +│ - NO literal filter values │ +└────────┬────────────────────────────────────────────────┘ + │ + v +┌─────────────────────────────────────────────────────────┐ +│ GitHub Copilot LLM Processing │ +│ - Analyzes execution plan structure │ +│ - Reviews collection and index statistics │ +│ - Generates index recommendations │ +└────────┬────────────────────────────────────────────────┘ + │ + v +┌─────────────────────────────────────────────────────────┐ +│ Response │ +│ - Index recommendations │ +│ - Performance optimization suggestions │ +│ - Explanation of recommendations │ +└────────┬────────────────────────────────────────────────┘ + │ + v +┌─────────────────┐ +│ Display to │ +│ User │ +└─────────────────┘ +``` + +### Operational Modes Comparison + +| Aspect | Standard Mode | Preload Mode | +| ----------------------- | ----------------------------------- | ----------------------------------------- | +| **Database Connection** | Required (uses cluster ID) | Not required | +| **Data Collection** | Real-time from database | Pre-provided by caller | +| **Use Case** | Interactive optimization in VS Code | Batch processing, external tools, testing | +| **Execution Plan** | Fetched via explain() | Provided in context | +| **Collection Stats** | Fetched via collStats | Provided in context | +| **Index Stats** | Fetched via $indexStats | Provided in context | +| **Cluster Metadata** | Fetched from connection | Minimal default (non-Azure) | +| **Sanitization** | Applied to fetched data | Applied to pre-loaded data | +| **Privacy Impact** | Same (all data sanitized) | Same (all data sanitized) | + +### Customer Data Categories + +#### Standard Mode: Data Collected from Database + +1. **Query Execution Plan**: + - Fetched: Complete explain() output from MongoDB + - Contains: Query filters with actual literal values + - Purpose: Performance analysis + - Lifecycle: Sanitized before sending to LLM + +2. **Collection Statistics**: + - Document count, storage size, index sizes + - Average document size + - Number of indexes + +3. **Index Statistics**: + - Index names and key patterns + - Index usage statistics (ops, since) + - Index sizes + +4. **Cluster Metadata**: + - Whether cluster is hosted on Azure + - Azure Cosmos DB API type (if applicable) + +#### Preload Mode: Data Provided by Caller + +1. **Pre-loaded Execution Plan**: + - Source: Provided by external caller + - Contains: May include query filters with actual literal values + - **Privacy Risk**: Caller must ensure they want to share this data + - Lifecycle: Sanitized before sending to LLM (same as standard mode) + +2. **Pre-loaded Collection Statistics**: + - Provided as-is by caller + - Expected format: Same as MongoDB collStats output + +3. **Pre-loaded Index Statistics**: + - Provided as-is by caller + - Expected format: Same as MongoDB $indexStats output + +4. **Cluster Metadata**: + - Not provided in preload mode + - Default: `{ isAzure: false, api: 'N/A' }` + +**The extension still applies full sanitization** to preloaded data. + +#### Data Sent to LLM (After Sanitization) + +1. **Metadata**: + - Database name (e.g., "customerDB") + - Collection name (e.g., "users") + - Command type ("find", "aggregate", or "count") + +2. **Collection Statistics**: + - Numeric metrics only (counts, sizes) + - No customer data values + + ```json + { + "count": 150000, + "size": 45000000, + "avgObjSize": 300, + "storageSize": 50000000, + "totalIndexSize": 5000000, + "nindexes": 3 + } + ``` + +3. **Index Statistics**: + - Index definitions (field names and sort order) + - Usage statistics (operation counts) + - **NO indexed values** + + ```json + [ + { + "name": "email_1", + "key": { "email": 1 }, + "accesses": { + "ops": 12500, + "since": "2024-01-15T10:30:00.000Z" + } + } + ] + ``` + +4. **Sanitized Execution Plan**: + - Query structure with field names + - Operators and stage types + - Performance metrics (nReturned, executionTimeMillis, etc.) + - **All literal values replaced with `` placeholder** + +5. **Cluster Metadata**: + - `isAzureCluster`: boolean indicator + +- `AzureClusterType`: API type if Azure (e.g., "MongoDB RU", "DocumentDB") + +### Sanitization Process Details + +The sanitization process is implemented in `src/commands/llmEnhancedCommands/indexAdvisorCommands.ts` and includes the following operations: + +#### 1. Filter Value Sanitization + +Original filter values are replaced with the generic placeholder ``: + +**Before Sanitization (Never Sent):** + +```json +{ + "filter": { + "email": "john.doe@example.com", + "age": { "$gt": 25 }, + "status": "active" + } +} +``` + +**After Sanitization (Sent to LLM):** + +```json +{ + "filter": { + "email": "", + "age": { "$gt": "" }, + "status": "" + } +} +``` + +#### 2. Execution Plan Stage Sanitization + +All stages in the execution plan are recursively sanitized: + +- **`command` field**: Redacted or filter values replaced +- **`parsedQuery` field**: Filter values replaced +- **`filter` field in stages**: Values replaced +- **`indexFilterSet` array**: Each filter object sanitized +- **`runtimeFilterSet` array**: Each filter object sanitized +- **Nested stages**: Recursively processed + - `inputStage` + - `inputStages` array + - `shards` array with `executionStages` + +#### 3. What Gets Sanitized + +| Component | Original Data | After Sanitization | +| --------------------- | ----------------------- | --------------------------------- | +| Filter literal values | `"john@example.com"` | `""` | +| Numeric comparisons | `{ "$gt": 25 }` | `{ "$gt": "" }` | +| Array values | `["active", "pending"]` | `["", ""]` | +| Nested object values | `{ "city": "Seattle" }` | `{ "city": "" }` | +| Field names | `"email"` | `"email"` (Preserved) | +| Operators | `"$gt"`, `"$in"` | `"$gt"`, `"$in"` (Preserved) | +| Stage types | `"IXSCAN"`, `"FETCH"` | `"IXSCAN"`, `"FETCH"` (Preserved) | +| Performance metrics | `nReturned: 100` | `nReturned: 100` (Preserved) | + +#### 4. Code Reference + +Key sanitization functions in `indexAdvisorCommands.ts`: + +```typescript +// Main sanitization entry point +export function sanitizeExplainResult(explainResult: unknown): unknown; + +// Removes constants from filter objects +function removeConstantsFromFilter(obj: unknown): unknown; + +// Recursively sanitizes execution plan stages +function sanitizeStage(stage: unknown): unknown; +``` + +**Preload Mode Code Pattern:** + +```typescript +// Check if we have pre-loaded data +const hasPreloadedData = queryContext.executionPlan; + +if (hasPreloadedData) { + // Use pre-loaded data (no database connection needed) + explainResult = queryContext.executionPlan; + collectionStats = queryContext.collectionStats!; + indexes = queryContext.indexStats!; + + // For pre-loaded data, create a minimal cluster info + clusterInfo = { + domainInfo_isAzure: 'false', + domainInfo_api: 'N/A', + }; +} else { + // Standard mode: fetch from database + const client = await ClustersClient.getClient(queryContext.clusterId); + // ... fetch execution plan, stats, etc. +} + +// Regardless of mode, sanitize before sending to LLM +const sanitizedExplainResult = sanitizeExplainResult(explainResult); +``` + +### Complete Example: Find Query + +#### User's Query (Input): + +```javascript +db.users + .find({ + email: 'john.doe@example.com', + age: { $gt: 25 }, + }) + .sort({ name: -1 }) + .limit(10); +``` + +#### Query Object (Parsed Locally): + +```json +{ + "filter": { + "email": "john.doe@example.com", + "age": { "$gt": 25 } + }, + "sort": { "name": -1 }, + "limit": 10 +} +``` + +#### Execution Plan (Before Sanitization - Never Sent): + +```json +{ + "queryPlanner": { + "parsedQuery": { + "email": "john.doe@example.com", + "age": { "$gt": 25 } + }, + "winningPlan": { + "stage": "LIMIT", + "limitAmount": 10, + "inputStage": { + "stage": "FETCH", + "filter": { + "age": { "$gt": 25 } + }, + "inputStage": { + "stage": "IXSCAN", + "keyPattern": { "email": 1 }, + "indexFilterSet": [{ "email": "john.doe@example.com" }] + } + } + } + }, + "executionStats": { + "nReturned": 1, + "executionTimeMillis": 15, + "totalKeysExamined": 1, + "totalDocsExamined": 1 + } +} +``` + +#### Execution Plan (After Sanitization - Sent to LLM): + +```json +{ + "queryPlanner": { + "parsedQuery": { + "email": "", + "age": { "$gt": "" } + }, + "winningPlan": { + "stage": "LIMIT", + "limitAmount": 10, + "inputStage": { + "stage": "FETCH", + "filter": { + "age": { "$gt": "" } + }, + "inputStage": { + "stage": "IXSCAN", + "keyPattern": { "email": 1 }, + "indexFilterSet": [{ "email": "" }] + } + } + } + }, + "executionStats": { + "nReturned": 1, + "executionTimeMillis": 15, + "totalKeysExamined": 1, + "totalDocsExamined": 1 + } +} +``` + +**Key Privacy Point**: The LLM receives the execution plan structure showing: + +- Field names being queried ("email", "age") +- Operators being used ("$gt") +- Index usage patterns +- Performance metrics + +But it does NOT receive: + +- The actual email address "john.doe@example.com" +- The actual age threshold value 25 +- Any other literal values from the query + +This allows the LLM to understand query patterns and suggest index optimizations without accessing sensitive customer data. + +## Proposed Future Enhancement (v2.0) - Under Privacy Review + +### Overview of Proposed Change + +To enable more accurate index recommendations for complex queries, we are considering providing the original query structure and unsanitized execution plans to the LLM. This would allow the model to: + +1. Understand the actual selectivity of filter conditions +2. Provide more precise index recommendations based on data distribution +3. Suggest compound indexes with optimal field order based on actual query patterns + +### Additional Data Flow (Proposed) + +``` +┌─────────────────────────────────────┐ +│ User Input (New Option) │ +│ - Original query with literals │ +│ - User consent to share query data │ +└────────┬────────────────────────────┘ + │ + v +┌─────────────────────────────────────┐ +│ Data Sent to LLM (Additional) │ +│ + Original query with filter values│ +│ + Unsanitized execution plan │ +│ ! May contain customer data in │ +│ query predicates │ +└─────────────────────────────────────┘ +``` + +### Privacy Concerns with Proposed Enhancement + +#### New Customer Data Being Sent to LLM: + +1. **Original Query Filters**: + - Content: Actual literal values used in query predicates + - Risk: May contain sensitive customer data + - Example: + ```javascript + db.users.find({ + email: 'customer@company.com', // WARNING: Customer email + accountId: 'ACC-789012', // WARNING: Account identifier + lastLoginDate: { $gte: ISODate('2024-01-15') }, // WARNING: Temporal data + }); + ``` + +2. **Unsanitized Execution Plan**: + - Contains: Filter predicates with actual values + - Risk: Exposes data values throughout the execution plan tree + - Impact: All stages (IXSCAN, FETCH, etc.) would include literal values + +3. **Data Distribution Insights**: + - Cardinality of specific values + - Selectivity ratios based on actual data + - Could reveal business logic and data patterns + +#### Privacy Risk Assessment: + +| Data Type | Current (v1.0) | Proposed (v2.0) | Risk Level | +| ------------------------- | --------------------------- | -------------------- | ------------------------ | +| Filter literal values | NO (Sanitized to ``) | **Would be sent** | **High** | +| Collection statistics | Sent (aggregate only) | Sent | Low (metadata) | +| Index definitions | Sent (structure only) | Sent | Low (metadata) | +| Execution plan structure | Sent (sanitized) | **Sent unsanitized** | **High** | +| Database/collection names | Sent | Sent | Low (metadata) | +| Performance metrics | Sent | Sent | Low (numeric only) | +| Query field names | Sent | Sent | Low-Medium (schema info) | + +### Benefits of Proposed Enhancement: + +1. **Improved Recommendation Accuracy**: + - Better understanding of query selectivity + - More precise compound index field ordering + - Cardinality-aware index suggestions + +2. **Performance Optimization**: + - Recommendations based on actual data distribution + - Better coverage analysis for multi-field indexes + - More accurate impact predictions + +3. **Cost Optimization**: + - Identify over-indexing based on actual usage patterns + - Recommend index consolidation opportunities + +## Privacy Best Practices + +### Current Implementation (v1.0): + +1. **Aggressive Sanitization**: All literal values replaced with placeholders (both modes) +2. **Metadata Only**: Only structural and statistical data sent to LLM +3. **No Sample Data**: Unlike query generation, no documents fetched +4. **Field Names Preserved**: Allows meaningful recommendations +5. **Operator Preservation**: Maintains query pattern analysis capability +6. **Mode-Independent Privacy**: Sanitization applied regardless of standard or preload mode + +### Preload Mode Specific Considerations: + +1. **Caller Responsibility**: External callers must understand the data they provide +2. **Pre-sanitization Option**: Callers can sanitize data before providing it (defense in depth) +3. **Extension Still Sanitizes**: Even in preload mode, the extension applies full sanitization +4. **No Database Connection**: Preload mode cannot verify data sensitivity against database +5. **Use Case Awareness**: Primarily for testing, batch processing, and external tool integration + +### If Future Enhancement Implemented (v2.0): + +1. **Explicit Consent Required**: Users must opt-in to share query values +2. **Clear Data Disclosure**: Show exactly what data will be shared + +## Compliance Notes + +- Current implementation (v1.0) with sanitization minimizes PII exposure +- Metadata sharing (database/collection names, field names) may still be considered sensitive in some contexts +- Organizations should review their data classification policies +- Proposed v2.0 enhancement would require additional privacy impact assessment diff --git a/docs/index.md b/docs/index.md index 8a20d0100..277b9b5a5 100644 --- a/docs/index.md +++ b/docs/index.md @@ -48,7 +48,7 @@ The User Manual provides guidance on using DocumentDB for VS Code. It contains d - [Connecting with a URL](./user-manual/how-to-construct-url) - [Service Discovery](./user-manual/service-discovery) - - [Azure Cosmos DB for MongoDB (vCore)](./user-manual/service-discovery-azure-cosmosdb-for-mongodb-vcore) + - [Azure DocumentDB](./user-manual/service-discovery-azure-cosmosdb-for-mongodb-vcore) - [Azure Cosmos DB for MongoDB (RU)](./user-manual/service-discovery-azure-cosmosdb-for-mongodb-ru) - [Azure VMs (DocumentDB)](./user-manual/service-discovery-azure-vms) - [Managing Azure Subscriptions](./user-manual/managing-azure-discovery) @@ -64,9 +64,10 @@ The User Manual provides guidance on using DocumentDB for VS Code. It contains d Explore the history of updates and improvements to the DocumentDB for VS Code extension. Each release brings new features, enhancements, and fixes to improve your experience. -- [0.5](./release-notes/0.5) | [0.5.1](./release-notes/0.5#patch-release-v051) | [0.5.2](./release-notes/0.5#patch-release-v052) -- [0.4](./release-notes/0.4) | [0.4.1](./release-notes/0.4#patch-release-v041) -- [0.3](./release-notes/0.3) | [0.3.1](./release-notes/0.3#patch-release-v031) +- [0.6](./release-notes/0.6) +- [0.5](./release-notes/0.5), [0.5.1](./release-notes/0.5#patch-release-v051), [0.5.2](./release-notes/0.5#patch-release-v052) +- [0.4](./release-notes/0.4), [0.4.1](./release-notes/0.4#patch-release-v041) +- [0.3](./release-notes/0.3), [0.3.1](./release-notes/0.3#patch-release-v031) - [0.2.4](./release-notes/0.2.4) - [0.2.3](./release-notes/0.2.3) - [0.2.2](./release-notes/0.2.2) diff --git a/docs/query-generation-data-flow.md b/docs/query-generation-data-flow.md new file mode 100644 index 000000000..2d7d8a7d7 --- /dev/null +++ b/docs/query-generation-data-flow.md @@ -0,0 +1,231 @@ +# Query Generation Data Flow - Privacy Review Documentation + +## Overview + +This document describes the data flow for the MongoDB query generation feature, with emphasis on customer data handling and privacy considerations. The query generation feature uses GitHub Copilot's language models to convert natural language descriptions into MongoDB queries. + +## Current Implementation (v1.0) + +### Data Flow Diagram + +``` +┌─────────────────┐ +│ User Input │ +│ (Natural Lang) │ +└────────┬────────┘ + │ + v +┌─────────────────────────────────────────────────────────┐ +│ Query Generation Context │ +│ - Database name │ +│ - Collection name(s) │ +│ - Target query type (Find/Aggregation) │ +│ - Natural language query description │ +└────────┬────────────────────────────────────────────────┘ + │ + v +┌─────────────────────────────────────────────────────────┐ +│ Schema Inference Process │ +│ 1. Fetch sample documents from customer database │ +│ 2. Analyze document structure locally │ +│ 3. Generate schema definition (field types only) │ +│ 4. DISCARD original documents │ +└────────┬────────────────────────────────────────────────┘ + │ + v +┌─────────────────────────────────────────────────────────┐ +│ Data Sent to LLM (GitHub Copilot) │ +│ ✓ Database name (metadata) │ +│ ✓ Collection name(s) (metadata) │ +│ ✓ Schema structure (field names and types only) │ +│ ✓ Natural language query from user │ +│ ✗ NO actual customer data values │ +│ ✗ NO sample documents │ +└────────┬────────────────────────────────────────────────┘ + │ + v +┌─────────────────────────────────────────────────────────┐ +│ GitHub Copilot LLM Processing │ +│ - Processes schema structure │ +│ - Generates MongoDB query syntax │ +└────────┬────────────────────────────────────────────────┘ + │ + v +┌─────────────────────────────────────────────────────────┐ +│ Response │ +│ - Generated MongoDB query (JSON) │ +│ - Explanation of the query logic │ +└────────┬────────────────────────────────────────────────┘ + │ + v +┌─────────────────┐ +│ Display to │ +│ User │ +└─────────────────┘ +``` + +### Customer Data Categories + +#### Data Collected Locally (Never Sent to LLM) + +1. **Sample Documents**: + - Fetched: 3-10 documents per collection + - Purpose: Schema inference only + - Lifecycle: Used for schema analysis, then immediately discarded + - Storage: Temporary in-memory only, never persisted + +2. **Actual Data Values**: + - Type: Any customer data content (strings, numbers, objects, etc.) + - Handling: Never extracted, never sent to LLM + - Example: If a document has `{"name": "John Doe", "age": 30}`, these values are never sent + +#### Data Sent to LLM + +1. **Metadata**: + - Database name (e.g., "customerDB") + - Collection name(s) (e.g., "users", "orders") + - Target query type ("Find" or "Aggregation") + +2. **Schema Structure**: + - Field names (e.g., "name", "age", "email") + - Field types (e.g., "string", "number", "object") + - Nested structure (e.g., "address.city" is a string) + - **NO actual values from customer documents** + +3. **User Input**: + - Natural language query description provided by the user + - Example: "Find all users who are over 25 years old" + +### Schema Inference Example + +#### Customer's Actual Document (Never Sent): +```json +{ + "_id": "507f1f77bcf86cd799439011", + "name": "John Doe", + "email": "john.doe@example.com", + "age": 30, + "address": { + "city": "Seattle", + "state": "WA", + "zipCode": "98101" + }, + "orders": [ + {"orderId": "ORD-001", "total": 99.99} + ] +} +``` + +#### Schema Definition Sent to LLM: +```json +{ + "collectionName": "users", + "fields": { + "_id": "string", + "name": "string", + "email": "string", + "age": "number", + "address": { + "city": "string", + "state": "string", + "zipCode": "string" + }, + "orders": [ + { + "orderId": "string", + "total": "number" + } + ] + } +} +``` + +**Key Privacy Point**: Only the structure (field names and types) is sent. Actual values like "John Doe", "john.doe@example.com", "Seattle", etc., are never included in the LLM request. + +### Code Reference + +The schema inference is implemented in `src/utils/schemaInference.ts`: + +```typescript +export function generateSchemaDefinition( + documents: Array, + collectionName?: string, +): SchemaDefinition { + // Processes documents to extract ONLY field names and types + // Returns structure without any actual data values +} +``` + +The query generation call in `src/commands/llmEnhancedCommands/queryGenerationCommands.ts`: + +```typescript +// Sample documents are fetched +const sampleDocs = await client.getSampleDocuments( + queryContext.databaseName, + queryContext.collectionName, + 10 +); + +// Schema is extracted (structure only) +const schema = generateSchemaDefinition(sampleDocs, queryContext.collectionName); + +// Original documents are discarded after this point +// Only schema structure is used in prompt template +``` + +## Proposed Future Enhancement (v2.0) - Under Privacy Review + +### Overview of Proposed Change + +To enable query modification features, we are considering allowing users to provide their existing MongoDB queries for modification or optimization. + +### Additional Data Flow (Proposed) + +``` +┌─────────────────────────────────────┐ +│ User Input (New) │ +│ - Natural language modification │ +│ request │ +│ - Existing MongoDB query (CUSTOMER │ +│ CREATED, may contain literals) │ +└────────┬────────────────────────────┘ + │ + v +┌─────────────────────────────────────┐ +│ Data Sent to LLM (Additional) │ +│ ✓ User's original query structure │ +│ ⚠ May contain customer-specified │ +│ literals/values in query filters │ +└─────────────────────────────────────┘ +``` + +### Privacy Concerns with Proposed Enhancement + +#### New Customer Data Being Sent to LLM: + +1. **User's Original Query**: + - Content: MongoDB query syntax provided by the user + - Risk: May contain literal values used in filters + - Example: + ```javascript + // User's query may contain: + db.users.find({ + "email": "specific@customer.com", // ⚠ Customer email + "accountId": "ACCT-12345" // ⚠ Customer account ID + }) + ``` + +2. **Embedded Literals**: + - Query filters often contain specific values + - These values could be sensitive customer data + - Examples: email addresses, account IDs, names, dates, amounts + +#### Privacy Risk Assessment: + +| Data Type | Current (v1.0) | Proposed (v2.0) | Risk Level | +|-----------|----------------|-----------------|------------| +| Sample document values | ✗ Never sent | ✗ Never sent | None | +| Schema structure | ✓ Sent | ✓ Sent | Low (metadata only) | +| Database/collection names | ✓ Sent | ✓ Sent | Low (metadata) | +| User's natural language input | ✓ Sent | ✓ Sent | Low-Medium (user provided) | +| Query literals/filters | ✗ Not applicable | ⚠ **Would be sent** | **Medium-High** | diff --git a/docs/release-notes/0.2.4.md b/docs/release-notes/0.2.4.md index ab8ece60d..fe71f5a66 100644 --- a/docs/release-notes/0.2.4.md +++ b/docs/release-notes/0.2.4.md @@ -40,7 +40,7 @@ Here are a few examples of how you can use the new query syntax: ### 1️⃣ **Modernized Azure Integration** -We have migrated our Azure discovery mechanism to use the new `@azure/arm-mongocluster` package. This update improves the reliability and performance of discovering Azure Cosmos DB for MongoDB (vCore) and other DocumentDB resources. +We have migrated our Azure discovery mechanism to use the new `@azure/arm-mongocluster` package. This update improves the reliability and performance of discovering Azure DocumentDB and other DocumentDB resources. ### 2️⃣ **Automated Release Pipeline** diff --git a/docs/release-notes/0.3.md b/docs/release-notes/0.3.md index 1f65a2a19..f47764007 100644 --- a/docs/release-notes/0.3.md +++ b/docs/release-notes/0.3.md @@ -6,20 +6,20 @@ # DocumentDB for VS Code Extension v0.3 -We are excited to announce the release of **DocumentDB for VS Code Extension v0.3**. This release introduces a major new feature: support for **Microsoft Entra ID** authentication with **Azure Cosmos DB for MongoDB (vCore)** clusters. This enhances security for enterprise scenarios and aligns with modern identity management practices. +We are excited to announce the release of **DocumentDB for VS Code Extension v0.3**. This release introduces a major new feature: support for **Microsoft Entra ID** authentication with **Azure DocumentDB** clusters. This enhances security for enterprise scenarios and aligns with modern identity management practices. ## What's New in v0.3 -### ⭐ **Support for Entra ID for Azure Cosmos DB for MongoDB (vCore)** ([#123](https://github.com/microsoft/vscode-documentdb/issues/123)) +### ⭐ **Support for Entra ID for Azure DocumentDB** ([#123](https://github.com/microsoft/vscode-documentdb/issues/123)) -You can now connect to Azure Cosmos DB for MongoDB (vCore) clusters using Microsoft Entra ID (formerly Azure AD), providing a secure, passwordless authentication method. This feature is integrated throughout the extension's connection workflows. +You can now connect to Azure DocumentDB clusters using Microsoft Entra ID (formerly Azure AD), providing a secure, passwordless authentication method. This feature is integrated throughout the extension's connection workflows. For an efficient authentication process, the extension leverages your existing Azure session within VS Code. If you are already signed in to Azure, your identity will be used automatically for Entra ID authentication. If no active session is found, you will be prompted to sign in to your Azure account first. The available authentication methods are: - **Username and Password**: The traditional authentication method. -- **Entra ID for Azure Cosmos DB for MongoDB (vCore)**: Authenticate using your Microsoft Entra ID credentials. +- **Entra ID for Azure DocumentDB**: Authenticate using your Microsoft Entra ID credentials. Authentication Method Selection @@ -38,7 +38,7 @@ When you save a connection from the **Service Discovery** view, it is added to y When adding a new connection using a connection string, you can now select your preferred authentication method from a list of all supported methods. -To help guide you, if the provided hostname is not recognized as an Azure Cosmos DB for MongoDB (vCore) cluster, a note ("Cluster support unknown") will appear next to the Entra ID option. We display all available methods to ensure you are not blocked from using this feature, even with custom or unrecognized hostnames. +To help guide you, if the provided hostname is not recognized as an Azure DocumentDB cluster, a note ("Cluster support unknown") will appear next to the Entra ID option. We display all available methods to ensure you are not blocked from using this feature, even with custom or unrecognized hostnames. ## Changelog diff --git a/docs/release-notes/0.4.md b/docs/release-notes/0.4.md index a9820ef86..570f60f35 100644 --- a/docs/release-notes/0.4.md +++ b/docs/release-notes/0.4.md @@ -21,7 +21,7 @@ This release improves how developers working with Azure discover, connect to, an > > The full integration experience will be enabled when the Azure Resources extension update is released in the coming days. -This release improves the user experience for developers in the Azure ecosystem by integrating directly with the **Azure Resources extension**. The DocumentDB extension now takes ownership of the **Azure Cosmos DB for MongoDB (RU)** and **(vCore)** nodes directly within the Azure resource tree. +This release improves the user experience for developers in the Azure ecosystem by integrating directly with the **Azure Resources extension**. The DocumentDB extension now takes ownership of the **Azure Cosmos DB for MongoDB (RU)** and **DocumentDB** nodes directly within the Azure resource tree. This collaboration provides a single, authoritative view for all your Azure resources while enriching the experience with the specialized MongoDB tooling that our extension provides. @@ -34,7 +34,7 @@ This collaboration provides a single, authoritative view for all your Azure reso ### 2️⃣ **Service Discovery for Azure Cosmos DB for MongoDB (RU)** ([#244](https://github.com/microsoft/vscode-documentdb/issues/244)) -We've expanded our service discovery capabilities by adding a dedicated provider for **Azure Cosmos DB for MongoDB (RU)** resources. This complements our existing vCore provider and makes connecting to RU-based accounts easier than ever. +We've expanded our service discovery capabilities by adding a dedicated provider for **Azure Cosmos DB for MongoDB (RU)** resources. This complements our existing DocumentDB provider and makes connecting to RU-based accounts easier than ever. - **New Discovery Option**: A new "Azure Cosmos DB for MongoDB (RU)" provider is now available in the Discovery View. - **Consistent User Experience**: The new provider uses the same authentication and wizard-based workflow (select subscription → select cluster → connect) that users are already familiar with. diff --git a/docs/release-notes/0.5.md b/docs/release-notes/0.5.md index d2ebd7eb8..9a980c4fa 100644 --- a/docs/release-notes/0.5.md +++ b/docs/release-notes/0.5.md @@ -12,7 +12,7 @@ We are excited to announce the release of **DocumentDB for VS Code Extension v0. ### ⭐ Enhanced Microsoft Entra ID Support for Multi-Account and Multi-Tenant Scenarios -Continuing our focus on enterprise-grade security from release 0.3.0, we have overhauled our Microsoft Entra ID integration for Azure Cosmos DB for MongoDB (vCore) to fully support multi-account and multi-tenant environments. This enables uninterrupted workflows for developers working across different organizations and directories. +Continuing our focus on enterprise-grade security from release 0.3.0, we have overhauled our Microsoft Entra ID integration for Azure DocumentDB to fully support multi-account and multi-tenant environments. This enables uninterrupted workflows for developers working across different organizations and directories. - **Multi-Account Management**: You can now sign in with multiple Azure accounts and easily switch between them without leaving VS Code. A new **Manage Credentials** feature allows you to view all authenticated accounts and add new ones on the fly. - **Multi-Tenant Filtering**: For users with access to multiple Azure tenants, a new filtering wizard lets you select exactly which tenants and subscriptions should be visible in the Service Discovery panel. Your selections are saved and persisted across sessions, ensuring a clean and relevant view of your resources. @@ -47,11 +47,16 @@ This feature was introduced in PR [#289](https://github.com/microsoft/vscode-doc - **Updating connection authentication from EntraID to UserName/Password fails ([#284](https://github.com/microsoft/vscode-documentdb/issues/284))** - Corrected a failure that occurred when updating a connection's authentication method from Entra ID to a username/password. The connection now updates and connects successfully. +## Changelog + +See the full changelog entry for this release: +➡️ [CHANGELOG.md#050](https://github.com/microsoft/vscode-documentdb/blob/main/CHANGELOG.md#050) + --- ## Patch Release v0.5.1 -This patch release addresses a critical issue with connection string parsing, ensuring more reliable connections for Azure Cosmos DB and other services. +This patch release addresses a critical issue with connection string parsing, ensuring more reliable connections for Azure DocumentDB and other services. ### What's Changed in v0.5.1 diff --git a/docs/release-notes/0.6.md b/docs/release-notes/0.6.md new file mode 100644 index 000000000..02756df84 --- /dev/null +++ b/docs/release-notes/0.6.md @@ -0,0 +1,71 @@ +> **Release Notes** — [Back to Release Notes](../index.md#release-notes) + +--- + +# DocumentDB for VS Code Extension v0.6 + +We are excited to announce the release of **DocumentDB for VS Code Extension v0.6**. This is a landmark update for our DocumentDB and MongoDB GUI, focused on query optimization and developer productivity. It introduces a powerful new **Query Insights with Performance Advisor**, enhances query authoring capabilities, and improves index management for developers working with DocumentDB and MongoDB API databases. + +## What's New in v0.6 + +### ⭐ Query Insights with Performance Advisor + +We are introducing a major new feature: **Query Insights with Performance Advisor**. This powerful tool helps you understand and optimize your queries directly within VS Code. When you run a `find` query against your DocumentDB or MongoDB API database, a new **"Query Insights"** tab appears, providing a three-stage analysis of your query's performance. + +

Authentication Method Selection

+ +- **Stage 1: Initial Performance View** + The first stage provides an immediate, low-cost static analysis of your query. It visualizes the query plan, showing how the database intends to execute your query. This helps you understand performance bottlenecks and the query's processing stages without re-running it. + +- **Stage 2: Detailed Execution Analysis** + For a deeper dive, the second stage runs a detailed execution analysis using `executionStats` to gather authoritative metrics. You'll see precise counts for documents and keys examined, server-side execution time, and a detailed breakdown of each stage in the execution plan. This provides clear insights into how your query actually performed. + + The complex JSON response from the database is translated into an easy-to-comprehend chart, making it simple to visualize the query's execution flow. + + Additionally, a **Query Efficiency Analysis** card provides a quick performance assessment. It highlights key aspects of the query's execution, such as the execution strategy, index usage, and whether an in-memory sort occurred. A performance rating (Good, Fair, or Poor) helps you quickly identify an inefficient or slow query. + +- **Stage 3: AI-Powered Recommendations with GitHub Copilot** + The final stage brings the power of AI to your query optimization workflow. By clicking `Get AI Performance Insights`, the extension sends the query shape and execution statistics to a service powered by **GitHub Copilot**. For more details, please see our [documentation](https://learn.microsoft.com/en-us/azure/documentdb/index-advisor). + + The AI assistant provides: + + > 🕵️‍♂️ **Analysis** + > + > A summary of the query's performance. + + > 📈 **Actionable Recommendations** + > + > Suggestions for creating, hiding, or unhiding indexes to improve performance, with an option to apply them directly. + + > 🎓 **Detailed Explanations** + > + > A breakdown of the execution plan to help you understand how the query was processed. + +The **"Query Insights"** feature helps solve performance issues and educates users on query best practices for DocumentDB and MongoDB API databases. + +### ⭐ Improved Query Specification + +We've enhanced the query authoring experience to support more sophisticated queries. Previously, you could only specify the `filter` for a `find` query. Now, you have full control to include `projection`, `sort`, `skip`, and `limit` parameters directly in the query editor, enabling more complex data retrieval without leaving VS Code. + +The `projection` and `sort` fields also support the same rich autocompletion that was previously available for the `filter` field. + +

Authentication Method Selection

+ +### ⭐ Index Management from the Tree View + +Managing your indexes is now easier and more intuitive than ever. You can now `drop`, `hide`, and `unhide` indexes directly from the Connections View. Simply expand a database and collection, then expand the "Indexes" node to see all indexes on that collection. + +Hovering over an index will show you its details, and the context menu provides the available management operations. This direct workflow helps you maintain your indexes efficiently right from the explorer. + +

Authentication Method Selection

+ +## Key Fixes and Improvements + +- **Improved UI element visibility** + - Fixed an issue where the autocomplete list in the query area could be hidden behind other UI elements. + - Corrected a problem where tooltips in the table and tree views were sometimes displayed underneath the selection indicator. + +## Changelog + +See the full changelog entry for this release: +➡️ [CHANGELOG.md#060](https://github.com/microsoft/vscode-documentdb/blob/main/CHANGELOG.md#060) diff --git a/docs/release-notes/images/0.6.0_index_management.png b/docs/release-notes/images/0.6.0_index_management.png new file mode 100644 index 000000000..ac85ae396 Binary files /dev/null and b/docs/release-notes/images/0.6.0_index_management.png differ diff --git a/docs/release-notes/images/0.6.0_project_sort_skip_limit.png b/docs/release-notes/images/0.6.0_project_sort_skip_limit.png new file mode 100644 index 000000000..f9c867ebf Binary files /dev/null and b/docs/release-notes/images/0.6.0_project_sort_skip_limit.png differ diff --git a/docs/release-notes/images/0.6.0_query_insights.png b/docs/release-notes/images/0.6.0_query_insights.png new file mode 100644 index 000000000..75b048afb Binary files /dev/null and b/docs/release-notes/images/0.6.0_query_insights.png differ diff --git a/docs/user-manual/managing-azure-discovery.md b/docs/user-manual/managing-azure-discovery.md index 06a2155c4..5a62d71c8 100644 --- a/docs/user-manual/managing-azure-discovery.md +++ b/docs/user-manual/managing-azure-discovery.md @@ -7,7 +7,7 @@ When using Azure-based service discovery providers in DocumentDB for VS Code, you have access to shared features for managing your Azure credentials and filtering which resources are displayed. These features are consistent across all Azure service discovery providers: - [Azure Cosmos DB for MongoDB (RU)](./service-discovery-azure-cosmosdb-for-mongodb-ru) -- [Azure Cosmos DB for MongoDB (vCore)](./service-discovery-azure-cosmosdb-for-mongodb-vcore) +- [Azure DocumentDB](./service-discovery-azure-cosmosdb-for-mongodb-vcore) - [Azure VMs (DocumentDB)](./service-discovery-azure-vms) For a general overview of service discovery, see the [Service Discovery](./service-discovery) documentation. @@ -151,5 +151,5 @@ When adding a new connection via the **"Add New Connection"** wizard: - [Service Discovery Overview](./service-discovery) - [Azure CosmosDB for MongoDB (RU) Service Discovery](./service-discovery-azure-cosmosdb-for-mongodb-ru) -- [Azure CosmosDB for MongoDB (vCore) Service Discovery](./service-discovery-azure-cosmosdb-for-mongodb-vcore) +- [Azure DocumentDB Service Discovery](./service-discovery-azure-cosmosdb-for-mongodb-vcore) - [Azure VMs (DocumentDB) Service Discovery](./service-discovery-azure-vms) diff --git a/docs/user-manual/service-discovery-azure-cosmosdb-for-mongodb-vcore.md b/docs/user-manual/service-discovery-azure-cosmosdb-for-mongodb-vcore.md index 8211faaad..c1933e368 100644 --- a/docs/user-manual/service-discovery-azure-cosmosdb-for-mongodb-vcore.md +++ b/docs/user-manual/service-discovery-azure-cosmosdb-for-mongodb-vcore.md @@ -2,9 +2,9 @@ --- -# Azure CosmosDB for MongoDB (vCore) Service Discovery Plugin +# Azure DocumentDB Service Discovery Plugin -The **Azure CosmosDB for MongoDB (vCore)** plugin is available as part of the [Service Discovery](./service-discovery) feature in DocumentDB for VS Code. This plugin helps you connect to your Azure CosmosDB for MongoDB (vCore) clusters by handling authentication, resource discovery, and connection management within the extension. +The **Azure DocumentDB** plugin is available as part of the [Service Discovery](./service-discovery) feature in DocumentDB for VS Code. This plugin helps you connect to your Azure DocumentDB clusters by handling authentication, resource discovery, and connection management within the extension. > **📘 Managing Azure Resources**: This provider shares common Azure management features with other Azure-based providers. See [Managing Azure Discovery (Accounts, Tenants, and Subscriptions)](./managing-azure-discovery) for detailed information about: > @@ -17,13 +17,13 @@ The **Azure CosmosDB for MongoDB (vCore)** plugin is available as part of the [S You can access this plugin in two ways: - Through the `Service Discovery` panel in the extension sidebar. -- When adding a new connection, select the `Azure CosmosDB for MongoDB (vCore)` option. +- When adding a new connection, select the `Azure DocumentDB` option. ![Service Discovery Activation](./images/service-discovery-activation.png) ## How It Works -When you use the Azure CosmosDB for MongoDB (vCore) plugin, the following steps are performed: +When you use the Azure DocumentDB plugin, the following steps are performed: 1. **Authentication:** The plugin authenticates you with Azure using your credentials. See [Managing Azure Accounts](./managing-azure-discovery#managing-azure-accounts) for details on managing your Azure accounts. @@ -32,11 +32,11 @@ When you use the Azure CosmosDB for MongoDB (vCore) plugin, the following steps When accessing this plugin from the Service Discovery panel, you can control which resources are displayed by filtering tenants and subscriptions. Click the funnel icon next to the provider name to configure filters. See [Filtering Azure Resources](./managing-azure-discovery#filtering-azure-resources) for more information. 3. **Subscription and Cluster Discovery:** - - **From Service Discovery Panel**: The plugin lists subscriptions based on your configured filters, allowing you to browse vCore clusters within selected subscriptions. + - **From Service Discovery Panel**: The plugin lists subscriptions based on your configured filters, allowing you to browse DocumentDB clusters within selected subscriptions. - **From Add New Connection Wizard**: All subscriptions from all tenants are shown without pre-filtering. You select one subscription to view its resources. 4. **Cluster Discovery:** - The plugin enumerates all Azure CosmosDB for MongoDB (vCore) clusters available in your selected subscriptions. + The plugin enumerates all Azure DocumentDB clusters available in your selected subscriptions. 5. **Connection Options:** - You can connect to a cluster by expanding its entry in the tree view. diff --git a/docs/user-manual/service-discovery.md b/docs/user-manual/service-discovery.md index 45fef396f..a50f3a3ef 100644 --- a/docs/user-manual/service-discovery.md +++ b/docs/user-manual/service-discovery.md @@ -24,7 +24,7 @@ This approach allows you to connect to a variety of platforms without needing to Currently, the following service discovery plugins are available: - **[Azure CosmosDB for MongoDB (RU)](./service-discovery-azure-cosmosdb-for-mongodb-ru)** -- **[Azure CosmosDB for MongoDB (vCore)](./service-discovery-azure-cosmosdb-for-mongodb-vcore)** +- **[Azure DocumentDB](./service-discovery-azure-cosmosdb-for-mongodb-vcore)** - **[Azure VMs (DocumentDB)](./service-discovery-azure-vms)** We are actively working to integrate more platforms and welcome contributions from the community. diff --git a/l10n/bundle.l10n.json b/l10n/bundle.l10n.json index acad8057c..73a612d08 100644 --- a/l10n/bundle.l10n.json +++ b/l10n/bundle.l10n.json @@ -1,11 +1,57 @@ { " (Press 'Space' to select and 'Enter' to confirm)": " (Press 'Space' to select and 'Enter' to confirm)", + " on GitHub.": " on GitHub.", + " or ": " or ", ", No public IP or FQDN found.": ", No public IP or FQDN found.", "\"{0}\" is not implemented on \"{1}\".": "\"{0}\" is not implemented on \"{1}\".", "\"mongodb://\" or \"mongodb+srv://\" must be the prefix of the connection string.": "\"mongodb://\" or \"mongodb+srv://\" must be the prefix of the connection string.", "\"registerAzureUtilsExtensionVariables\" must be called before using the vscode-azext-azureutils package.": "\"registerAzureUtilsExtensionVariables\" must be called before using the vscode-azext-azureutils package.", "\"registerUIExtensionVariables\" must be called before using the vscode-azureextensionui package.": "\"registerUIExtensionVariables\" must be called before using the vscode-azureextensionui package.", "(recently used)": "(recently used)", + "[Query Generation] Calling Copilot (model: {model})...": "[Query Generation] Calling Copilot (model: {model})...", + "[Query Generation] Completed successfully": "[Query Generation] Completed successfully", + "[Query Generation] Copilot response received in {ms}ms (model: {model})": "[Query Generation] Copilot response received in {ms}ms (model: {model})", + "[Query Generation] listCollections completed in {ms}ms ({count} collections)": "[Query Generation] listCollections completed in {ms}ms ({count} collections)", + "[Query Generation] Schema sampling completed in {ms}ms": "[Query Generation] Schema sampling completed in {ms}ms", + "[Query Generation] Schema sampling for {collection} completed in {ms}ms": "[Query Generation] Schema sampling for {collection} completed in {ms}ms", + "[Query Generation] Started: type={type}, targetQueryType={targetQueryType}": "[Query Generation] Started: type={type}, targetQueryType={targetQueryType}", + "[Query Insights Action] Create index action completed successfully": "[Query Insights Action] Create index action completed successfully", + "[Query Insights Action] Create index action error: {error}": "[Query Insights Action] Create index action error: {error}", + "[Query Insights Action] Create index action failed: {error}": "[Query Insights Action] Create index action failed: {error}", + "[Query Insights Action] Drop index action completed successfully": "[Query Insights Action] Drop index action completed successfully", + "[Query Insights Action] Drop index action error: {error}": "[Query Insights Action] Drop index action error: {error}", + "[Query Insights Action] Drop index action failed: {error}": "[Query Insights Action] Drop index action failed: {error}", + "[Query Insights Action] Executing {operation} action for \"{indexName}\" on collection: {collection}": "[Query Insights Action] Executing {operation} action for \"{indexName}\" on collection: {collection}", + "[Query Insights Action] Executing createIndex action for collection: {collection}": "[Query Insights Action] Executing createIndex action for collection: {collection}", + "[Query Insights Action] Executing dropIndex action for \"{indexName}\" on collection: {collection}": "[Query Insights Action] Executing dropIndex action for \"{indexName}\" on collection: {collection}", + "[Query Insights Action] Invalid mongoShell command format: {command}": "[Query Insights Action] Invalid mongoShell command format: {command}", + "[Query Insights Action] Invalid payload for create index action": "[Query Insights Action] Invalid payload for create index action", + "[Query Insights Action] Invalid payload for drop index action": "[Query Insights Action] Invalid payload for drop index action", + "[Query Insights Action] Invalid payload for modify index action": "[Query Insights Action] Invalid payload for modify index action", + "[Query Insights Action] Modify index action completed successfully": "[Query Insights Action] Modify index action completed successfully", + "[Query Insights Action] Modify index action error: {error}": "[Query Insights Action] Modify index action error: {error}", + "[Query Insights Action] Modify index action failed: {error}": "[Query Insights Action] Modify index action failed: {error}", + "[Query Insights Action] Session ID is required": "[Query Insights Action] Session ID is required", + "[Query Insights AI] Calling Copilot (model: {model})...": "[Query Insights AI] Calling Copilot (model: {model})...", + "[Query Insights AI] Copilot response received in {ms}ms (model: {model})": "[Query Insights AI] Copilot response received in {ms}ms (model: {model})", + "[Query Insights AI] explain({commandType}) completed in {ms}ms": "[Query Insights AI] explain({commandType}) completed in {ms}ms", + "[Query Insights AI] Failed to retrieve collection/index stats (non-critical): {message}": "[Query Insights AI] Failed to retrieve collection/index stats (non-critical): {message}", + "[Query Insights AI] getCollectionStats completed in {ms}ms": "[Query Insights AI] getCollectionStats completed in {ms}ms", + "[Query Insights AI] getIndexStats completed in {ms}ms": "[Query Insights AI] getIndexStats completed in {ms}ms", + "[Query Insights AI] listIndexes completed in {ms}ms": "[Query Insights AI] listIndexes completed in {ms}ms", + "[Query Insights Stage 1] Completed: indexes={idx}, collScan={scan}": "[Query Insights Stage 1] Completed: indexes={idx}, collScan={scan}", + "[Query Insights Stage 1] explain(queryPlanner) completed in {ms}ms": "[Query Insights Stage 1] explain(queryPlanner) completed in {ms}ms", + "[Query Insights Stage 1] Query Insights is not supported on Azure Cosmos DB for MongoDB (RU) clusters.": "[Query Insights Stage 1] Query Insights is not supported on Azure Cosmos DB for MongoDB (RU) clusters.", + "[Query Insights Stage 1] Started for {db}.{collection}": "[Query Insights Stage 1] Started for {db}.{collection}", + "[Query Insights Stage 1] Using debug data file": "[Query Insights Stage 1] Using debug data file", + "[Query Insights Stage 2] Completed: execTime={time}ms, returned={ret}, examined={ex}, ratio={ratio}": "[Query Insights Stage 2] Completed: execTime={time}ms, returned={ret}, examined={ex}, ratio={ratio}", + "[Query Insights Stage 2] explain(executionStats) completed in {ms}ms": "[Query Insights Stage 2] explain(executionStats) completed in {ms}ms", + "[Query Insights Stage 2] Query execution failed: {error}": "[Query Insights Stage 2] Query execution failed: {error}", + "[Query Insights Stage 2] Started for {db}.{collection}": "[Query Insights Stage 2] Started for {db}.{collection}", + "[Query Insights Stage 2] Using debug data file": "[Query Insights Stage 2] Using debug data file", + "[Query Insights Stage 3] AI service completed in {ms}ms (requestKey: {key})": "[Query Insights Stage 3] AI service completed in {ms}ms (requestKey: {key})", + "[Query Insights Stage 3] Completed: {count} improvement cards generated (requestKey: {key})": "[Query Insights Stage 3] Completed: {count} improvement cards generated (requestKey: {key})", + "[Query Insights Stage 3] Started for {db}.{collection} (requestKey: {key})": "[Query Insights Stage 3] Started for {db}.{collection} (requestKey: {key})", "{0} is currently being used for Azure service discovery": "{0} is currently being used for Azure service discovery", "{countMany} documents have been deleted.": "{countMany} documents have been deleted.", "{countOne} document has been deleted.": "{countOne} document has been deleted.", @@ -38,18 +84,28 @@ "A value is required to proceed.": "A value is required to proceed.", "Account information is incomplete.": "Account information is incomplete.", "Account Management Completed": "Account Management Completed", + "Action completed successfully": "Action completed successfully", + "Action failed": "Action failed", "Add new document": "Add new document", + "Additional write and storage overhead for maintaining a new index.": "Additional write and storage overhead for maintaining a new index.", "Advanced": "Advanced", + "AI is analyzing...": "AI is analyzing...", + "AI Performance Insights": "AI Performance Insights", + "AI recommendations": "AI recommendations", + "AI responses may be inaccurate": "AI responses may be inaccurate", "All available providers have been added already.": "All available providers have been added already.", "Always upload": "Always upload", "An element with the following id already exists: {id}": "An element with the following id already exists: {id}", "An error has occurred. Check output window for more details.": "An error has occurred. Check output window for more details.", + "An index on {0} would allow direct lookup of matching documents and eliminate full collection scans.": "An index on {0} would allow direct lookup of matching documents and eliminate full collection scans.", "An item with id \"{0}\" already exists for workspace \"{1}\".": "An item with id \"{0}\" already exists for workspace \"{1}\".", + "An unexpected error occurred": "An unexpected error occurred", "API v0.3.0: Registered new migration provider: \"{providerId}\" - \"{providerLabel}\" from extension \"{extensionId}\"": "API v0.3.0: Registered new migration provider: \"{providerId}\" - \"{providerLabel}\" from extension \"{extensionId}\"", "API version \"{0}\" for extension id \"{1}\" is no longer supported. Minimum version is \"{2}\".": "API version \"{0}\" for extension id \"{1}\" is no longer supported. Minimum version is \"{2}\".", "API: Registered new migration provider: \"{providerId}\" - \"{providerLabel}\"": "API: Registered new migration provider: \"{providerId}\" - \"{providerLabel}\"", "Applying Azure discovery filters…": "Applying Azure discovery filters…", "Are you sure?": "Are you sure?", + "Ask Copilot to generate the query for you": "Ask Copilot to generate the query for you", "Attempting to authenticate with \"{cluster}\"…": "Attempting to authenticate with \"{cluster}\"…", "Authenticate to connect with your DocumentDB cluster": "Authenticate to connect with your DocumentDB cluster", "Authenticate to Connect with Your DocumentDB Cluster": "Authenticate to Connect with Your DocumentDB Cluster", @@ -68,9 +124,10 @@ "Azure Activity": "Azure Activity", "Azure Cosmos DB for MongoDB (RU)": "Azure Cosmos DB for MongoDB (RU)", "Azure Cosmos DB for MongoDB (RU) Emulator": "Azure Cosmos DB for MongoDB (RU) Emulator", - "Azure Cosmos DB for MongoDB (vCore)": "Azure Cosmos DB for MongoDB (vCore)", "Azure discovery filters applied successfully.": "Azure discovery filters applied successfully.", + "Azure DocumentDB": "Azure DocumentDB", "Azure Service Discovery": "Azure Service Discovery", + "Azure Service Discovery for Azure DocumentDB": "Azure Service Discovery for Azure DocumentDB", "Azure Service Discovery for MongoDB RU": "Azure Service Discovery for MongoDB RU", "Azure sign-in completed successfully": "Azure sign-in completed successfully", "Azure sign-in failed: {0}": "Azure sign-in failed: {0}", @@ -95,15 +152,18 @@ "Choose the migration action…": "Choose the migration action…", "Choose your provider…": "Choose your provider…", "Choose your Service Provider": "Choose your Service Provider", + "Clear Query": "Clear Query", "Click here to retry": "Click here to retry", "Click here to update credentials": "Click here to update credentials", "Click to view resource": "Click to view resource", "Close the account management wizard": "Close the account management wizard", + "Cluster metadata not initialized. Client may not be properly connected.": "Cluster metadata not initialized. Client may not be properly connected.", "Cluster support unknown $(info)": "Cluster support unknown $(info)", "Collection name cannot begin with the system. prefix (Reserved for internal use).": "Collection name cannot begin with the system. prefix (Reserved for internal use).", "Collection name cannot contain .system.": "Collection name cannot contain .system.", "Collection name cannot contain the $.": "Collection name cannot contain the $.", "Collection name cannot contain the null character.": "Collection name cannot contain the null character.", + "Collection name is required for single-collection query generation": "Collection name is required for single-collection query generation", "Collection name is required.": "Collection name is required.", "Collection names should begin with an underscore or a letter character.": "Collection names should begin with an underscore or a letter character.", "Configure Azure Discovery Filters": "Configure Azure Discovery Filters", @@ -130,17 +190,25 @@ "Create an Azure for Students Account...": "Create an Azure for Students Account...", "Create collection": "Create collection", "Create Collection…": "Create Collection…", + "Create compound indexes for queries that filter on multiple fields. Order matters: place equality filters first, then sort fields, then range filters.": "Create compound indexes for queries that filter on multiple fields. Order matters: place equality filters first, then sort fields, then range filters.", "Create database": "Create database", "Create Database…": "Create Database…", "Create Free Azure DocumentDB Cluster": "Create Free Azure DocumentDB Cluster", + "Create index \"{indexName}\" on collection \"{collectionName}\"?": "Create index \"{indexName}\" on collection \"{collectionName}\"?", + "Create index on collection \"{collectionName}\"?": "Create index on collection \"{collectionName}\"?", + "Create index?": "Create index?", + "Create Index…": "Create Index…", "Create new {0}...": "Create new {0}...", "Creating \"{nodeName}\"…": "Creating \"{nodeName}\"…", "Creating {0}...": "Creating {0}...", + "Creating index \"{indexName}\" on collection: {collection}": "Creating index \"{indexName}\" on collection: {collection}", "Creating new connection…": "Creating new connection…", "Creating resource group \"{0}\" in location \"{1}\"...": "Creating resource group \"{0}\" in location \"{1}\"...", "Creating storage account \"{0}\" in location \"{1}\" with sku \"{2}\"...": "Creating storage account \"{0}\" in location \"{1}\" with sku \"{2}\"...", "Creating user assigned identity \"{0}\" in location \"{1}\"\"...": "Creating user assigned identity \"{0}\" in location \"{1}\"\"...", "Credentials updated successfully.": "Credentials updated successfully.", + "Data shown was correct": "Data shown was correct", + "Data shown was incorrect": "Data shown was incorrect", "Database name cannot be longer than 64 characters.": "Database name cannot be longer than 64 characters.", "Database name cannot contain any of the following characters: \"{0}{1}\"": "Database name cannot contain any of the following characters: \"{0}{1}\"", "Database name is required when collection is specified": "Database name is required when collection is specified", @@ -152,8 +220,12 @@ "Delete {count} documents?": "Delete {count} documents?", "Delete collection \"{collectionId}\" and its contents?": "Delete collection \"{collectionId}\" and its contents?", "Delete database \"{databaseId}\" and its contents?": "Delete database \"{databaseId}\" and its contents?", + "Delete index \"{indexName}\" from collection \"{collectionName}\"?": "Delete index \"{indexName}\" from collection \"{collectionName}\"?", + "Delete index from collection \"{collectionName}\"?": "Delete index from collection \"{collectionName}\"?", + "Delete index?": "Delete index?", "Delete selected document(s)": "Delete selected document(s)", "Deleting...": "Deleting...", + "detailed execution analysis": "detailed execution analysis", "Disable TLS/SSL (Not recommended)": "Disable TLS/SSL (Not recommended)", "Disable TLS/SSL checks when connecting.": "Disable TLS/SSL checks when connecting.", "Do not rely on case to distinguish between databases. For example, you cannot use two databases with names like, salesData and SalesData.": "Do not rely on case to distinguish between databases. For example, you cannot use two databases with names like, salesData and SalesData.", @@ -164,17 +236,23 @@ "DocumentDB Documentation": "DocumentDB Documentation", "DocumentDB for VS Code is not signed in to Azure": "DocumentDB for VS Code is not signed in to Azure", "DocumentDB Local": "DocumentDB Local", + "DocumentDB Performance Tips": "DocumentDB Performance Tips", "Documents": "Documents", + "Documents Examined": "Documents Examined", + "Documents Returned": "Documents Returned", "Does this occur consistently? ": "Does this occur consistently? ", "Don't Ask Again": "Don't Ask Again", "Don't upload": "Don't upload", "Don't warn again": "Don't warn again", + "Drop Index…": "Drop Index…", + "Dropping index \"{indexName}\" from collection: {collection}": "Dropping index \"{indexName}\" from collection: {collection}", "e.g., 12345678-1234-1234-1234-123456789012 or 12345678123412341234123456789012": "e.g., 12345678-1234-1234-1234-123456789012 or 12345678123412341234123456789012", "e.g., DocumentDB, Environment, Project": "e.g., DocumentDB, Environment, Project", "Edit selected document": "Edit selected document", "Element with id of {rootId} not found.": "Element with id of {rootId} not found.", "Enable TLS/SSL (Default)": "Enable TLS/SSL (Default)", "Enforce TLS/SSL checks for a secure connection.": "Enforce TLS/SSL checks for a secure connection.", + "Enhanced Query Configuration\n(Projection, Sort, Skip, Limit)": "Enhanced Query Configuration\n(Projection, Sort, Skip, Limit)", "Enter a collection name.": "Enter a collection name.", "Enter a database name.": "Enter a database name.", "Enter the Azure VM tag key used for discovering DocumentDB instances.": "Enter the Azure VM tag key used for discovering DocumentDB instances.", @@ -188,10 +266,15 @@ "Enter the tenant ID (GUID)": "Enter the tenant ID (GUID)", "Enter the username": "Enter the username", "Enter the username for {experience}": "Enter the username for {experience}", - "Entra ID for Azure Cosmos DB for MongoDB (vCore)": "Entra ID for Azure Cosmos DB for MongoDB (vCore)", + "Entra ID for Azure DocumentDB": "Entra ID for Azure DocumentDB", + "Error": "Error", + "Error creating index: {error}": "Error creating index: {error}", "Error creating resource: {0}": "Error creating resource: {0}", "Error deleting selected documents": "Error deleting selected documents", + "Error dropping index: {error}": "Error dropping index: {error}", "Error exporting documents: {error}": "Error exporting documents: {error}", + "Error generating query": "Error generating query", + "Error modifying index: {error}": "Error modifying index: {error}", "Error opening the document view": "Error opening the document view", "Error running process: ": "Error running process: ", "Error saving the document": "Error saving the document", @@ -203,58 +286,105 @@ "Error: {0}": "Error: {0}", "Error: {error}": "Error: {error}", "Errors found in document {path}. Please fix these.": "Errors found in document {path}. Please fix these.", + "Examined-to-Returned Ratio": "Examined-to-Returned Ratio", + "Excellent": "Excellent", "Execute the find query": "Execute the find query", "Executing all commands in shell…": "Executing all commands in shell…", + "Executing explain(aggregate) for collection: {collection}, pipeline stages: {stageCount}": "Executing explain(aggregate) for collection: {collection}, pipeline stages: {stageCount}", + "Executing explain(count) for collection: {collection}": "Executing explain(count) for collection: {collection}", + "Executing explain(find) for collection: {collection}": "Executing explain(find) for collection: {collection}", "Executing the command in shell…": "Executing the command in shell…", + "Execution Strategy": "Execution Strategy", + "Execution Time": "Execution Time", "Execution timed out": "Execution timed out", "Execution timed out.": "Execution timed out.", "Exit": "Exit", "Exit without making changes": "Exit without making changes", "Expected a file name \"{0}\", but the selected filename is \"{1}\"": "Expected a file name \"{0}\", but the selected filename is \"{1}\"", "Expecting parentheses or quotes at \"{text}\"": "Expecting parentheses or quotes at \"{text}\"", + "Explain(aggregate) completed [{durationMs}ms]": "Explain(aggregate) completed [{durationMs}ms]", + "Explain(count) completed [{durationMs}ms]": "Explain(count) completed [{durationMs}ms]", + "Explain(find) completed [{durationMs}ms]": "Explain(find) completed [{durationMs}ms]", "Export": "Export", "Export Current Query Results…": "Export Current Query Results…", "Export Entire Collection…": "Export Entire Collection…", + "Export Execution Plan Details": "Export Execution Plan Details", + "Export Optimization Opportunities": "Export Optimization Opportunities", "Exported document count: {documentCount}": "Exported document count: {documentCount}", "Exporting data to: {filePath}": "Exporting data to: {filePath}", "Exporting documents": "Exporting documents", "Exporting…": "Exporting…", "Extension dependency with id \"{0}\" must be updated.": "Extension dependency with id \"{0}\" must be updated.", "Extension Documentation": "Extension Documentation", + "Failed to {action} index \"{indexName}\": {error} [{durationMs}ms]": "Failed to {action} index \"{indexName}\": {error} [{durationMs}ms]", "Failed to access Azure Databases VS Code Extension storage for migration: {error}": "Failed to access Azure Databases VS Code Extension storage for migration: {error}", "Failed to connect to \"{cluster}\"": "Failed to connect to \"{cluster}\"", "Failed to connect to VM \"{vmName}\"": "Failed to connect to VM \"{vmName}\"", + "Failed to convert query parameters: {error}": "Failed to convert query parameters: {error}", "Failed to create Azure management clients: {0}": "Failed to create Azure management clients: {0}", + "Failed to create index: {error}": "Failed to create index: {error}", "Failed to create role assignment \"{0}\" for the {2} resource \"{1}\".": "Failed to create role assignment \"{0}\" for the {2} resource \"{1}\".", "Failed to create role assignment(s).": "Failed to create role assignment(s).", "Failed to delete documents. Unknown error.": "Failed to delete documents. Unknown error.", "Failed to delete item \"{0}\".": "Failed to delete item \"{0}\".", "Failed to delete secrets for item \"{0}\".": "Failed to delete secrets for item \"{0}\".", + "Failed to drop index: {error}": "Failed to drop index: {error}", + "Failed to drop index.": "Failed to drop index.", "Failed to export documents. Please see the output for details.": "Failed to export documents. Please see the output for details.", "Failed to extract cluster credentials from the selected node.": "Failed to extract cluster credentials from the selected node.", "Failed to extract the connection string from the selected account.": "Failed to extract the connection string from the selected account.", "Failed to find commandId on generic tree item.": "Failed to find commandId on generic tree item.", + "Failed to gather query optimization data: {message}": "Failed to gather query optimization data: {message}", + "Failed to gather schema information: {message}": "Failed to gather schema information: {message}", + "Failed to get optimization recommendations from index advisor.": "Failed to get optimization recommendations from index advisor.", "Failed to get public IP": "Failed to get public IP", + "Failed to hide index.": "Failed to hide index.", "Failed to initialize Azure management clients": "Failed to initialize Azure management clients", + "Failed to load {0}": "Failed to load {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 template file for {type}: {error}": "Failed to load template file for {type}: {error}", + "Failed to modify index: {error}": "Failed to modify index: {error}", "Failed to obtain Entra ID token.": "Failed to obtain Entra ID token.", + "Failed to open raw execution stats": "Failed to open raw execution stats", + "Failed to parse AI optimization response. {error}": "Failed to parse AI optimization response. {error}", + "Failed to parse generated query. Query generation provided an invalid response.": "Failed to parse generated query. Query generation provided an invalid response.", + "Failed to parse query string: {message}": "Failed to parse query string: {message}", "Failed to parse secrets for key {0}:": "Failed to parse secrets for key {0}:", + "Failed to parse the response from the language model. LLM output:\n{output}": "Failed to parse the response from the language model. LLM output:\n{output}", "Failed to process URI: {0}": "Failed to process URI: {0}", "Failed to rename the connection.": "Failed to rename the connection.", "Failed to retrieve Azure accounts: {0}": "Failed to retrieve Azure accounts: {0}", "Failed to save credentials for \"{cluster}\".": "Failed to save credentials for \"{cluster}\".", "Failed to save credentials.": "Failed to save credentials.", "Failed to store secrets for key {0}:": "Failed to store secrets for key {0}:", + "Failed to unhide index.": "Failed to unhide index.", "Failed to update the connection.": "Failed to update the connection.", "Failed with code \"{0}\".": "Failed with code \"{0}\".", + "Fair": "Fair", "Find Query": "Find Query", "Finished importing": "Finished importing", + "Generate": "Generate", + "Generate query with AI": "Generate query with AI", + "Get AI Performance Insights": "Get AI Performance Insights", + "Get personalized recommendations to optimize your query performance. AI will analyze your cluster configuration, index usage, execution plan, and more to suggest specific improvements.": "Get personalized recommendations to optimize your query performance. AI will analyze your cluster configuration, index usage, execution plan, and more to suggest specific improvements.", + "GitHub Copilot is not available. Please install the GitHub Copilot extension and ensure you have an active subscription.": "GitHub Copilot is not available. Please install the GitHub Copilot extension and ensure you have an active subscription.", "Go back.": "Go back.", "Go to first page": "Go to first page", "Go to next page": "Go to next page", "Go to previous page": "Go to previous page", "Go to start": "Go to start", + "Good": "Good", "Got a moment? Share your feedback on DocumentDB for VS Code!": "Got a moment? Share your feedback on DocumentDB for VS Code!", + "Helped me understand the query execution": "Helped me understand the query execution", + "hide": "hide", + "Hide index \"{indexName}\" from collection \"{collectionName}\"?": "Hide index \"{indexName}\" from collection \"{collectionName}\"?", + "Hide index?": "Hide index?", + "Hide Index…": "Hide Index…", + "Hiding index…": "Hiding index…", + "HIGH PRIORITY": "HIGH PRIORITY", "How do you want to connect?": "How do you want to connect?", + "How would you rate Query Insights?": "How would you rate Query Insights?", + "I like it": "I like it", "I want to choose the server from an online registry.": "I want to choose the server from an online registry.", "I want to connect to a local DocumentDB instance.": "I want to connect to a local DocumentDB instance.", "I want to connect to the Azure Cosmos DB Emulator for MongoDB (RU).": "I want to connect to the Azure Cosmos DB Emulator for MongoDB (RU).", @@ -269,8 +399,34 @@ "Importing document {num} of {countDocuments}": "Importing document {num} of {countDocuments}", "Importing documents…": "Importing documents…", "Importing…": "Importing…", + "Improved my query performance": "Improved my query performance", + "In-Memory Sort": "In-Memory Sort", + "Index \"{indexName}\" {action} successfully [{durationMs}ms]": "Index \"{indexName}\" {action} successfully [{durationMs}ms]", + "Index \"{indexName}\" {operation} successfully": "Index \"{indexName}\" {operation} successfully", + "Index \"{indexName}\" created successfully": "Index \"{indexName}\" created successfully", + "Index \"{indexName}\" created successfully [{durationMs}ms]": "Index \"{indexName}\" created successfully [{durationMs}ms]", + "Index \"{indexName}\" dropped successfully": "Index \"{indexName}\" dropped successfully", + "Index \"{indexName}\" dropped successfully [{durationMs}ms]": "Index \"{indexName}\" dropped successfully [{durationMs}ms]", + "Index \"{indexName}\" has been deleted.": "Index \"{indexName}\" has been deleted.", + "Index \"{indexName}\" has been hidden.": "Index \"{indexName}\" has been hidden.", + "Index \"{indexName}\" has been unhidden.": "Index \"{indexName}\" has been unhidden.", + "Index \"{indexName}\" is already hidden.": "Index \"{indexName}\" is already hidden.", + "Index \"{indexName}\" is not hidden.": "Index \"{indexName}\" is not hidden.", + "Index creation cancelled": "Index creation cancelled", + "Index creation completed with warning: {note}": "Index creation completed with warning: {note}", + "Index creation failed for \"{indexName}\": {error} [{durationMs}ms]": "Index creation failed for \"{indexName}\": {error} [{durationMs}ms]", + "Index deletion cancelled": "Index deletion cancelled", + "Index drop completed with warning": "Index drop completed with warning", + "Index drop failed for \"{indexName}\": {error} [{durationMs}ms]": "Index drop failed for \"{indexName}\": {error} [{durationMs}ms]", + "Index modification cancelled": "Index modification cancelled", + "Index Name": "Index Name", + "Index optimization is using model \"{actualModel}\" instead of preferred \"{preferredModel}\". Recommendations may be less optimal.": "Index optimization is using model \"{actualModel}\" instead of preferred \"{preferredModel}\". Recommendations may be less optimal.", + "Index Options": "Index Options", + "Index Used": "Index Used", + "Index visibility modification completed with warning": "Index visibility modification completed with warning", "Indexes": "Indexes", "Info from the webview: ": "Info from the webview: ", + "Information was confusing": "Information was confusing", "Inserted {0} document(s). See output for more details.": "Inserted {0} document(s). See output for more details.", "Install Azure Account Extension...": "Install Azure Account Extension...", "Internal error: connectionString must be defined.": "Internal error: connectionString must be defined.", @@ -285,10 +441,19 @@ "Invalid Connection String: {error}": "Invalid Connection String: {error}", "Invalid connection type selected.": "Invalid connection type selected.", "Invalid document ID: {0}": "Invalid document ID: {0}", + "Invalid mongoShell command format": "Invalid mongoShell command format", + "Invalid payload for create index action": "Invalid payload for create index action", + "Invalid payload for drop index action": "Invalid payload for drop index action", + "Invalid payload for modify index action": "Invalid payload for modify index action", + "Invalid projection syntax: {0}": "Invalid projection syntax: {0}", "Invalid semver \"{0}\".": "Invalid semver \"{0}\".", + "Invalid sort syntax: {0}": "Invalid sort syntax: {0}", + "It could be better": "It could be better", "JSON View": "JSON View", + "Keys Examined": "Keys Examined", "Learn more": "Learn more", "Learn more about {0}.": "Learn more about {0}.", + "Learn more about AI Performance Insights": "Learn more about AI Performance Insights", "Learn more about DocumentDB and MongoDB migrations.": "Learn more about DocumentDB and MongoDB migrations.", "Learn more about enabling TLS/SSL.": "Learn more about enabling TLS/SSL.", "Learn more about integrating your cloud provider.": "Learn more about integrating your cloud provider.", @@ -297,6 +462,8 @@ "Learn more…": "Learn more…", "Length must be greater than 1": "Length must be greater than 1", "Level up": "Level up", + "Limit": "Limit", + "Limit Returned Fields": "Limit Returned Fields", "Load More...": "Load More...", "Loading \"{0}\"...": "Loading \"{0}\"...", "Loading Azure Accounts Used for Service Discovery…": "Loading Azure Accounts Used for Service Discovery…", @@ -313,12 +480,20 @@ "Loading Virtual Machines…": "Loading Virtual Machines…", "Loading...": "Loading...", "Location": "Location", + "LOW PRIORITY": "LOW PRIORITY", "Manage Azure Accounts": "Manage Azure Accounts", "Manually enter a custom tenant ID": "Manually enter a custom tenant ID", + "MEDIUM PRIORITY": "MEDIUM PRIORITY", "Migration of connections from the Azure Databases VS Code Extension to the DocumentDB for VS Code Extension completed: {migratedCount} connections migrated.": "Migration of connections from the Azure Databases VS Code Extension to the DocumentDB for VS Code Extension completed: {migratedCount} connections migrated.", + "Missing important information": "Missing important information", + "Modify index?": "Modify index?", + "Modify Index…": "Modify Index…", + "Modifying index visibility ({action}) for \"{indexName}\" on collection: {collection}": "Modifying index visibility ({action}) for \"{indexName}\" on collection: {collection}", "Mongo Shell connected.": "Mongo Shell connected.", "Mongo Shell Error: {error}": "Mongo Shell Error: {error}", "MongoDB Emulator": "MongoDB Emulator", + "Monitor Index Usage": "Monitor Index Usage", + "N/A": "N/A", "New Connection": "New Connection", "New connection has been added to your DocumentDB Connections.": "New connection has been added to your DocumentDB Connections.", "New connection has been added.": "New connection has been added.", @@ -326,6 +501,7 @@ "New Local Connection": "New Local Connection", "New Local Connection…": "New Local Connection…", "No": "No", + "No Action": "No Action", "No authentication method selected.": "No authentication method selected.", "No authentication methods available for \"{cluster}\".": "No authentication methods available for \"{cluster}\".", "No Azure subscription found for this tree item.": "No Azure subscription found for this tree item.", @@ -336,6 +512,8 @@ "No Connectivity": "No Connectivity", "No credentials found for id {credentialId}": "No credentials found for id {credentialId}", "No credentials found for the selected cluster.": "No credentials found for the selected cluster.", + "No index changes needed at this time.": "No index changes needed at this time.", + "No index selected.": "No index selected.", "No matching resources found.": "No matching resources found.", "No node selected.": "No node selected.", "No properties found in the schema at path \"{0}\"": "No properties found in the schema at path \"{0}\"", @@ -345,17 +523,26 @@ "No session found for id {sessionId}": "No session found for id {sessionId}", "No subscriptions found": "No subscriptions found", "No subscriptions found for the selected tenants. Please adjust your tenant selection or check your Azure permissions.": "No subscriptions found for the selected tenants. Please adjust your tenant selection or check your Azure permissions.", + "No suitable language model found. Please ensure GitHub Copilot is installed and you have an active subscription.": "No suitable language model found. Please ensure GitHub Copilot is installed and you have an active subscription.", "No tenants found. Please try signing in again or check your Azure permissions.": "No tenants found. Please try signing in again or check your Azure permissions.", "No tenants selected. Azure discovery will be filtered to exclude all tenant results.": "No tenants selected. Azure discovery will be filtered to exclude all tenant results.", + "None": "None", "Not connected to any MongoDB database.": "Not connected to any MongoDB database.", "Note: This confirmation type can be configured in the extension settings.": "Note: This confirmation type can be configured in the extension settings.", "Note: You can disable these URL handling confirmations in the extension settings.": "Note: You can disable these URL handling confirmations in the extension settings.", + "Number of documents returned by the query": "Number of documents returned by the query", + "Number of documents scanned to find matching results. Should be close to documents returned for optimal performance.": "Number of documents scanned to find matching results. Should be close to documents returned for optimal performance.", + "Number of index keys scanned during query execution. Lower is better.": "Number of index keys scanned during query execution. Lower is better.", "OK": "OK", "Open Collection": "Open Collection", "Open installation page": "Open installation page", "Opening DocumentDB connection…": "Opening DocumentDB connection…", "Operation cancelled.": "Operation cancelled.", + "Optimization Opportunities": "Optimization Opportunities", + "Optimize Index Strategy": "Optimize Index Strategy", + "Optimizing the index on {0} can improve query performance by better matching the query pattern.": "Optimizing the index on {0} can improve query performance by better matching the query pattern.", "Password for {username_at_resource}": "Password for {username_at_resource}", + "Performance Rating": "Performance Rating", "Pick \"{number}\" to confirm and continue.": "Pick \"{number}\" to confirm and continue.", "Please authenticate first by expanding the tree item of the selected cluster.": "Please authenticate first by expanding the tree item of the selected cluster.", "Please confirm by re-entering the previous value.": "Please confirm by re-entering the previous value.", @@ -367,24 +554,51 @@ "Please enter the username": "Please enter the username", "Please enter the word \"{expectedConfirmationWord}\" to confirm the operation.": "Please enter the word \"{expectedConfirmationWord}\" to confirm the operation.", "Please provide the username for \"{resource}\":": "Please provide the username for \"{resource}\":", + "Poor": "Poor", "Port number is required": "Port number is required", "Port number must be a number": "Port number must be a number", "Port number must be between 1 and 65535": "Port number must be between 1 and 65535", "Procedure not found: {name}": "Procedure not found: {name}", "Process exited: \"{command}\"": "Process exited: \"{command}\"", + "Project": "Project", "Provide Feedback": "Provide Feedback", "Provider \"{0}\" does not have resource type \"{1}\".": "Provider \"{0}\" does not have resource type \"{1}\".", + "Query Efficiency Analysis": "Query Efficiency Analysis", + "Query Execution Failed": "Query Execution Failed", + "Query generation failed": "Query generation failed", + "Query generation failed with the error: {0}": "Query generation failed with the error: {0}", + "Query generation is using model \"{actualModel}\" instead of preferred \"{preferredModel}\". Results may vary.": "Query generation is using model \"{actualModel}\" instead of preferred \"{preferredModel}\". Results may vary.", + "query insights": "query insights", + "Query Insights APIs not initialized. Client may not be properly connected.": "Query Insights APIs not initialized. Client may not be properly connected.", + "Query Insights is not available for Azure Cosmos DB for MongoDB (RU) accounts.": "Query Insights is not available for Azure Cosmos DB for MongoDB (RU) accounts.", + "Query Insights is not supported on Azure Cosmos DB for MongoDB (RU) clusters.": "Query Insights is not supported on Azure Cosmos DB for MongoDB (RU) clusters.", + "Query Insights Not Available": "Query Insights Not Available", + "Query Insights Stage 2 completed with execution error": "Query Insights Stage 2 completed with execution error", + "query or queryObject is required when not using pre-loaded data": "query or queryObject is required when not using pre-loaded data", + "Query Performance Analysis": "Query Performance Analysis", + "Query Performance Insight": "Query Performance Insight", + "Query Plan Summary": "Query Plan Summary", + "Quick Actions": "Quick Actions", + "Recommendation: Create Index": "Recommendation: Create Index", + "Recommendation: Drop Index": "Recommendation: Drop Index", + "Recommendation: Modify Index": "Recommendation: Modify Index", + "Recommendations were actionable": "Recommendations were actionable", + "Recommendations were not helpful": "Recommendations were not helpful", + "Recommended Index": "Recommended Index", "Refresh": "Refresh", "Refresh current view": "Refresh current view", "Refreshing Azure discovery tree…": "Refreshing Azure discovery tree…", "Registering Providers...": "Registering Providers...", + "Regularly review index statistics to identify unused indexes. Each index adds overhead to write operations, so remove indexes that are not being utilized.": "Regularly review index statistics to identify unused indexes. Each index adds overhead to write operations, so remove indexes that are not being utilized.", "Reload original document from the database": "Reload original document from the database", "Reload Window": "Reload Window", "Remind Me Later": "Remind Me Later", "Rename Connection": "Rename Connection", "Report a Bug": "Report a Bug", + "report an issue": "report an issue", "Report an issue": "Report an issue", "Resource group \"{0}\" already exists in subscription \"{1}\".": "Resource group \"{0}\" already exists in subscription \"{1}\".", + "Retry": "Retry", "Return to the account list": "Return to the account list", "Reusing active connection for \"{cluster}\".": "Reusing active connection for \"{cluster}\".", "Revisit connection details and try again.": "Revisit connection details and try again.", @@ -416,21 +630,33 @@ "Selected subscriptions: {0}": "Selected subscriptions: {0}", "Selected tenants: {0}": "Selected tenants: {0}", "Service Discovery": "Service Discovery", + "Session ID is required": "Session ID is required", + "sessionId is required for query optimization": "sessionId is required for query optimization", + "SHARD_MERGE · {0} shards": "SHARD_MERGE · {0} shards", + "SHARD_MERGE · {0} shards · {1} docs · {2}ms": "SHARD_MERGE · {0} shards · {1} docs · {2}ms", + "Shard: {0}": "Shard: {0}", + "Show Stage Details": "Show Stage Details", "Sign in to Azure to continue…": "Sign in to Azure to continue…", "Sign in to Azure...": "Sign in to Azure...", "Sign in to other Azure accounts to access more subscriptions": "Sign in to other Azure accounts to access more subscriptions", "Sign in to other Azure accounts to access more tenants": "Sign in to other Azure accounts to access more tenants", "Sign in with a different account…": "Sign in with a different account…", "Signing out programmatically is not supported. You must sign out by selecting the account in the Accounts menu and choosing Sign Out.": "Signing out programmatically is not supported. You must sign out by selecting the account in the Accounts menu and choosing Sign Out.", + "Skip": "Skip", "Skip for now": "Skip for now", "Small breadcrumb example with buttons": "Small breadcrumb example with buttons", "Some items could not be displayed": "Some items could not be displayed", + "Sort": "Sort", "Specified character lengths should be 1 character or greater.": "Specified character lengths should be 1 character or greater.", + "Start a discussion": "Start a discussion", "Started executable: \"{command}\". Connecting to host…": "Started executable: \"{command}\". Connecting to host…", "Starting Azure account management wizard": "Starting Azure account management wizard", "Starting Azure sign-in process…": "Starting Azure sign-in process…", "Starting executable: \"{command}\"": "Starting executable: \"{command}\"", "Starts with mongodb:// or mongodb+srv://": "Starts with mongodb:// or mongodb+srv://", + "Submit": "Submit", + "Submit Feedback": "Submit Feedback", + "Submitting...": "Submitting...", "subscription": "subscription", "Subscription ID: {0}": "Subscription ID: {0}", "Successfully configured subscription filtering. Selected {0} subscription(s)": "Successfully configured subscription filtering. Selected {0} subscription(s)", @@ -445,9 +671,15 @@ "Tag can only contain alphanumeric characters, underscores, periods, and hyphens.": "Tag can only contain alphanumeric characters, underscores, periods, and hyphens.", "Tag cannot be empty.": "Tag cannot be empty.", "Tag cannot be longer than 256 characters.": "Tag cannot be longer than 256 characters.", + "Template file is empty: {path}": "Template file is empty: {path}", + "Template file not found: {path}": "Template file not found: {path}", "Tenant ID cannot be empty": "Tenant ID cannot be empty", "Tenant ID: {0}": "Tenant ID: {0}", "Tenant Name: {0}": "Tenant Name: {0}", + "Thank you for helping us improve!": "Thank you for helping us improve!", + "Thank you for your feedback!": "Thank you for your feedback!", + "The \"_id_\" index cannot be deleted.": "The \"_id_\" index cannot be deleted.", + "The \"_id_\" index cannot be hidden.": "The \"_id_\" index cannot be hidden.", "The \"{databaseId}\" database has been deleted.": "The \"{databaseId}\" database has been deleted.", "The \"{name}\" database has been created.": "The \"{name}\" database has been created.", "The \"{newCollectionName}\" collection has been created.": "The \"{newCollectionName}\" collection has been created.", @@ -486,16 +718,24 @@ "The value must be {0} characters or greater.": "The value must be {0} characters or greater.", "The value must be {0} characters or less.": "The value must be {0} characters or less.", "The value must be between {0} and {1} characters long.": "The value must be between {0} and {1} characters long.", + "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 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 the Mongo DB shell, but we could not find it in the path or using the documentDB.mongoShell.path setting.": "This functionality requires the Mongo DB shell, but we could not find it in the path or using the documentDB.mongoShell.path setting.", "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}\".", + "This index on {0} is not being used and adds unnecessary overhead to write operations.": "This index on {0} is not being used and adds unnecessary overhead to write operations.", "This operation is not supported.": "This operation is not supported.", "This table view presents data at the root level by default.": "This table view presents data at the root level by default.", + "This will {operation} an index on collection \"{collectionName}\".": "This will {operation} an index on collection \"{collectionName}\".", + "This will {operation} the index \"{indexName}\" on collection \"{collectionName}\".": "This will {operation} the index \"{indexName}\" on collection \"{collectionName}\".", + "This will allow the query planner to use this index again.": "This will allow the query planner to use this index again.", + "This will prevent the query planner from using this index.": "This will prevent the query planner from using this index.", "Timed out trying to execute the Mongo script. To use a longer timeout, modify the VS Code 'mongo.shell.timeout' setting.": "Timed out trying to execute the Mongo script. To use a longer timeout, modify the VS Code 'mongo.shell.timeout' setting.", "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.", "Too many arguments. Expecting 0 or 1 argument(s) to {constructorCall}": "Too many arguments. Expecting 0 or 1 argument(s) to {constructorCall}", + "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", "Try again": "Try again", "Type \"it\" for more": "Type \"it\" for more", @@ -504,9 +744,17 @@ "Unable to parse syntax near line {line}, col {column}: {message}": "Unable to parse syntax near line {line}, col {column}: {message}", "Unable to retrieve credentials for cluster \"{cluster}\".": "Unable to retrieve credentials for cluster \"{cluster}\".", "Unable to retrieve credentials for the selected cluster.": "Unable to retrieve credentials for the selected cluster.", + "Understanding Your Query Execution Plan": "Understanding Your Query Execution Plan", "Unexpected status code: {0}": "Unexpected status code: {0}", + "unhide": "unhide", + "Unhide index \"{indexName}\" from collection \"{collectionName}\"?": "Unhide index \"{indexName}\" from collection \"{collectionName}\"?", + "Unhide index?": "Unhide index?", + "Unhide Index…": "Unhide Index…", + "Unhiding index…": "Unhiding index…", + "Unknown command type: {type}": "Unknown command type: {type}", "Unknown error": "Unknown error", "Unknown Error": "Unknown Error", + "Unknown query generation type: {type}": "Unknown query generation type: {type}", "Unknown tenant": "Unknown tenant", "Unrecognized node type encountered. Could not parse {constructorCall} as part of {functionCall}": "Unrecognized node type encountered. Could not parse {constructorCall} as part of {functionCall}", "Unrecognized node type encountered. We could not parse {text}": "Unrecognized node type encountered. We could not parse {text}", @@ -515,7 +763,9 @@ "Unsupported authentication mechanism. Only SCRAM-SHA-256 (username/password) is supported.": "Unsupported authentication mechanism. Only SCRAM-SHA-256 (username/password) is supported.", "Unsupported authentication method: {0}": "Unsupported authentication method: {0}", "Unsupported authentication method.": "Unsupported authentication method.", + "Unsupported command type detected.": "Unsupported command type detected.", "Unsupported emulator type: \"{emulatorType}\"": "Unsupported emulator type: \"{emulatorType}\"", + "Unsupported query type: {queryType}": "Unsupported query type: {queryType}", "Unsupported resource: {0}": "Unsupported resource: {0}", "Unsupported view for an authentication retry.": "Unsupported view for an authentication retry.", "Up": "Up", @@ -526,13 +776,18 @@ "Upload": "Upload", "URL handling aborted. Connection was unsuccessful or the specified database/collection does not exist.": "URL handling aborted. Connection was unsuccessful or the specified database/collection does not exist.", "Use anyway": "Use anyway", + "Use projection to return only necessary fields. This reduces network transfer and memory usage, especially important for documents with large embedded arrays or binary data.": "Use projection to return only necessary fields. This reduces network transfer and memory usage, especially important for documents with large embedded arrays or binary data.", "Username and Password": "Username and Password", "Username cannot be empty": "Username cannot be empty", "Username for {resource}": "Username for {resource}", + "Using custom prompt template for {type} query generation: {path}": "Using custom prompt template for {type} query generation: {path}", + "Using custom prompt template for {type} query: {path}": "Using custom prompt template for {type} query: {path}", "Using existing resource group \"{0}\".": "Using existing resource group \"{0}\".", "Using the table navigation, you can explore deeper levels or move back and forth between them.": "Using the table navigation, you can explore deeper levels or move back and forth between them.", "Validate": "Validate", "View in Marketplace": "View in Marketplace", + "View Raw Execution Stats": "View Raw Execution Stats", + "View Raw Explain Output": "View Raw Explain Output", "View selected document": "View selected document", "Viewing Azure account information for: {0}": "Viewing Azure account information for: {0}", "Waiting for Azure sign-in...": "Waiting for Azure sign-in...", @@ -542,6 +797,7 @@ "What's New": "What's New", "Where to save the exported documents?": "Where to save the exported documents?", "with Popover": "with Popover", + "Working...": "Working...", "Working…": "Working…", "Would you like to open the Collection View?": "Would you like to open the Collection View?", "Write error: {0}": "Write error: {0}", @@ -559,6 +815,10 @@ "You might be asked for credentials to establish the connection.\nDo you want to continue?\n\nNote: You can disable these URL handling confirmations in the extension settings.": "You might be asked for credentials to establish the connection.\nDo you want to continue?\n\nNote: You can disable these URL handling confirmations in the extension settings.", "You must open a *.vscode-documentdb-scrapbook file to run commands.": "You must open a *.vscode-documentdb-scrapbook file to run commands.", "You need to provide the password for \"{username}\" in order to continue. Your password will not be stored.": "You need to provide the password for \"{username}\" in order to continue. Your password will not be stored.", + "Your Cluster": "Your Cluster", "Your database stores documents with embedded fields, allowing for hierarchical data organization.": "Your database stores documents with embedded fields, allowing for hierarchical data organization.", + "Your feedback helps us improve Query Insights. Tell us what could be better:": "Your feedback helps us improve Query Insights. Tell us what could be better:", + "Your positive feedback helps us understand what works well in Query Insights. Tell us more:": "Your positive feedback helps us understand what works well in Query Insights. Tell us more:", + "Your query is performing well. You can still use the AI-powered analysis to get a detailed explanation of the query execution, review the indexing, and explore if further optimizations are possible.": "Your query is performing well. You can still use the AI-powered analysis to get a detailed explanation of the query execution, review the indexing, and explore if further optimizations are possible.", "Your VS Code window must be reloaded to perform this action.": "Your VS Code window must be reloaded to perform this action." } diff --git a/package-lock.json b/package-lock.json index 1838996b6..c490e9fe8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "vscode-documentdb", - "version": "0.5.2", + "version": "0.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "vscode-documentdb", - "version": "0.5.2", + "version": "0.6.0", "license": "SEE LICENSE IN LICENSE.md", "dependencies": { "@azure/arm-compute": "^22.4.0", @@ -23,6 +23,7 @@ "@microsoft/vscode-azext-utils": "~3.3.1", "@microsoft/vscode-azureresources-api": "~2.5.0", "@monaco-editor/react": "~4.7.0", + "@mongodb-js/explain-plan-helper": "1.4.24", "@trpc/client": "~11.4.3", "@trpc/server": "~11.4.3", "@vscode/l10n": "~0.0.18", @@ -35,6 +36,7 @@ "mongodb": "~6.17.0", "mongodb-connection-string-url": "~3.0.2", "react-hotkeys-hook": "~5.1.0", + "react-markdown": "^10.1.0", "regenerator-runtime": "^0.14.1", "semver": "~7.7.2", "slickgrid-react": "~5.14.1", @@ -4142,6 +4144,16 @@ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/@mongodb-js/explain-plan-helper": { + "version": "1.4.24", + "resolved": "https://registry.npmjs.org/@mongodb-js/explain-plan-helper/-/explain-plan-helper-1.4.24.tgz", + "integrity": "sha512-JKX44aUFBAUlGkIw6Ad7Ov0WCidmrt0Z8Q5aGijLFTYGKaRN99lA5o1hDnUHC2f3MFasze5GNTLaP7TIwDQaZw==", + "license": "SSPL", + "dependencies": { + "@mongodb-js/shell-bson-parser": "^1.2.0", + "mongodb-explain-compat": "^3.3.23" + } + }, "node_modules/@mongodb-js/saslprep": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.3.0.tgz", @@ -4151,6 +4163,18 @@ "sparse-bitfield": "^3.0.3" } }, + "node_modules/@mongodb-js/shell-bson-parser": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@mongodb-js/shell-bson-parser/-/shell-bson-parser-1.3.3.tgz", + "integrity": "sha512-B72m2oLK/yCUF5bX1BUFdjfO2LHKsqFNmoOhmw8+o36o2JMlwT0g0+p+s5aYVp9MVReb+l+3Fa3aAYq2cNo5bA==", + "license": "Apache-2.0", + "dependencies": { + "acorn": "^8.14.1" + }, + "peerDependencies": { + "bson": "^4.6.3 || ^5 || ^6" + } + }, "node_modules/@napi-rs/nice": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/@napi-rs/nice/-/nice-1.0.4.tgz", @@ -5848,6 +5872,15 @@ "@types/node": "*" } }, + "node_modules/@types/debug": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", + "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, "node_modules/@types/documentdb": { "version": "1.10.13", "resolved": "https://registry.npmjs.org/@types/documentdb/-/documentdb-1.10.13.tgz", @@ -5862,9 +5895,17 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, "license": "MIT" }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, "node_modules/@types/express": { "version": "4.17.23", "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.23.tgz", @@ -5891,6 +5932,15 @@ "@types/send": "*" } }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, "node_modules/@types/http-cache-semantics": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.4.tgz", @@ -5967,6 +6017,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -5981,6 +6040,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "22.15.35", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.35.tgz", @@ -6130,6 +6195,12 @@ "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", "license": "MIT" }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, "node_modules/@types/uuid": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", @@ -6443,7 +6514,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", - "dev": true, "license": "ISC" }, "node_modules/@unrs/resolver-binding-android-arm-eabi": { @@ -7484,7 +7554,6 @@ "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", - "dev": true, "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -8035,6 +8104,16 @@ "@babel/core": "^7.11.0" } }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -8589,6 +8668,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -8629,6 +8718,46 @@ "node": ">=10" } }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/charenc": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz", @@ -8914,6 +9043,16 @@ "node": ">= 0.8" } }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/commander": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", @@ -9326,6 +9465,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/decode-named-character-reference": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.2.0.tgz", + "integrity": "sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/decompress-response": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", @@ -9583,6 +9735,19 @@ "dev": true, "license": "MIT" }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/diff": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz", @@ -10555,6 +10720,16 @@ "node": ">=4.0" } }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -10761,6 +10936,12 @@ "node": ">=4" } }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -11706,6 +11887,46 @@ "node": ">= 0.4" } }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/he": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", @@ -11856,6 +12077,16 @@ "entities": "^2.0.0" } }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/htmlparser2": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.0.0.tgz", @@ -12216,6 +12447,12 @@ "license": "ISC", "optional": true }, + "node_modules/inline-style-parser": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.6.tgz", + "integrity": "sha512-gtGXVaBdl5mAes3rPcMedEBm12ibjt1kDMFfheul1wUAOVEJW60voNdMVzVkfLN06O7ZaD/rxhfKgtlgtTbMjg==", + "license": "MIT" + }, "node_modules/inspect-with-kind": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/inspect-with-kind/-/inspect-with-kind-1.0.5.tgz", @@ -12261,6 +12498,30 @@ "node": ">= 10" } }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -12423,6 +12684,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-docker": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", @@ -12516,6 +12787,16 @@ "node": ">=0.10.0" } }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-inside-container": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", @@ -14010,6 +14291,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -14128,6 +14419,159 @@ "is-buffer": "~1.1.6" } }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.2.tgz", + "integrity": "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.0.tgz", + "integrity": "sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/mdurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", @@ -14208,31 +14652,473 @@ "node": ">= 0.6" } }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], "license": "MIT", "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/mime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", - "dev": true, - "license": "MIT", - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4" + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" } }, "node_modules/mime-db": { @@ -14588,6 +15474,12 @@ "whatwg-url": "^14.1.0 || ^13.0.0" } }, + "node_modules/mongodb-explain-compat": { + "version": "3.3.23", + "resolved": "https://registry.npmjs.org/mongodb-explain-compat/-/mongodb-explain-compat-3.3.23.tgz", + "integrity": "sha512-3OygQjzjHr0hsT3y0f91yP7Ylp+2bbEM3IPil2yv+9avmGhA9Ru+CcgTXI+IfkfNlbcly/w7alvHDk+dlX28QQ==", + "license": "SSPL" + }, "node_modules/moo": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/moo/-/moo-0.5.2.tgz", @@ -15349,6 +16241,31 @@ "node": ">=6" } }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, "node_modules/parse-json": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", @@ -15923,6 +16840,16 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, + "node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -16257,6 +17184,33 @@ "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "license": "MIT" }, + "node_modules/react-markdown": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", + "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -16460,6 +17414,39 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -17608,6 +18595,16 @@ "node": ">=0.10.0" } }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/sparse-bitfield": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", @@ -17974,6 +18971,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/strip-ansi": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", @@ -18102,6 +19113,24 @@ "webpack": "^5.27.0" } }, + "node_modules/style-to-js": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.19.tgz", + "integrity": "sha512-Ev+SgeqiNGT1ufsXyVC5RrJRXdrkRJ1Gol9Qw7Pb72YCKJXrBvP0ckZhBeVSrw2m06DJpei2528uIpjMb4TsoQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.12" + } + }, + "node_modules/style-to-object": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.12.tgz", + "integrity": "sha512-ddJqYnoT4t97QvN2C95bCgt+m7AAgXjVnkk/jxAfmp7EAB8nnqqZYEbMd3em7/vEomDb2LAQKAy1RFfv41mdNw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.6" + } + }, "node_modules/stylis": { "version": "4.3.6", "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.6.tgz", @@ -18686,6 +19715,26 @@ "tslib": "2" } }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/ts-api-utils": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", @@ -19155,6 +20204,105 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unified/node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.0.0.tgz", + "integrity": "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/universalify": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", @@ -19380,6 +20528,34 @@ "url": "https://bevry.me/fund" } }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/vscode-json-languageservice": { "version": "5.6.1", "resolved": "https://registry.npmjs.org/vscode-json-languageservice/-/vscode-json-languageservice-5.6.1.tgz", @@ -20475,6 +21651,16 @@ "funding": { "url": "https://github.com/sponsors/colinhacks" } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } } } } diff --git a/package.json b/package.json index e08afef7a..ac3f3928e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "vscode-documentdb", - "version": "0.5.2", + "version": "0.6.0", "aiKey": "0c6ae279ed8443289764825290e4f9e2-1a736e7c-1324-4338-be46-fc2a58ae4d14-7255", "publisher": "ms-azuretools", "displayName": "DocumentDB for VS Code", @@ -158,6 +158,7 @@ "@microsoft/vscode-azext-utils": "~3.3.1", "@microsoft/vscode-azureresources-api": "~2.5.0", "@monaco-editor/react": "~4.7.0", + "@mongodb-js/explain-plan-helper": "1.4.24", "@trpc/client": "~11.4.3", "@trpc/server": "~11.4.3", "@vscode/l10n": "~0.0.18", @@ -170,6 +171,7 @@ "mongodb": "~6.17.0", "mongodb-connection-string-url": "~3.0.2", "react-hotkeys-hook": "~5.1.0", + "react-markdown": "^10.1.0", "regenerator-runtime": "^0.14.1", "semver": "~7.7.2", "slickgrid-react": "~5.14.1", @@ -411,6 +413,24 @@ "command": "vscode-documentdb.command.dropDatabase", "title": "Delete Database…" }, + { + "//": "Hide Index", + "category": "DocumentDB", + "command": "vscode-documentdb.command.hideIndex", + "title": "Hide Index…" + }, + { + "//": "Unhide Index", + "category": "DocumentDB", + "command": "vscode-documentdb.command.unhideIndex", + "title": "Unhide Index…" + }, + { + "//": "Delete Index", + "category": "DocumentDB", + "command": "vscode-documentdb.command.dropIndex", + "title": "Delete Index…" + }, { "//": "Create Collection", "category": "DocumentDB", @@ -677,6 +697,24 @@ "when": "view =~ /connectionsView|discoveryView|azure(ResourceGroups|FocusView)/ && viewItem =~ /\\btreeitem_collection\\b/i && viewItem =~ /\\bexperience_(documentDB|mongoRU)\\b/i", "group": "3@1" }, + { + "//": "[Index] Hide Index", + "command": "vscode-documentdb.command.hideIndex", + "when": "view =~ /connectionsView|discoveryView|azure(ResourceGroups|FocusView)/ && viewItem =~ /\\btreeitem_index\\b/i && viewItem =~ /\\bexperience_(documentDB|mongoRU)\\b/i", + "group": "2@1" + }, + { + "//": "[Index] Unhide Index", + "command": "vscode-documentdb.command.unhideIndex", + "when": "view =~ /connectionsView|discoveryView|azure(ResourceGroups|FocusView)/ && viewItem =~ /\\btreeitem_index\\b/i && viewItem =~ /\\bexperience_(documentDB|mongoRU)\\b/i", + "group": "2@2" + }, + { + "//": "[Index] Delete Index", + "command": "vscode-documentdb.command.dropIndex", + "when": "view =~ /connectionsView|discoveryView|azure(ResourceGroups|FocusView)/ && viewItem =~ /\\btreeitem_index\\b/i && viewItem =~ /\\bexperience_(documentDB|mongoRU)\\b/i", + "group": "3@1" + }, { "//": "[Collection] Launch shell", "command": "vscode-documentdb.command.launchShell", @@ -774,6 +812,18 @@ "command": "vscode-documentdb.command.dropCollection", "when": "never" }, + { + "command": "vscode-documentdb.command.hideIndex", + "when": "never" + }, + { + "command": "vscode-documentdb.command.unhideIndex", + "when": "never" + }, + { + "command": "vscode-documentdb.command.dropIndex", + "when": "never" + }, { "command": "vscode-documentdb.command.createDocument", "when": "never" @@ -900,6 +950,57 @@ "type": "number", "description": "The batch size to be used when querying working with the shell.", "default": 50 + }, + "documentDB.collectionView.defaultPageSize": { + "type": "number", + "description": "Default page size for loading data in the collection view.", + "default": 50, + "enum": [ + 10, + 50, + 100, + 500 + ] + }, + "documentDB.aiAssistant.findQueryPromptPath": { + "type": [ + "string", + "null" + ], + "description": "Path to a custom prompt template file for find query optimization. Leave empty to use the built-in template.", + "default": null + }, + "documentDB.aiAssistant.aggregateQueryPromptPath": { + "type": [ + "string", + "null" + ], + "description": "Path to a custom prompt template file for aggregate query optimization. Leave empty to use the built-in template.", + "default": null + }, + "documentDB.aiAssistant.countQueryPromptPath": { + "type": [ + "string", + "null" + ], + "description": "Path to a custom prompt template file for count query optimization. Leave empty to use the built-in template.", + "default": null + }, + "documentDB.aiAssistant.crossCollectionQueryPromptPath": { + "type": [ + "string", + "null" + ], + "description": "Path to a custom prompt template file for cross-collection query generation. Leave empty to use the built-in template.", + "default": null + }, + "documentDB.aiAssistant.singleCollectionQueryPromptPath": { + "type": [ + "string", + "null" + ], + "description": "Path to a custom prompt template file for single collection query generation. Leave empty to use the built-in template.", + "default": null } } } @@ -949,7 +1050,7 @@ { "id": "azure-resources-integration-v0.4.0", "title": "5. Use Azure Resources (optional)", - "description": "**If you use the Azure Resources extension**, you can access Azure Cosmos DB for MongoDB (RU) and Azure Cosmos DB for MongoDB (vCore) clusters from the Azure Resource View. This **optional integration** helps you work from the Azure environment you already use. [Read more...](https://github.com/microsoft/vscode-documentdb/discussions/251)", + "description": "**If you use the Azure Resources extension**, you can access Azure Cosmos DB for MongoDB (RU) and Azure DocumentDB clusters from the Azure Resource View. This **optional integration** helps you work from the Azure environment you already use. [Read more...](https://github.com/microsoft/vscode-documentdb/discussions/251)", "media": { "image": "resources/walkthroughs/azure-resources.png", "altText": "Azure Resources integration" diff --git a/resources/debug/README.md b/resources/debug/README.md new file mode 100644 index 000000000..fce2cece1 --- /dev/null +++ b/resources/debug/README.md @@ -0,0 +1,111 @@ +# Query Insights Debug Override Files + +This directory contains debug override files for testing Query Insights with different MongoDB explain plan responses. + +## Quick Start + +1. **Copy an example** from `examples/` to the parent directory: + + ```bash + cp examples/collscan-stage1.json query-insights-stage1.json + cp examples/collscan-stage2.json query-insights-stage2.json + ``` + +2. **Set `_debug_active` to `true`** in each file to activate + +3. **Run any query** in the Query Insights tab - your debug data will be used instead! + +4. **Edit live** - changes take effect on next query execution + +## How It Works + +When you execute a query in Query Insights: + +1. The system checks for `query-insights-stage1.json` and `query-insights-stage2.json` +2. If found and `"_debug_active": true`, uses that data instead of calling MongoDB +3. The data is processed through the same `ExplainPlanAnalyzer` as real queries +4. You see real UX with your custom explain plans + +## Files + +- `query-insights-stage1.json` - Override Stage 1 (Query Planner) - uses `explain("queryPlanner")` +- `query-insights-stage2.json` - Override Stage 2 (Execution Stats) - uses `explain("executionStats")` +- `examples/` - Pre-made examples to copy and modify + +## Examples Provided + +### Efficient Index Scan (Default in main files) + +- IXSCAN → FETCH → PROJECTION +- 100% efficiency (100 docs examined, 100 returned) +- Fast execution (~120ms) + +### Collection Scan with Sort (examples/collscan-\*) + +- COLLSCAN → SORT (in-memory) +- 0% index usage +- Poor efficiency (2400 docs examined, 20 returned) +- Slow execution (~550ms) + +## Creating Your Own Test Data + +### From MongoDB Shell + +```javascript +// For Stage 1 (Query Planner) +db.collection.explain('queryPlanner').find({ yourQuery }); + +// For Stage 2 (Execution Stats) +db.collection.explain('executionStats').find({ yourQuery }); +``` + +Copy the entire JSON output into the respective file. + +### Important: The JSON Format + +The files should contain the **raw MongoDB explain response**, exactly as returned by: + +- `db.collection.explain("queryPlanner").find(...)` +- `db.collection.explain("executionStats").find(...)` + +This includes fields like `queryPlanner`, `executionStats`, `serverInfo`, etc. + +## Testing Scenarios + +Edit the files to test different query patterns: + +### ✅ Good Performance + +- Index scans (IXSCAN) +- Covered queries +- Low examined/returned ratio + +### ⚠️ Poor Performance + +- Collection scans (COLLSCAN) +- In-memory sorts (SORT without index) +- High examined/returned ratio +- Multiple rejected plans + +### 🔍 Complex Queries + +- Multiple stages +- Sharded queries +- Compound index usage + +## Tips + +- **Live editing**: Files are read on every query execution +- **Output panel**: Check "DocumentDB" output channel for debug messages +- **Validation**: VS Code validates JSON syntax automatically +- **Reset**: Set `"_debug_active"` to `false` or delete files to return to normal mode +- **Both stages**: You can override just Stage 1, just Stage 2, or both independently + +## Deactivating + +To stop using debug files: + +1. Set `"_debug_active": false` in the JSON, OR +2. Delete the files + +The system will automatically fall back to real MongoDB queries. diff --git a/resources/debug/examples/collscan-stage1.json b/resources/debug/examples/collscan-stage1.json new file mode 100644 index 000000000..47c7718d6 --- /dev/null +++ b/resources/debug/examples/collscan-stage1.json @@ -0,0 +1,33 @@ +{ + "_debug_active": false, + "_instructions": "Set _debug_active to true to activate - This is a COLLSCAN example for testing poor query performance", + + "queryPlanner": { + "plannerVersion": 1, + "namespace": "mydb.mycollection", + "indexFilterSet": false, + "parsedQuery": { + "description": { "$regex": "urgent" } + }, + "winningPlan": { + "stage": "SORT", + "sortPattern": { + "createdAt": -1 + }, + "inputStage": { + "stage": "COLLSCAN", + "filter": { + "description": { "$regex": "urgent" } + }, + "direction": "forward" + } + }, + "rejectedPlans": [] + }, + "serverInfo": { + "host": "localhost", + "port": 27017, + "version": "6.0.0" + }, + "ok": 1 +} diff --git a/resources/debug/examples/collscan-stage2.json b/resources/debug/examples/collscan-stage2.json new file mode 100644 index 000000000..5d7a4db44 --- /dev/null +++ b/resources/debug/examples/collscan-stage2.json @@ -0,0 +1,74 @@ +{ + "_debug_active": false, + "_instructions": "Set _debug_active to true to activate - This is a COLLSCAN example showing poor performance with in-memory sort", + + "queryPlanner": { + "plannerVersion": 1, + "namespace": "mydb.mycollection", + "indexFilterSet": false, + "parsedQuery": { + "description": { "$regex": "urgent" } + }, + "winningPlan": { + "stage": "SORT", + "sortPattern": { + "createdAt": -1 + }, + "inputStage": { + "stage": "COLLSCAN", + "filter": { + "description": { "$regex": "urgent" } + }, + "direction": "forward" + } + }, + "rejectedPlans": [] + }, + "executionStats": { + "executionSuccess": true, + "nReturned": 20, + "executionTimeMillis": 550, + "totalKeysExamined": 0, + "totalDocsExamined": 2400, + "executionStages": { + "stage": "SORT", + "nReturned": 20, + "executionTimeMillisEstimate": 165, + "works": 2402, + "advanced": 20, + "needTime": 2401, + "needYield": 0, + "saveState": 2, + "restoreState": 2, + "isEOF": 1, + "sortPattern": { + "createdAt": -1 + }, + "memUsage": 8192, + "memLimit": 33554432, + "inputStage": { + "stage": "COLLSCAN", + "filter": { + "description": { "$regex": "urgent" } + }, + "nReturned": 2400, + "executionTimeMillisEstimate": 385, + "works": 2402, + "advanced": 2400, + "needTime": 1, + "needYield": 0, + "saveState": 2, + "restoreState": 2, + "isEOF": 1, + "direction": "forward", + "docsExamined": 2400 + } + } + }, + "serverInfo": { + "host": "localhost", + "port": 27017, + "version": "6.0.0" + }, + "ok": 1 +} diff --git a/resources/debug/examples/query-insights-stage1.json b/resources/debug/examples/query-insights-stage1.json new file mode 100644 index 000000000..d20b6e499 --- /dev/null +++ b/resources/debug/examples/query-insights-stage1.json @@ -0,0 +1,50 @@ +{ + "_debug_active": true, + "_instructions": "Set _debug_active to true to activate this override. This file contains a MongoDB explain('queryPlanner') response.", + + "queryPlanner": { + "plannerVersion": 1, + "namespace": "mydb.mycollection", + "indexFilterSet": false, + "parsedQuery": { + "status": { "$eq": "PENDING" } + }, + "winningPlan": { + "stage": "PROJECTION", + "transformBy": { + "_id": 1, + "status": 1, + "createdAt": 1 + }, + "inputStage": { + "stage": "FETCH", + "inputStage": { + "stage": "IXSCAN", + "keyPattern": { + "status": 1 + }, + "indexName": "status_1", + "isMultiKey": false, + "multiKeyPaths": { + "status": [] + }, + "isUnique": false, + "isSparse": false, + "isPartial": false, + "indexVersion": 2, + "direction": "forward", + "indexBounds": { + "status": ["[\"PENDING\", \"PENDING\"]"] + } + } + } + }, + "rejectedPlans": [] + }, + "serverInfo": { + "host": "localhost", + "port": 27017, + "version": "6.0.0" + }, + "ok": 1 +} diff --git a/resources/debug/examples/query-insights-stage2.json b/resources/debug/examples/query-insights-stage2.json new file mode 100644 index 000000000..50e4102be --- /dev/null +++ b/resources/debug/examples/query-insights-stage2.json @@ -0,0 +1,120 @@ +{ + "_debug_active": true, + "_instructions": "Set _debug_active to true to activate this override. This file contains a MongoDB explain('executionStats') response.", + + "queryPlanner": { + "plannerVersion": 1, + "namespace": "mydb.mycollection", + "indexFilterSet": false, + "parsedQuery": { + "status": { "$eq": "PENDING" } + }, + "winningPlan": { + "stage": "PROJECTION", + "transformBy": { + "_id": 1, + "status": 1, + "createdAt": 1 + }, + "inputStage": { + "stage": "FETCH", + "inputStage": { + "stage": "IXSCAN", + "keyPattern": { + "status": 1 + }, + "indexName": "status_1", + "isMultiKey": false, + "multiKeyPaths": { + "status": [] + }, + "isUnique": false, + "isSparse": false, + "isPartial": false, + "indexVersion": 2, + "direction": "forward", + "indexBounds": { + "status": ["[\"PENDING\", \"PENDING\"]"] + } + } + } + }, + "rejectedPlans": [] + }, + "executionStats": { + "executionSuccess": true, + "nReturned": 100, + "executionTimeMillis": 120, + "totalKeysExamined": 100, + "totalDocsExamined": 100, + "executionStages": { + "stage": "PROJECTION", + "nReturned": 100, + "executionTimeMillisEstimate": 10, + "works": 101, + "advanced": 100, + "needTime": 0, + "needYield": 0, + "saveState": 0, + "restoreState": 0, + "isEOF": 1, + "transformBy": { + "_id": 1, + "status": 1, + "createdAt": 1 + }, + "inputStage": { + "stage": "FETCH", + "nReturned": 100, + "executionTimeMillisEstimate": 45, + "works": 101, + "advanced": 100, + "needTime": 0, + "needYield": 0, + "saveState": 0, + "restoreState": 0, + "isEOF": 1, + "docsExamined": 100, + "alreadyHasObj": 0, + "inputStage": { + "stage": "IXSCAN", + "nReturned": 100, + "executionTimeMillisEstimate": 65, + "works": 101, + "advanced": 100, + "needTime": 0, + "needYield": 0, + "saveState": 0, + "restoreState": 0, + "isEOF": 1, + "keyPattern": { + "status": 1 + }, + "indexName": "status_1", + "isMultiKey": false, + "multiKeyPaths": { + "status": [] + }, + "isUnique": false, + "isSparse": false, + "isPartial": false, + "indexVersion": 2, + "direction": "forward", + "indexBounds": { + "status": ["[\"PENDING\", \"PENDING\"]"] + }, + "keysExamined": 100, + "seeks": 1, + "dupsTested": 0, + "dupsDropped": 0 + } + } + } + }, + "serverInfo": { + "host": "localhost", + "port": 27017, + "version": "6.0.0" + }, + "ok": 1 +} diff --git a/resources/debug/examples/sharded-stage1.json b/resources/debug/examples/sharded-stage1.json new file mode 100644 index 000000000..52e1897c0 --- /dev/null +++ b/resources/debug/examples/sharded-stage1.json @@ -0,0 +1,99 @@ +{ + "_debug_active": false, + "_instructions": "Set _debug_active to true to activate this override. This file contains a MongoDB explain('queryPlanner') response for a SHARDED collection.", + + "queryPlanner": { + "mongosPlannerVersion": 1, + "winningPlan": { + "stage": "SHARD_MERGE", + "shards": [ + { + "shardName": "shard-01", + "connectionString": "shard-01/mongo1:27017,mongo2:27017", + "serverInfo": { + "host": "mongo1", + "port": 27017, + "version": "6.0.0" + }, + "plannerVersion": 1, + "namespace": "mydb.orders", + "indexFilterSet": false, + "parsedQuery": { + "status": { "$eq": "PENDING" } + }, + "winningPlan": { + "stage": "PROJECTION", + "transformBy": { + "_id": 1, + "status": 1, + "total": 1 + }, + "inputStage": { + "stage": "FETCH", + "inputStage": { + "stage": "IXSCAN", + "keyPattern": { + "status": 1 + }, + "indexName": "status_1", + "isMultiKey": false, + "multiKeyPaths": { + "status": [] + }, + "isUnique": false, + "isSparse": false, + "isPartial": false, + "indexVersion": 2, + "direction": "forward", + "indexBounds": { + "status": ["[\"PENDING\", \"PENDING\"]"] + } + } + } + } + }, + { + "shardName": "shard-02", + "connectionString": "shard-02/mongo3:27017,mongo4:27017", + "serverInfo": { + "host": "mongo3", + "port": 27017, + "version": "6.0.0" + }, + "plannerVersion": 1, + "namespace": "mydb.orders", + "indexFilterSet": false, + "parsedQuery": { + "status": { "$eq": "PENDING" } + }, + "winningPlan": { + "stage": "PROJECTION", + "transformBy": { + "_id": 1, + "status": 1, + "total": 1 + }, + "inputStage": { + "stage": "SORT", + "sortPattern": { + "createdAt": -1 + }, + "memLimit": 104857600, + "type": "simple", + "inputStage": { + "stage": "COLLSCAN", + "direction": "forward" + } + } + } + } + ] + } + }, + "serverInfo": { + "host": "mongos", + "port": 27017, + "version": "6.0.0" + }, + "ok": 1 +} diff --git a/resources/debug/examples/sharded-stage2.json b/resources/debug/examples/sharded-stage2.json new file mode 100644 index 000000000..586e6e65c --- /dev/null +++ b/resources/debug/examples/sharded-stage2.json @@ -0,0 +1,244 @@ +{ + "_debug_active": false, + "_instructions": "Set _debug_active to true to activate this override. This file contains a MongoDB explain('executionStats') response for a SHARDED collection.", + + "queryPlanner": { + "mongosPlannerVersion": 1, + "winningPlan": { + "stage": "SHARD_MERGE", + "shards": [ + { + "shardName": "shard-01", + "connectionString": "shard-01/mongo1:27017,mongo2:27017", + "serverInfo": { + "host": "mongo1", + "port": 27017, + "version": "6.0.0" + }, + "plannerVersion": 1, + "namespace": "mydb.orders", + "indexFilterSet": false, + "parsedQuery": { + "status": { "$eq": "PENDING" } + }, + "winningPlan": { + "stage": "PROJECTION", + "transformBy": { + "_id": 1, + "status": 1, + "total": 1 + }, + "inputStage": { + "stage": "FETCH", + "inputStage": { + "stage": "IXSCAN", + "keyPattern": { + "status": 1 + }, + "indexName": "status_1", + "isMultiKey": false, + "multiKeyPaths": { + "status": [] + }, + "isUnique": false, + "isSparse": false, + "isPartial": false, + "indexVersion": 2, + "direction": "forward", + "indexBounds": { + "status": ["[\"PENDING\", \"PENDING\"]"] + } + } + } + } + }, + { + "shardName": "shard-02", + "connectionString": "shard-02/mongo3:27017,mongo4:27017", + "serverInfo": { + "host": "mongo3", + "port": 27017, + "version": "6.0.0" + }, + "plannerVersion": 1, + "namespace": "mydb.orders", + "indexFilterSet": false, + "parsedQuery": { + "status": { "$eq": "PENDING" } + }, + "winningPlan": { + "stage": "PROJECTION", + "transformBy": { + "_id": 1, + "status": 1, + "total": 1 + }, + "inputStage": { + "stage": "SORT", + "sortPattern": { + "createdAt": -1 + }, + "memLimit": 104857600, + "type": "simple", + "inputStage": { + "stage": "COLLSCAN", + "direction": "forward" + } + } + } + } + ] + } + }, + "executionStats": { + "executionSuccess": true, + "nReturned": 50, + "executionTimeMillis": 1400, + "totalKeysExamined": 6200, + "totalDocsExamined": 9900, + "executionStages": { + "stage": "SHARD_MERGE", + "nReturned": 50, + "executionTimeMillis": 1400, + "totalKeysExamined": 6200, + "totalDocsExamined": 9900, + "shards": [ + { + "shardName": "shard-01", + "executionSuccess": true, + "nReturned": 30, + "executionTimeMillis": 850, + "totalKeysExamined": 6200, + "totalDocsExamined": 7500, + "executionStages": { + "stage": "PROJECTION", + "nReturned": 30, + "executionTimeMillisEstimate": 840, + "works": 6231, + "advanced": 30, + "needTime": 6200, + "needYield": 0, + "saveState": 48, + "restoreState": 48, + "isEOF": 1, + "transformBy": { + "_id": 1, + "status": 1, + "total": 1 + }, + "inputStage": { + "stage": "FETCH", + "nReturned": 30, + "executionTimeMillisEstimate": 835, + "works": 6231, + "advanced": 30, + "needTime": 6200, + "needYield": 0, + "saveState": 48, + "restoreState": 48, + "isEOF": 1, + "docsExamined": 7500, + "alreadyHasObj": 0, + "inputStage": { + "stage": "IXSCAN", + "nReturned": 7500, + "executionTimeMillisEstimate": 510, + "works": 6231, + "advanced": 7500, + "needTime": 0, + "needYield": 0, + "saveState": 48, + "restoreState": 48, + "isEOF": 1, + "keyPattern": { + "status": 1 + }, + "indexName": "status_1", + "isMultiKey": false, + "multiKeyPaths": { + "status": [] + }, + "isUnique": false, + "isSparse": false, + "isPartial": false, + "indexVersion": 2, + "direction": "forward", + "indexBounds": { + "status": ["[\"PENDING\", \"PENDING\"]"] + }, + "keysExamined": 6200, + "seeks": 1, + "dupsTested": 0, + "dupsDropped": 0 + } + } + } + }, + { + "shardName": "shard-02", + "executionSuccess": true, + "nReturned": 20, + "executionTimeMillis": 550, + "totalKeysExamined": 0, + "totalDocsExamined": 2400, + "executionStages": { + "stage": "PROJECTION", + "nReturned": 20, + "executionTimeMillisEstimate": 545, + "works": 2402, + "advanced": 20, + "needTime": 2401, + "needYield": 0, + "saveState": 18, + "restoreState": 18, + "isEOF": 1, + "transformBy": { + "_id": 1, + "status": 1, + "total": 1 + }, + "inputStage": { + "stage": "SORT", + "nReturned": 20, + "executionTimeMillisEstimate": 540, + "works": 2402, + "advanced": 20, + "needTime": 2401, + "needYield": 0, + "saveState": 18, + "restoreState": 18, + "isEOF": 1, + "sortPattern": { + "createdAt": -1 + }, + "memLimit": 104857600, + "type": "simple", + "totalDataSizeSorted": 153600, + "usedDisk": false, + "inputStage": { + "stage": "COLLSCAN", + "nReturned": 2400, + "executionTimeMillisEstimate": 385, + "works": 2402, + "advanced": 2400, + "needTime": 1, + "needYield": 0, + "saveState": 18, + "restoreState": 18, + "isEOF": 1, + "direction": "forward", + "docsExamined": 2400 + } + } + } + } + ] + } + }, + "serverInfo": { + "host": "mongos", + "port": 27017, + "version": "6.0.0" + }, + "ok": 1 +} diff --git a/resources/debug/query-insights-stage1.json b/resources/debug/query-insights-stage1.json new file mode 100644 index 000000000..52e1897c0 --- /dev/null +++ b/resources/debug/query-insights-stage1.json @@ -0,0 +1,99 @@ +{ + "_debug_active": false, + "_instructions": "Set _debug_active to true to activate this override. This file contains a MongoDB explain('queryPlanner') response for a SHARDED collection.", + + "queryPlanner": { + "mongosPlannerVersion": 1, + "winningPlan": { + "stage": "SHARD_MERGE", + "shards": [ + { + "shardName": "shard-01", + "connectionString": "shard-01/mongo1:27017,mongo2:27017", + "serverInfo": { + "host": "mongo1", + "port": 27017, + "version": "6.0.0" + }, + "plannerVersion": 1, + "namespace": "mydb.orders", + "indexFilterSet": false, + "parsedQuery": { + "status": { "$eq": "PENDING" } + }, + "winningPlan": { + "stage": "PROJECTION", + "transformBy": { + "_id": 1, + "status": 1, + "total": 1 + }, + "inputStage": { + "stage": "FETCH", + "inputStage": { + "stage": "IXSCAN", + "keyPattern": { + "status": 1 + }, + "indexName": "status_1", + "isMultiKey": false, + "multiKeyPaths": { + "status": [] + }, + "isUnique": false, + "isSparse": false, + "isPartial": false, + "indexVersion": 2, + "direction": "forward", + "indexBounds": { + "status": ["[\"PENDING\", \"PENDING\"]"] + } + } + } + } + }, + { + "shardName": "shard-02", + "connectionString": "shard-02/mongo3:27017,mongo4:27017", + "serverInfo": { + "host": "mongo3", + "port": 27017, + "version": "6.0.0" + }, + "plannerVersion": 1, + "namespace": "mydb.orders", + "indexFilterSet": false, + "parsedQuery": { + "status": { "$eq": "PENDING" } + }, + "winningPlan": { + "stage": "PROJECTION", + "transformBy": { + "_id": 1, + "status": 1, + "total": 1 + }, + "inputStage": { + "stage": "SORT", + "sortPattern": { + "createdAt": -1 + }, + "memLimit": 104857600, + "type": "simple", + "inputStage": { + "stage": "COLLSCAN", + "direction": "forward" + } + } + } + } + ] + } + }, + "serverInfo": { + "host": "mongos", + "port": 27017, + "version": "6.0.0" + }, + "ok": 1 +} diff --git a/resources/debug/query-insights-stage2.json b/resources/debug/query-insights-stage2.json new file mode 100644 index 000000000..586e6e65c --- /dev/null +++ b/resources/debug/query-insights-stage2.json @@ -0,0 +1,244 @@ +{ + "_debug_active": false, + "_instructions": "Set _debug_active to true to activate this override. This file contains a MongoDB explain('executionStats') response for a SHARDED collection.", + + "queryPlanner": { + "mongosPlannerVersion": 1, + "winningPlan": { + "stage": "SHARD_MERGE", + "shards": [ + { + "shardName": "shard-01", + "connectionString": "shard-01/mongo1:27017,mongo2:27017", + "serverInfo": { + "host": "mongo1", + "port": 27017, + "version": "6.0.0" + }, + "plannerVersion": 1, + "namespace": "mydb.orders", + "indexFilterSet": false, + "parsedQuery": { + "status": { "$eq": "PENDING" } + }, + "winningPlan": { + "stage": "PROJECTION", + "transformBy": { + "_id": 1, + "status": 1, + "total": 1 + }, + "inputStage": { + "stage": "FETCH", + "inputStage": { + "stage": "IXSCAN", + "keyPattern": { + "status": 1 + }, + "indexName": "status_1", + "isMultiKey": false, + "multiKeyPaths": { + "status": [] + }, + "isUnique": false, + "isSparse": false, + "isPartial": false, + "indexVersion": 2, + "direction": "forward", + "indexBounds": { + "status": ["[\"PENDING\", \"PENDING\"]"] + } + } + } + } + }, + { + "shardName": "shard-02", + "connectionString": "shard-02/mongo3:27017,mongo4:27017", + "serverInfo": { + "host": "mongo3", + "port": 27017, + "version": "6.0.0" + }, + "plannerVersion": 1, + "namespace": "mydb.orders", + "indexFilterSet": false, + "parsedQuery": { + "status": { "$eq": "PENDING" } + }, + "winningPlan": { + "stage": "PROJECTION", + "transformBy": { + "_id": 1, + "status": 1, + "total": 1 + }, + "inputStage": { + "stage": "SORT", + "sortPattern": { + "createdAt": -1 + }, + "memLimit": 104857600, + "type": "simple", + "inputStage": { + "stage": "COLLSCAN", + "direction": "forward" + } + } + } + } + ] + } + }, + "executionStats": { + "executionSuccess": true, + "nReturned": 50, + "executionTimeMillis": 1400, + "totalKeysExamined": 6200, + "totalDocsExamined": 9900, + "executionStages": { + "stage": "SHARD_MERGE", + "nReturned": 50, + "executionTimeMillis": 1400, + "totalKeysExamined": 6200, + "totalDocsExamined": 9900, + "shards": [ + { + "shardName": "shard-01", + "executionSuccess": true, + "nReturned": 30, + "executionTimeMillis": 850, + "totalKeysExamined": 6200, + "totalDocsExamined": 7500, + "executionStages": { + "stage": "PROJECTION", + "nReturned": 30, + "executionTimeMillisEstimate": 840, + "works": 6231, + "advanced": 30, + "needTime": 6200, + "needYield": 0, + "saveState": 48, + "restoreState": 48, + "isEOF": 1, + "transformBy": { + "_id": 1, + "status": 1, + "total": 1 + }, + "inputStage": { + "stage": "FETCH", + "nReturned": 30, + "executionTimeMillisEstimate": 835, + "works": 6231, + "advanced": 30, + "needTime": 6200, + "needYield": 0, + "saveState": 48, + "restoreState": 48, + "isEOF": 1, + "docsExamined": 7500, + "alreadyHasObj": 0, + "inputStage": { + "stage": "IXSCAN", + "nReturned": 7500, + "executionTimeMillisEstimate": 510, + "works": 6231, + "advanced": 7500, + "needTime": 0, + "needYield": 0, + "saveState": 48, + "restoreState": 48, + "isEOF": 1, + "keyPattern": { + "status": 1 + }, + "indexName": "status_1", + "isMultiKey": false, + "multiKeyPaths": { + "status": [] + }, + "isUnique": false, + "isSparse": false, + "isPartial": false, + "indexVersion": 2, + "direction": "forward", + "indexBounds": { + "status": ["[\"PENDING\", \"PENDING\"]"] + }, + "keysExamined": 6200, + "seeks": 1, + "dupsTested": 0, + "dupsDropped": 0 + } + } + } + }, + { + "shardName": "shard-02", + "executionSuccess": true, + "nReturned": 20, + "executionTimeMillis": 550, + "totalKeysExamined": 0, + "totalDocsExamined": 2400, + "executionStages": { + "stage": "PROJECTION", + "nReturned": 20, + "executionTimeMillisEstimate": 545, + "works": 2402, + "advanced": 20, + "needTime": 2401, + "needYield": 0, + "saveState": 18, + "restoreState": 18, + "isEOF": 1, + "transformBy": { + "_id": 1, + "status": 1, + "total": 1 + }, + "inputStage": { + "stage": "SORT", + "nReturned": 20, + "executionTimeMillisEstimate": 540, + "works": 2402, + "advanced": 20, + "needTime": 2401, + "needYield": 0, + "saveState": 18, + "restoreState": 18, + "isEOF": 1, + "sortPattern": { + "createdAt": -1 + }, + "memLimit": 104857600, + "type": "simple", + "totalDataSizeSorted": 153600, + "usedDisk": false, + "inputStage": { + "stage": "COLLSCAN", + "nReturned": 2400, + "executionTimeMillisEstimate": 385, + "works": 2402, + "advanced": 2400, + "needTime": 1, + "needYield": 0, + "saveState": 18, + "restoreState": 18, + "isEOF": 1, + "direction": "forward", + "docsExamined": 2400 + } + } + } + } + ] + } + }, + "serverInfo": { + "host": "mongos", + "port": 27017, + "version": "6.0.0" + }, + "ok": 1 +} diff --git a/resources/icons/theme-agnostic/AzureDocumentDb.svg b/resources/icons/theme-agnostic/AzureDocumentDb.svg new file mode 100644 index 000000000..2042390da --- /dev/null +++ b/resources/icons/theme-agnostic/AzureDocumentDb.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/src/commands/exportDocuments/exportDocuments.ts b/src/commands/exportDocuments/exportDocuments.ts index 65860e92b..e00a25a94 100644 --- a/src/commands/exportDocuments/exportDocuments.ts +++ b/src/commands/exportDocuments/exportDocuments.ts @@ -7,7 +7,7 @@ import { callWithTelemetryAndErrorHandling, type IActionContext, parseError } fr import * as l10n from '@vscode/l10n'; import { EJSON } from 'bson'; import * as vscode from 'vscode'; -import { ClustersClient } from '../../documentdb/ClustersClient'; +import { ClustersClient, type FindQueryParams } from '../../documentdb/ClustersClient'; import { ext } from '../../extensionVariables'; import { type CollectionItem } from '../../tree/documentdb/CollectionItem'; import { appendToFile } from '../../utils/fs/appendToFile'; @@ -22,7 +22,7 @@ export async function exportEntireCollection(context: IActionContext, node?: Col export async function exportQueryResults( context: IActionContext, node?: CollectionItem, - props?: { queryText?: string; source?: string }, + props?: { queryText?: string; queryParams?: FindQueryParams; source?: string }, ): Promise { context.telemetry.properties.experience = node?.experience.api; @@ -42,11 +42,17 @@ export async function exportQueryResults( const client = await ClustersClient.getClient(node.cluster.id); const docStreamAbortController = new AbortController(); - const docStream = client.streamDocuments( + + // Convert legacy queryText to queryParams if needed + const queryParams: FindQueryParams = props?.queryParams ?? { + filter: props?.queryText ?? '{}', + }; + + const docStream = client.streamDocumentsWithQuery( node.databaseInfo.name, node.collectionInfo.name, docStreamAbortController.signal, - props?.queryText, + queryParams, ); const filePath = targetUri.fsPath; // Convert `vscode.Uri` to a regular file path @@ -67,7 +73,8 @@ export async function exportQueryResults( }); actionContext.telemetry.properties.source = props?.source; - actionContext.telemetry.measurements.queryLength = props?.queryText?.length; + actionContext.telemetry.measurements.queryLength = + props?.queryParams?.filter?.length ?? props?.queryText?.length; actionContext.telemetry.measurements.documentCount = documentCount; }); diff --git a/src/commands/importDocuments/importDocuments.ts b/src/commands/importDocuments/importDocuments.ts index 2a1e0866c..a1ed8c39a 100644 --- a/src/commands/importDocuments/importDocuments.ts +++ b/src/commands/importDocuments/importDocuments.ts @@ -209,7 +209,7 @@ async function parseAndValidateFile( /** * @param uri - An array of `vscode.Uri` objects representing the file paths to the JSON documents. * EJSON is used to read documents that are supposed to be converted into BSON. - * EJSON supports more datatypes and is specific to MongoDB. This is currently used for MongoDB clusters/vcore. + * EJSON supports more datatypes and is specific to MongoDB. This is currently used for MongoDB clusters/DocumentDB. * @returns A promise that resolves to an array of parsed documents as unknown objects. */ async function parseAndValidateFileForMongo(uri: vscode.Uri): Promise<{ documents: unknown[]; errors: string[] }> { diff --git a/src/commands/index.dropIndex/dropIndex.ts b/src/commands/index.dropIndex/dropIndex.ts new file mode 100644 index 000000000..b0d2d1636 --- /dev/null +++ b/src/commands/index.dropIndex/dropIndex.ts @@ -0,0 +1,74 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { ClustersClient } from '../../documentdb/ClustersClient'; +import { ext } from '../../extensionVariables'; +import { type IndexItem } from '../../tree/documentdb/IndexItem'; +import { getConfirmationAsInSettings } from '../../utils/dialogs/getConfirmation'; +import { showConfirmationAsInSettings } from '../../utils/dialogs/showConfirmation'; + +export async function dropIndex(context: IActionContext, node: IndexItem): Promise { + if (!node) { + throw new Error(l10n.t('No index selected.')); + } + + context.telemetry.properties.experience = node.experience.api; + context.telemetry.properties.indexName = node.indexInfo.name; + + // Prevent deleting the _id index + if (node.indexInfo.name === '_id_') { + throw new Error(l10n.t('The "_id_" index cannot be deleted.')); + } + + const indexName = node.indexInfo.name; + const collectionName = node.collectionInfo.name; + + const confirmed = await getConfirmationAsInSettings( + l10n.t('Delete index?'), + l10n.t('Delete index "{indexName}" from collection "{collectionName}"?', { indexName, collectionName }) + + '\n' + + l10n.t('This cannot be undone.'), + indexName, + ); + + if (!confirmed) { + return; + } + + try { + const client = await ClustersClient.getClient(node.cluster.id); + + let success = false; + await ext.state.showDeleting(node.id, async () => { + const result = await client.dropIndex( + node.databaseInfo.name, + node.collectionInfo.name, + node.indexInfo.name, + ); + + // Check for errors in the response + if (result.ok === 0 || result.note) { + const errorMessage = typeof result.note === 'string' ? result.note : l10n.t('Failed to drop index.'); + throw new Error(errorMessage); + } + + success = result.ok === 1; + }); + + if (success) { + showConfirmationAsInSettings(l10n.t('Index "{indexName}" has been deleted.', { indexName })); + } + } finally { + // Refresh parent (collection's indexes folder) + const lastSlashIndex = node.id.lastIndexOf('/'); + let parentId = node.id; + if (lastSlashIndex !== -1) { + parentId = parentId.substring(0, lastSlashIndex); + } + ext.state.notifyChildrenChanged(parentId); + } +} diff --git a/src/commands/index.hideIndex/hideIndex.ts b/src/commands/index.hideIndex/hideIndex.ts new file mode 100644 index 000000000..53f6f0902 --- /dev/null +++ b/src/commands/index.hideIndex/hideIndex.ts @@ -0,0 +1,79 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { ClustersClient } from '../../documentdb/ClustersClient'; +import { ext } from '../../extensionVariables'; +import { type IndexItem } from '../../tree/documentdb/IndexItem'; +import { getConfirmationWithClick } from '../../utils/dialogs/getConfirmation'; +import { showConfirmationAsInSettings } from '../../utils/dialogs/showConfirmation'; + +export async function hideIndex(context: IActionContext, node: IndexItem): Promise { + if (!node) { + throw new Error(l10n.t('No index selected.')); + } + + context.telemetry.properties.experience = node.experience.api; + context.telemetry.properties.indexName = node.indexInfo.name; + + // Prevent hiding the _id index + if (node.indexInfo.name === '_id_') { + throw new Error(l10n.t('The "_id_" index cannot be hidden.')); + } + + // Check if already hidden + if (node.indexInfo.hidden) { + throw new Error(l10n.t('Index "{indexName}" is already hidden.', { indexName: node.indexInfo.name })); + } + + const indexName = node.indexInfo.name; + const collectionName = node.collectionInfo.name; + + const confirmed = await getConfirmationWithClick( + l10n.t('Hide index?'), + l10n.t('Hide index "{indexName}" from collection "{collectionName}"?', { indexName, collectionName }) + + '\n' + + l10n.t('This will prevent the query planner from using this index.'), + ); + + if (!confirmed) { + return; + } + + try { + const client = await ClustersClient.getClient(node.cluster.id); + + let success = false; + await ext.state.showCreatingChild(node.id, l10n.t('Hiding index…'), async () => { + const result = await client.hideIndex( + node.databaseInfo.name, + node.collectionInfo.name, + node.indexInfo.name, + ); + + // Check for errors in the response + if (result.ok === 0 || result.errmsg) { + const errorMessage = + typeof result.errmsg === 'string' ? result.errmsg : l10n.t('Failed to hide index.'); + throw new Error(errorMessage); + } + + success = result.ok === 1; + }); + + if (success) { + showConfirmationAsInSettings(l10n.t('Index "{indexName}" has been hidden.', { indexName })); + } + } finally { + // Refresh parent (collection's indexes folder) + const lastSlashIndex = node.id.lastIndexOf('/'); + let parentId = node.id; + if (lastSlashIndex !== -1) { + parentId = parentId.substring(0, lastSlashIndex); + } + ext.state.notifyChildrenChanged(parentId); + } +} diff --git a/src/commands/index.unhideIndex/unhideIndex.ts b/src/commands/index.unhideIndex/unhideIndex.ts new file mode 100644 index 000000000..7bd606e5b --- /dev/null +++ b/src/commands/index.unhideIndex/unhideIndex.ts @@ -0,0 +1,74 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { ClustersClient } from '../../documentdb/ClustersClient'; +import { ext } from '../../extensionVariables'; +import { type IndexItem } from '../../tree/documentdb/IndexItem'; +import { getConfirmationWithClick } from '../../utils/dialogs/getConfirmation'; +import { showConfirmationAsInSettings } from '../../utils/dialogs/showConfirmation'; + +export async function unhideIndex(context: IActionContext, node: IndexItem): Promise { + if (!node) { + throw new Error(l10n.t('No index selected.')); + } + + context.telemetry.properties.experience = node.experience.api; + context.telemetry.properties.indexName = node.indexInfo.name; + + // Check if index is actually hidden + if (!node.indexInfo.hidden) { + throw new Error(l10n.t('Index "{indexName}" is not hidden.', { indexName: node.indexInfo.name })); + } + + const indexName = node.indexInfo.name; + const collectionName = node.collectionInfo.name; + + const confirmed = await getConfirmationWithClick( + l10n.t('Unhide index?'), + l10n.t('Unhide index "{indexName}" from collection "{collectionName}"?', { indexName, collectionName }) + + '\n' + + l10n.t('This will allow the query planner to use this index again.'), + ); + + if (!confirmed) { + return; + } + + try { + const client = await ClustersClient.getClient(node.cluster.id); + + let success = false; + await ext.state.showCreatingChild(node.id, l10n.t('Unhiding index…'), async () => { + const result = await client.unhideIndex( + node.databaseInfo.name, + node.collectionInfo.name, + node.indexInfo.name, + ); + + // Check for errors in the response + if (result.ok === 0 || result.errmsg) { + const errorMessage = + typeof result.errmsg === 'string' ? result.errmsg : l10n.t('Failed to unhide index.'); + throw new Error(errorMessage); + } + + success = result.ok === 1; + }); + + if (success) { + showConfirmationAsInSettings(l10n.t('Index "{indexName}" has been unhidden.', { indexName })); + } + } finally { + // Refresh parent (collection's indexes folder) + const lastSlashIndex = node.id.lastIndexOf('/'); + let parentId = node.id; + if (lastSlashIndex !== -1) { + parentId = parentId.substring(0, lastSlashIndex); + } + ext.state.notifyChildrenChanged(parentId); + } +} diff --git a/src/commands/launchShell/launchShell.ts b/src/commands/launchShell/launchShell.ts index 7975e2466..9daa626a2 100644 --- a/src/commands/launchShell/launchShell.ts +++ b/src/commands/launchShell/launchShell.ts @@ -75,8 +75,8 @@ export async function launchShell( authMechanism = AuthMethodId.NativeAuth; } else { // Only SCRAM-SHA-256 (username/password) authentication is supported here. - // Today we support Entra ID with Azure Cosmos DB for MongoDB (vCore), and vCore does not support shell connectivity as of today - // https://learn.microsoft.com/en-us/azure/cosmos-db/mongodb/vcore/limits#microsoft-entra-id-authentication + // Today we support Entra ID with Azure DocumentDB, and Azure DocumentDB does not support EntraID + shell connectivity as of today + // https://learn.microsoft.com/en-us/azure/cosmos-db/mongodb/vcore/limits#microsoft-entra-id-authentication (formerly vCore) throw Error( l10n.t( 'Unsupported authentication mechanism. Only "Username and Password" (SCRAM-SHA-256) is supported.', @@ -94,8 +94,8 @@ export async function launchShell( if (authMechanism !== AuthMethodId.NativeAuth) { // Only SCRAM-SHA-256 (username/password) authentication is supported here. - // Today we support Entra ID with Azure Cosmos DB for MongoDB (vCore), and vCore does not support shell connectivity as of today - // https://learn.microsoft.com/en-us/azure/cosmos-db/mongodb/vcore/limits#microsoft-entra-id-authentication + // Today we support Entra ID with Azure DocumentDB, and Azure DocumentDB does not support EntraID + shell connectivity as of today + // https://learn.microsoft.com/en-us/azure/cosmos-db/mongodb/vcore/limits#microsoft-entra-id-authentication (formerly vCore) throw Error( l10n.t('Unsupported authentication mechanism. Only SCRAM-SHA-256 (username/password) is supported.'), ); diff --git a/src/commands/llmEnhancedCommands/indexAdvisorCommands.ts b/src/commands/llmEnhancedCommands/indexAdvisorCommands.ts new file mode 100644 index 000000000..a1e5816b2 --- /dev/null +++ b/src/commands/llmEnhancedCommands/indexAdvisorCommands.ts @@ -0,0 +1,781 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { type Document, type Filter } from 'mongodb'; +import * as vscode from 'vscode'; +import { type IndexItemModel } from '../../documentdb/ClustersClient'; +import { ClusterSession } from '../../documentdb/ClusterSession'; +import { type CollectionStats, type IndexStats } from '../../documentdb/LlmEnhancedFeatureApis'; +import { type ClusterMetadata } from '../../documentdb/utils/getClusterMetadata'; +import { ext } from '../../extensionVariables'; +import { CopilotService } from '../../services/copilotService'; +import { PromptTemplateService } from '../../services/promptTemplateService'; +import { FALLBACK_MODELS, PREFERRED_MODEL } from './promptTemplates'; + +/** + * Type of MongoDB command to optimize + */ +export enum CommandType { + Find = 'find', + Aggregate = 'aggregate', + Count = 'count', +} + +/** + * Query object structure + * NOTE: For now we only support find queries here + */ +export interface QueryObject { + // Filter criteria + filter?: Filter; + // Sort specification + sort?: Document; + // Projection specification + projection?: Document; + // Number of documents to skip + skip?: number; + // Maximum number of documents to return + limit?: number; +} + +/** + * Context information needed for query optimization + */ +export interface QueryOptimizationContext { + // The session ID + sessionId: string; + // Database name + databaseName: string; + // Collection name + collectionName: string; + // The query or pipeline to optimize + // Will be removed in later version + // Currently remains for aggregate and count commands + query?: string; + // The query object for find operations + queryObject?: QueryObject; + // The detected command type + commandType: CommandType; + // Pre-loaded execution plan + executionPlan?: unknown; + // Pre-loaded collection stats + collectionStats?: CollectionStats; + // Pre-loaded index stat + indexStats?: IndexStats[]; + // Preferred LLM model for optimization + preferredModel?: string; + // Fallback LLM models + fallbackModels?: string[]; +} + +/** + * Result from query optimization + */ +export interface OptimizationResult { + // The optimization recommendations + recommendations: string; + // The model used to generate recommendations + modelUsed: string; +} + +/** + * Recursively removes constant values from query filters while preserving field names and operators + * @param obj The object to process + * @returns Processed object with constants removed + */ +function removeConstantsFromFilter(obj: unknown): unknown { + if (obj === null || obj === undefined) { + return obj; + } + + if (Array.isArray(obj)) { + return obj.map((item) => removeConstantsFromFilter(item)); + } + + if (typeof obj === 'object') { + const result: Record = {}; + + for (const [key, value] of Object.entries(obj)) { + // Keep MongoDB operators (start with $) + if (key.startsWith('$')) { + // For operators, recursively process their values + if (typeof value === 'object' && value !== null) { + result[key] = removeConstantsFromFilter(value); + } else { + // Replace primitive constant values with placeholder + result[key] = ''; + } + } else { + // For field names, keep the key but process the value + if (typeof value === 'object' && value !== null) { + // Recursively process nested objects (like operators on this field) + result[key] = removeConstantsFromFilter(value); + } else { + // Replace constant value with placeholder + result[key] = ''; + } + } + } + + return result; + } + + // For primitive values at the root level, replace with placeholder + return ''; +} + +/** + * Removes sensitive constant values from explain result while preserving structure and field names + * @param explainResult The explain result to sanitize + * @returns Sanitized explain result + */ +export function sanitizeExplainResult(explainResult: unknown): unknown { + if (!explainResult || typeof explainResult !== 'object') { + return explainResult; + } + + const result = JSON.parse(JSON.stringify(explainResult)) as Record; + + // Process command field if it exists + if ('command' in result) { + if (typeof result.command === 'string') { + // Command is a string, redact it to avoid exposing query details + result.command = ''; + } else if (typeof result.command === 'object' && result.command !== null) { + const command = result.command as Record; + if ('filter' in command) { + command.filter = removeConstantsFromFilter(command.filter); + } + result.command = command; + } + } + + // Process queryPlanner section + if ('queryPlanner' in result && typeof result.queryPlanner === 'object' && result.queryPlanner !== null) { + const queryPlanner = result.queryPlanner as Record; + + // Process parsedQuery + if ('parsedQuery' in queryPlanner) { + queryPlanner.parsedQuery = removeConstantsFromFilter(queryPlanner.parsedQuery); + } + + // Process winningPlan + if ( + 'winningPlan' in queryPlanner && + typeof queryPlanner.winningPlan === 'object' && + queryPlanner.winningPlan !== null + ) { + queryPlanner.winningPlan = sanitizeStage(queryPlanner.winningPlan); + } + + result.queryPlanner = queryPlanner; + } + + // Process rejectedPlans + if ('rejectedPlans' in result && Array.isArray(result.rejectedPlans)) { + result.rejectedPlans = result.rejectedPlans.map((plan) => sanitizeStage(plan)); + } + + // Process executionStats section + if ('executionStats' in result && typeof result.executionStats === 'object' && result.executionStats !== null) { + const executionStats = result.executionStats as Record; + + // Process executionStages + if ('executionStages' in executionStats) { + executionStats.executionStages = sanitizeStage(executionStats.executionStages); + } + + result.executionStats = executionStats; + } + + return result; +} + +/** + * Recursively sanitizes execution plan stages + * @param stage The stage to sanitize + * @returns Sanitized stage + */ +function sanitizeStage(stage: unknown): unknown { + if (!stage || typeof stage !== 'object') { + return stage; + } + + if (Array.isArray(stage)) { + return stage.map((item) => sanitizeStage(item)); + } + + const result = { ...stage } as Record; + + // Process filter field if present + if ('filter' in result) { + result.filter = removeConstantsFromFilter(result.filter); + } + + // Process indexFilterSet field if present (array of filter objects) + if ('indexFilterSet' in result && Array.isArray(result.indexFilterSet)) { + result.indexFilterSet = result.indexFilterSet.map((filter) => removeConstantsFromFilter(filter)); + } + + if ('runtimeFilterSet' in result && Array.isArray(result.runtimeFilterSet)) { + result.runtimeFilterSet = result.runtimeFilterSet.map((filter) => removeConstantsFromFilter(filter)); + } + + // Recursively process nested stages + if ('inputStage' in result) { + result.inputStage = sanitizeStage(result.inputStage); + } + + if ('inputStages' in result && Array.isArray(result.inputStages)) { + result.inputStages = result.inputStages.map((s) => sanitizeStage(s)); + } + + if ('shards' in result && Array.isArray(result.shards)) { + result.shards = result.shards.map((shard) => { + if (typeof shard === 'object' && shard !== null && 'executionStages' in shard) { + const shardObj = shard as Record; + return { + ...shardObj, + executionStages: sanitizeStage(shardObj.executionStages), + }; + } + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return shard; + }); + } + + return result; +} + +/** + * Gets the prompt template for a given command type + * @param commandType The type of command + * @returns The prompt template string + */ +async function getPromptTemplate(commandType: CommandType): Promise { + return PromptTemplateService.getIndexAdvisorPromptTemplate(commandType); +} + +/** + * Detects the type of MongoDB command from the query string + * @param command The MongoDB command string + * @returns The detected command type + */ +export function detectCommandType(command: string): CommandType { + const trimmed = command.trim().toLowerCase(); + + // Check for aggregate + if (trimmed.includes('.aggregate(') || trimmed.startsWith('[')) { + return CommandType.Aggregate; + } + + // Check for count + if (trimmed.includes('.count(') || trimmed.includes('.countdocuments(')) { + return CommandType.Count; + } + + if (trimmed.includes('.find(')) { + return CommandType.Find; + } + + // Throw unsupported if we see other commands + throw new Error(l10n.t('Unsupported command type detected.')); +} + +/** + * Fills a prompt template with actual data + * @param templateType The type of template to use (find, aggregate, or count) + * @param context The query optimization context + * @param collectionStats Statistics about the collection + * @param indexes Current indexes on the collection + * @param executionStats Execution statistics from explain() + * @returns The filled prompt template + */ +async function fillPromptTemplate( + templateType: CommandType, + context: QueryOptimizationContext, + collectionStats: CollectionStats | undefined, + indexes: IndexStats[] | undefined, + executionStats: string, + clusterInfo: ClusterMetadata, +): Promise { + // Get the template for this command type + const template = await getPromptTemplate(templateType); + + // Note: Query information is currently not passed to the prompt + // This may be re-enabled in the future if needed + // if (templateType === CommandType.Find && context.queryObject) { + // // Format query object as structured information + // const queryParts: string[] = []; + // + // if (context.queryObject.filter) { + // queryParts.push(`**Filter**: \`\`\`json\n${JSON.stringify(context.queryObject.filter, null, 2)}\n\`\`\``); + // } + // + // if (context.queryObject.sort) { + // queryParts.push(`**Sort**: \`\`\`json\n${JSON.stringify(context.queryObject.sort, null, 2)}\n\`\`\``); + // } + // + // if (context.queryObject.projection) { + // queryParts.push(`**Projection**: \`\`\`json\n${JSON.stringify(context.queryObject.projection, null, 2)}\n\`\`\``); + // } + // + // if (context.queryObject.skip !== undefined) { + // queryParts.push(`**Skip**: ${context.queryObject.skip}`); + // } + // + // if (context.queryObject.limit !== undefined) { + // queryParts.push(`**Limit**: ${context.queryObject.limit}`); + // } + // + // queryInfo = queryParts.join('\n\n'); + // } else if (context.query) { + // // Fallback to string query for backward compatibility + // queryInfo = context.query; + // } + + // Fill the template with actual data + const filled = template + .replace('{databaseName}', context.databaseName) + .replace('{collectionName}', context.collectionName) + .replace('{collectionStats}', collectionStats ? JSON.stringify(collectionStats, null, 2) : 'N/A') + .replace('{indexStats}', indexes ? JSON.stringify(indexes, null, 2) : 'N/A') + .replace('{executionStats}', executionStats) + .replace('{isAzureCluster}', JSON.stringify(clusterInfo.domainInfo_isAzure, null, 2)) + .replace('{origin_query}', context.query || 'N/A') + .replace( + '{AzureClusterType}', + clusterInfo.domainInfo_isAzure === 'true' ? JSON.stringify(clusterInfo.domainInfo_api, null, 2) : 'N/A', + ); + return filled; +} + +/** + * Optimizes a MongoDB query using Copilot AI + * @param context Action context for telemetry + * @param queryContext Query optimization context + * @returns Optimization recommendations + */ +export async function optimizeQuery( + context: IActionContext, + queryContext: QueryOptimizationContext, +): Promise { + // Check if Copilot is available + const copilotAvailable = await CopilotService.isAvailable(); + if (!copilotAvailable) { + throw new Error( + l10n.t( + 'GitHub Copilot is not available. Please install the GitHub Copilot extension and ensure you have an active subscription.', + ), + ); + } + + let explainResult: unknown; + let collectionStats: CollectionStats | undefined; + let indexes: IndexStats[] | undefined; + + // TODO: allow null sessionId for testing framework + if (!queryContext.sessionId) { + throw new Error(l10n.t('sessionId is required for query optimization')); + } + const session = ClusterSession.getSession(queryContext.sessionId); + const client = session.getClient(); + const clusterInfo = await client.getClusterMetadata(); + + // Track cluster information in telemetry + context.telemetry.properties.commandType = queryContext.commandType; + context.telemetry.properties.isAzure = clusterInfo.domainInfo_isAzure || 'false'; + context.telemetry.properties.azureApi = clusterInfo.domainInfo_api || 'unknown'; + + // Check if we have pre-loaded data + const hasPreloadedData = queryContext.executionPlan; + context.telemetry.properties.hasPreloadedData = hasPreloadedData ? 'true' : 'false'; + + if (hasPreloadedData) { + // Use pre-loaded data + explainResult = queryContext.executionPlan; + collectionStats = queryContext.collectionStats!; + indexes = queryContext.indexStats!; + } else { + // Check if we have queryObject or need to parse query string + if (!queryContext.queryObject && !queryContext.query) { + throw new Error(l10n.t('query or queryObject is required when not using pre-loaded data')); + } + + // Prepare query options based on input format + let explainOptions: QueryObject | undefined; + let parsedQuery: + | { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + filter?: any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + pipeline?: any[]; + explainOptions?: QueryObject; + } + | undefined; + + if (queryContext.queryObject) { + // Use queryObject directly for find operations + explainOptions = queryContext.queryObject; + } else if (queryContext.query) { + // Parse the query string for backward compatibility + parsedQuery = parseQueryString(queryContext.query, queryContext.commandType); + explainOptions = parsedQuery.explainOptions; + } + + // Gather information needed for optimization + try { + const explainStart = Date.now(); + // Execute explain based on command type + if (queryContext.commandType === CommandType.Find) { + const explainData = await client.explainFind( + queryContext.databaseName, + queryContext.collectionName, + clusterInfo.domainInfo_isAzure === 'true' && clusterInfo.domainInfo_api === 'vCore' + ? 'allPlansExecution' + : 'executionStats', + explainOptions, + ); + explainResult = explainData; + } else if (queryContext.commandType === CommandType.Aggregate) { + const explainData = await client.explainAggregate( + queryContext.databaseName, + queryContext.collectionName, + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + parsedQuery?.pipeline || [], + ); + explainResult = explainData; + } else if (queryContext.commandType === CommandType.Count) { + explainResult = await client.explainCount( + queryContext.databaseName, + queryContext.collectionName, + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + parsedQuery?.filter || {}, + ); + } + const explainDuration = Date.now() - explainStart; + context.telemetry.measurements.explainDurationMs = explainDuration; + ext.outputChannel.trace( + l10n.t('[Query Insights AI] explain({commandType}) completed in {ms}ms', { + commandType: queryContext.commandType, + ms: explainDuration.toString(), + }), + ); + } catch (error) { + context.telemetry.properties.explainError = 'true'; + throw new Error( + l10n.t('Failed to gather query optimization data: {message}', { + message: error instanceof Error ? error.message : String(error), + }), + ); + } + } + + let indexesInfo: IndexItemModel[] | undefined; + try { + const statsStart = Date.now(); + collectionStats = await client.getCollectionStats(queryContext.databaseName, queryContext.collectionName); + const statsDuration = Date.now() - statsStart; + context.telemetry.measurements.collectionStatsDurationMs = statsDuration; + ext.outputChannel.trace( + l10n.t('[Query Insights AI] getCollectionStats completed in {ms}ms', { + ms: statsDuration.toString(), + }), + ); + + const indexesInfoStart = Date.now(); + indexesInfo = await client.listIndexes(queryContext.databaseName, queryContext.collectionName); + const indexesInfoDuration = Date.now() - indexesInfoStart; + context.telemetry.measurements.listIndexesDurationMs = indexesInfoDuration; + ext.outputChannel.trace( + l10n.t('[Query Insights AI] listIndexes completed in {ms}ms', { + ms: indexesInfoDuration.toString(), + }), + ); + + const indexesStatsStart = Date.now(); + const indexesStats = await client.getIndexStats(queryContext.databaseName, queryContext.collectionName); + const indexesStatsDuration = Date.now() - indexesStatsStart; + context.telemetry.measurements.indexStatsDurationMs = indexesStatsDuration; + ext.outputChannel.trace( + l10n.t('[Query Insights AI] getIndexStats completed in {ms}ms', { + ms: indexesStatsDuration.toString(), + }), + ); + + // // TODO: handle search indexes for Atlas + // const searchIndexes = await client.listSearchIndexesForAtlas(queryContext.databaseName, queryContext.collectionName); + indexes = indexesStats.map((indexStat) => { + const indexInfo = indexesInfo?.find((idx) => idx.name === indexStat.name); + return { + ...indexStat, + ...indexInfo, + }; + }); + // indexes.push(...searchIndexes); + + // Track stats availability in telemetry + context.telemetry.properties.hasCollectionStats = collectionStats ? 'true' : 'false'; + context.telemetry.measurements.indexCount = indexes?.length ?? 0; + } catch (error) { + // They are not critical errors, we can continue without index stats and collection stats + // If the API calls failed, collectionStats and indexes remain undefined (safe to continue) + context.telemetry.properties.statsError = 'true'; + ext.outputChannel.trace( + l10n.t('[Query Insights AI] Failed to retrieve collection/index stats (non-critical): {message}', { + message: error instanceof Error ? error.message : String(error), + }), + ); + + // Use basic index info as fallback if we have it (from successful listIndexes call) + if (indexesInfo && indexesInfo.length > 0) { + // We have index info but getIndexStats failed, convert to IndexStats format + indexes = indexesInfo + .filter((idx) => idx.key !== undefined) + .map((idx) => ({ + ...idx, + host: 'unknown', + accesses: 'N/A', + key: idx.key!, + })) as IndexStats[]; + + context.telemetry.properties.usedFallbackIndexes = 'true'; + context.telemetry.measurements.indexCount = indexes.length; + } + } + + // // Sanitize explain result to remove constant values while preserving field names + // const sanitizedExplainResult = sanitizeExplainResult(explainResult); + + // // Format execution stats for the prompt + // const sanitizedExecutionStats = JSON.stringify(sanitizedExplainResult, null, 2); + + // Fill the prompt template + const commandType = queryContext.commandType; + const promptContent = await fillPromptTemplate( + commandType, + queryContext, + collectionStats, + indexes, + // sanitizedExecutionStats, + JSON.stringify(explainResult, null, 2), + clusterInfo, + ); + + // Send to Copilot with configured models + const preferredModelToUse = queryContext.preferredModel || PREFERRED_MODEL; + const fallbackModelsToUse = queryContext.fallbackModels || FALLBACK_MODELS; + + const copilotStart = Date.now(); + ext.outputChannel.trace( + l10n.t('[Query Insights AI] Calling Copilot (model: {model})...', { + model: preferredModelToUse, + }), + ); + const response = await CopilotService.sendMessage([vscode.LanguageModelChatMessage.User(promptContent)], { + preferredModel: preferredModelToUse, + fallbackModels: fallbackModelsToUse, + }); + const copilotDuration = Date.now() - copilotStart; + + // Track Copilot call performance and response + context.telemetry.measurements.copilotDurationMs = copilotDuration; + context.telemetry.measurements.promptSize = promptContent.length; + context.telemetry.measurements.responseSize = response.text.length; + context.telemetry.properties.modelUsed = response.modelUsed; + + ext.outputChannel.trace( + l10n.t('[Query Insights AI] Copilot response received in {ms}ms (model: {model})', { + ms: copilotDuration.toString(), + model: response.modelUsed, + }), + ); + + // Check if the preferred model was used + if (response.modelUsed !== preferredModelToUse && preferredModelToUse) { + // Show warning if not using preferred model + void vscode.window.showWarningMessage( + l10n.t( + 'Index optimization is using model "{actualModel}" instead of preferred "{preferredModel}". Recommendations may be less optimal.', + { + actualModel: response.modelUsed, + preferredModel: preferredModelToUse, + }, + ), + ); + } + + return { + recommendations: response.text, + modelUsed: response.modelUsed, + }; +} + +/** + * Converts MongoDB-like query syntax to valid JSON + * Handles single quotes, unquoted keys, and MongoDB operators + */ +function mongoQueryToJSON(str: string): string { + let result = str; + + // Replace single quotes with double quotes + result = result.replace(/'/g, '"'); + + // Add quotes to unquoted MongoDB operators starting with $ + result = result.replace(/\$(\w+)/g, '"$$$1"'); + + // Add quotes to unquoted object keys (word followed by colon) + // This regex looks for word characters followed by optional whitespace and a colon + // But not if they're already quoted or are part of a value + result = result.replace(/(\{|,)\s*([a-zA-Z_]\w*)\s*:/g, '$1"$2":'); + + return result; +} + +/** + * Extracts a method call argument from a query string + * Example: extractMethodArg("sort({'name': -1})", "sort") => "{'name': -1}" + */ +function extractMethodArg(query: string, methodName: string): string | undefined { + const pattern = new RegExp(`\\.${methodName}\\s*\\(\\s*([^)]+)\\s*\\)`, 'i'); + const match = query.match(pattern); + return match ? match[1].trim() : undefined; +} + +/** + * Parses a query string to extract MongoDB query components + * @param queryString The query string to parse + * @param commandType The type of command + * @returns Parsed query components + */ +function parseQueryString( + queryString: string, + commandType: CommandType, +): { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + filter?: any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + pipeline?: any[]; + explainOptions?: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + filter?: any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sort?: any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + projection?: any; + skip?: number; + limit?: number; + }; +} { + const trimmed = queryString.trim(); + + try { + if (commandType === CommandType.Aggregate) { + // Parse aggregation pipeline + // Handle both array format and .aggregate() format + let pipelineStr = trimmed; + + // Remove .aggregate() wrapper if present + const aggregateMatch = trimmed.match(/\.aggregate\s*\(\s*(\[[\s\S]*\])\s*\)/); + if (aggregateMatch) { + pipelineStr = aggregateMatch[1]; + } + + // Convert MongoDB syntax to JSON and parse + const jsonStr = mongoQueryToJSON(pipelineStr); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const pipeline = JSON.parse(jsonStr); + if (!Array.isArray(pipeline)) { + throw new Error('Pipeline must be an array'); + } + + return { pipeline }; + } else if (commandType === CommandType.Count) { + // Parse count query + // Handle .count() or .countDocuments() format + const countMatch = trimmed.match(/\.count(?:Documents)?\s*\(\s*(\{[\s\S]*?\})\s*\)/); + let filterStr = countMatch ? countMatch[1] : trimmed.startsWith('{') ? trimmed : '{}'; + + // Convert MongoDB syntax to JSON + filterStr = mongoQueryToJSON(filterStr); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const filter = JSON.parse(filterStr); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + return { filter }; + } else { + // Parse find query with chained methods + // Example: db.test.find({'age': {$gt: 25}}).sort({'name': -1}).limit(15).project({'name': 1}) + + let filterStr = '{}'; + + // Extract filter from .find() + const findMatch = trimmed.match(/\.find\s*\(\s*(\{[\s\S]*?\})\s*\)/); + if (findMatch) { + filterStr = findMatch[1]; + } else if (trimmed.startsWith('{')) { + filterStr = trimmed; + } + + // Convert MongoDB syntax to JSON and parse filter + filterStr = mongoQueryToJSON(filterStr); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const filter = JSON.parse(filterStr); + + const explainOptions: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + filter?: any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sort?: any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + projection?: any; + skip?: number; + limit?: number; + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + } = { filter }; + + // Extract sort from .sort() + const sortStr = extractMethodArg(trimmed, 'sort'); + if (sortStr) { + const sortJson = mongoQueryToJSON(sortStr); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + explainOptions.sort = JSON.parse(sortJson); + } + + // Extract projection from .project() or .projection() + let projectionStr = extractMethodArg(trimmed, 'project'); + if (!projectionStr) { + projectionStr = extractMethodArg(trimmed, 'projection'); + } + if (projectionStr) { + const projectionJson = mongoQueryToJSON(projectionStr); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + explainOptions.projection = JSON.parse(projectionJson); + } + + // Extract skip from .skip() + const skipStr = extractMethodArg(trimmed, 'skip'); + if (skipStr) { + explainOptions.skip = parseInt(skipStr, 10); + } + + // Extract limit from .limit() + const limitStr = extractMethodArg(trimmed, 'limit'); + if (limitStr) { + explainOptions.limit = parseInt(limitStr, 10); + } + + return { explainOptions }; + } + } catch (error) { + throw new Error( + l10n.t('Failed to parse query string: {message}', { + message: error instanceof Error ? error.message : String(error), + }), + ); + } +} diff --git a/src/commands/llmEnhancedCommands/promptTemplates.ts b/src/commands/llmEnhancedCommands/promptTemplates.ts new file mode 100644 index 000000000..dd238e5cf --- /dev/null +++ b/src/commands/llmEnhancedCommands/promptTemplates.ts @@ -0,0 +1,674 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { l10n } from 'vscode'; + +/** + * Preferred language model for index optimization + */ +export const PREFERRED_MODEL = 'gpt-5'; + +/** + * Fallback models to use if the preferred model is not available + */ +export const FALLBACK_MODELS = ['gpt-4o', 'gpt-4o-mini']; + +/** + * Embedded prompt templates for query optimization + * These templates are compiled into the extension bundle at build time + */ + +export const FIND_QUERY_PROMPT_TEMPLATE = ` +You are an expert MongoDB assistant to provide index suggestions for a find query executed against a MongoDB collection with the following details: + +## Original Query +- **Query**: {origin_query} + +## Cluster Information +- **Is_Azure_Cluster**: {isAzureCluster} +- **Azure_Cluster_Type**: {AzureClusterType} + +## Collection Information +- **Collection_Stats**: {collectionStats} + +## Index Information of Current Collection +- **Indexes_Stats**: {indexStats} + +## Query Execution Stats +- **Execution_Stats**: {executionStats} + +Follow these strict instructions (must obey): +1. **Single JSON output only** — your response MUST be a single valid JSON object and **nothing else** (no surrounding text, no code fences, no explanation). +2. **Do not hallucinate** — only use facts present in the sections Collection_Stats, Indexes_Stats, Execution_Stats. If a required metric is absent, set the corresponding field to \`null\` in \`metadata\`. +3. **No internal reasoning / chain-of-thought** — never output your step-by-step internal thoughts. Give concise, evidence-based conclusions only. +4. **Analysis with fixed structure** — the \`analysis\` field must be a Markdown-formatted string following this exact structure: + + ### Performance Summary + [1-2 sentences summarizing the overall query performance (excellent/good/poor) and primary bottleneck] + + ### Key Issues + [Bullet points listing 2-3 most critical performance problems identified, each with specific metrics from execution stats] + + ### Recommendations + [Brief bullet points listing 2-3 prioritized optimization actions, focusing on highest-impact changes] +5. **Educational content with fixed template** — the \`educationalContent\` field must be a Markdown-formatted string that follows this exact structure: + + ### Query Execution Overview + [2-3 sentences providing a high-level summary of the query execution flow and strategy] + + ### Execution Stages Breakdown + [Detailed explanation of each stage in the execution plan. For each stage mentioned in executionStats, explain: + - What the stage does (e.g., COLLSCAN scans all documents, IXSCAN uses an index, FETCH retrieves full documents, SORT performs sorting, PROJECTION filters fields) + - Key metrics for that stage (documents/keys examined, documents returned) + - Why this stage was necessary + Use bullet points or numbered list for clarity. Be specific about the stage names from the actual execution plan.] + + ### Index Usage Analysis + [2-3 sentences explaining which indexes were used (if any), why they were chosen, or why a collection scan occurred. Mention the specific index name and key pattern if applicable.] + + ### Performance Metrics + [Analyze key performance indicators using bullet points: + - **Documents Examined vs Returned**: [specific numbers and efficiency ratio] + - **Keys Examined**: [number for index scans, if applicable] + - **Inefficiencies Detected**: [list any issues like in-memory sorts, excessive document fetches, blocking operations, etc.] + Keep each bullet point concise but specific with actual metrics from the execution plan.] + + ### Key Findings + [1-2 sentences summarizing the most critical performance bottlenecks or optimization opportunities identified] + +6. **Runnable shell commands** — any index changes you recommend must be provided as **mongosh/mongo shell** commands (runnable). Use \`db.getCollection("{collectionName}")\` to reference the collection (replace \`{collectionName}\` with the actual name from \`collectionStats\`). +7. **Modify operations format** — for any \`modify\` action (e.g., hiding/unhiding indexes, modifying index properties), you MUST use the \`db.getCollection('').operation()\` pattern (e.g., \`db.getCollection('users').hideIndex('index_name')\`). Do NOT use \`db.runCommand()\` format for modify actions. If the modify operation cannot be expressed in this format, set \`action\` to \`"none"\` and explain the limitation in the \`analysis\` field. +8. **Index identification for drop/modify** — for \`drop\` and \`modify\` actions, you MUST use the index **name** (e.g., \`'age_1'\`, \`'name_1_email_1'\`) rather than the index fields/specification. The \`mongoShell\` command should reference the index by name (e.g., \`db.getCollection('users').dropIndex('age_1')\` or \`db.getCollection('users').hideIndex('age_1')\`). +9. **Justify every index command** — each \`create\`/\`drop\` recommendation must include a one-sentence justification that references concrete fields/metrics from \`executionStats\` or \`indexStats\`. +10. **Prefer minimal, safe changes** — prefer a single, high-impact index over many small ones; avoid suggesting drops unless the benefit is clear and justified. +11. **Include priority** — each suggested improvement must include a \`priority\` (\`high\`/\`medium\`/\`low\`) so an engineer can triage. +12. **Priority of modify and drop actions** — priority of modify and drop actions should always be set to \`low\`. +13. **Be explicit about risks** — if a suggested index could increase write cost or large index size, include that as a short risk note in the improvement. +14. **Verification array requirement** — the \`verification\` field must be an **array** with **exactly one verification item per improvement item**. Each verification item must be a Markdown string containing \`\`\`javascript code blocks\`\`\` with valid mongosh commands to verify that specific improvement. If \`improvements\` is an empty array, \`verification\` must also be an empty array. +15. **Do not change input objects** — echo input objects only under \`metadata\`; do not mutate \`{collectionStats}\`, \`{indexStats}\`, or \`{executionStats}\`—just include them as-is (and add computed helper fields if needed). +16. **Do note drop index** — when you want to drop an index, do not drop it, suggest hide it instead. +17. **Be brave to say no** — if you confirm an index change is not beneficial, or not relates to the query, feel free to return empty improvements. +18. **Limited confidence** — if the Indexes_Stats or Collection_Stats is not available ('N/A'), add the following sentence as the first line in your analysis: "Note: Limited confidence in recommendations due to missing optional statistics.\n" + +Thinking / analysis tips (useful signals to form recommendations; don't output these tips themselves): +- Check **which index(es)** the winning plan used (or whether a \`COLLSCAN\` occurred) and whether \`totalKeysExamined\` is much smaller than \`totalDocsExamined\`. +- Prefer indexes that reduce document fetches and align with the winning plan's chosen index. +- **Wildcard index**: If queries filter on multiple unpredictable or dynamic nested fields and no existing index covers them efficiently, and the collection is large (>100k documents), recommend a wildcard index (\`$**\`). Wildcard index should be suggested as an alternative of regular index if schema may vary significantly, but set medium priority. +- **Equality first in compound index**: Always place equality (\`=\`) fields first in a compound index. These fields provide the highest selectivity and allow efficient index filtering. +- **Prioritize high selectivity fields**: When multiple range fields exist, prioritize the high-selectivity fields (those that filter out more documents) first to reduce scanned documents and improve performance. +- **Prioritize restrictive range**: When multiple range fields exist, prioritize the more restrictive ranges first to reduce scanned documents and improve performance. +- **Multiple range filters**: multiple range filters could also get benefit from a compound index, so compound index is also recommended. +- **Regex considerations**: For \`$regex\` queries, suggest indexes for both anchored (e.g., \`^abc\`) and non-anchored patterns (e.g., \`abc\`), as non-anchored regexes can also benefit from indexes by narrowing down the documents needed to be scanned. +- **Multikey/array considerations**: Be aware that multikey or array fields may affect index ordering and whether index-only coverage is achievable. +- **Filter → sort pushdown**: In a compound index, place filter fields (equality and the first range/anchored regex) first, followed by sort-only fields, to maximize index pushdown and avoid in-memory sorting. +- **Sort-only queries**: If a query only includes a sort without filters, consider a dedicated index on the sort fields. +- **Sort order alignment**: Ensure the sort order (ascending/descending) matches the index field order to allow index-covered sorting and avoid blocking stages. +- **Index coverage for filter, sort, and projection**: Prefer creating indexes that include all fields used in the query filter, sort, and projection, so that the query can be served entirely from the index without fetching full documents. This maximizes the chance of a covered query and reduces document fetches. +- Consider **composite indexes** including query, sort, and projection fields; check selectivity first to avoid unnecessary indexes. +- If the **Azure_Cluster_Type** is "vCore" and an index is being created (and it is **not** a wildcard index), always include in indexOptions the setting: "storageEngine": { "enableOrderedIndex": true }. +- For \`$or\` queries, prefer a single compound index if branches share leading fields; otherwise, consider separate indexes with intersection. +- For \`$or\` queries, low-selectivity strategy is not applicable, and **creating corresponding indexes is recommended**. +- **Avoid redundant indexes**; after creating a compound index, remember to suggest dropping any existing prefix indexes as they are redundant indexes after the compound index created. +- Consider **index size and write amplification**; prefer partial or sparse indexes or selective prefixes. +- **Small collection**: Do not create new indexes on collections with fewer than 1000 documents, as the performance gain is negligible and the index maintenance cost may outweigh the benefit. +- **Low-selectivity fields**: Do not create indexes on fields where the number of documents returned is close to the total number of documents (could get from collection stats), because the index will not effectively reduce scanned documents. +- **Explain plan validation**: Verify \`indexBounds\` in \`explain()\` output — \`[MinKey, MaxKey]\` means the field didn't benefit from the index. + +Output JSON schema (required shape; **adhere exactly**): +\`\`\` +{ + "metadata": { + "collectionName": "", + "collectionStats": { ... }, + "indexStats": [ ... ], + "executionStats": { ... }, + "derived": { + "totalKeysExamined": , + "totalDocsExamined": , + "keysToDocsRatio": , + "usedIndex": "" + } + }, + "educationalContent": "", + "analysis": "", + "improvements": [ + { + "action": "create" | "drop" | "none" | "modify", + "indexSpec": { "": 1|-1, ... }, + "indexOptions": { }, + "indexName": "", + "mongoShell": "db.getCollection(\\"{collectionName}\\").createIndex({...}, {...})" , + "justification": "", + "priority": "high" | "medium" | "low", + "risks": "" + } + ], + "verification": [ + "", + "", + "... (one per improvement item, or empty array if no improvements)" + ] +} +\`\`\` + +Additional rules for the JSON: +- \`metadata.collectionName\` must be filled from \`{collectionStats.ns}\` or a suitable field; if not available set to \`null\`. +- \`derived.totalKeysExamined\`, \`derived.totalDocsExamined\`, and \`derived.keysToDocsRatio\` should be filled from \`executionStats\` if present, otherwise \`null\`. \`keysToDocsRatio\` = \`totalKeysExamined / max(1, totalDocsExamined)\`. +- \`educationalContent\` must be a Markdown string following the fixed template structure with five sections: **Query Execution Overview**, **Execution Stages Breakdown**, **Index Usage Analysis**, **Performance Metrics**, and **Key Findings**. Use proper markdown headings (###) and write detailed, specific explanations. For the Execution Stages Breakdown section, analyze each stage from the execution plan individually with its specific metrics. +- \`analysis\` must be a Markdown string following the fixed template structure with three sections: **Performance Summary**, **Key Issues**, and **Recommendations**. Use proper markdown headings (###) and concise, actionable content. +- \`mongoShell\` commands must **only** use double quotes and valid JS object notation. +- \`verification\` must be an **array** with the **same length as improvements**. Each element is a Markdown string containing \`\`\`javascript code blocks\`\`\` with verification commands for the corresponding improvement. If \`improvements\` is empty, \`verification\` must be \`[]\`. +`; + +export const AGGREGATE_QUERY_PROMPT_TEMPLATE = ` +You are an expert MongoDB assistant to provide index suggestions for an aggregation pipeline executed against a MongoDB collection with the following details: + +## Cluster Information +- **Is_Azure_Cluster**: {isAzureCluster} +- **Azure_Cluster_Type**: {AzureClusterType} + +## Collection Information +- **Collection_Stats**: {collectionStats} + +## Index Information of Current Collection +- **Indexes_Stats**: {indexStats} + +## Query Execution Stats +- **Execution_Stats**: {executionStats} + +Follow these strict instructions (must obey): +1. **Single JSON output only** — your response MUST be a single valid JSON object and **nothing else** (no surrounding text, no code fences, no explanation). +2. **Do not hallucinate** — only use facts present in the sections Collection_Stats, Indexes_Stats, Execution_Stats. If a required metric is absent, set the corresponding field to \`null\` in \`metadata\`. +3. **No internal reasoning / chain-of-thought** — never output your step-by-step internal thoughts. Give concise, evidence-based conclusions only. +4. **Analysis with fixed structure** — the \`analysis\` field must be a Markdown-formatted string following this exact structure: + + ### Performance Summary + [1-2 sentences summarizing the overall pipeline performance (excellent/good/poor) and primary bottleneck] + + ### Key Issues + [Bullet points listing 2-3 most critical pipeline performance problems identified, each with specific metrics from execution stats] + + ### Recommendations + [Brief bullet points listing 2-3 prioritized optimization actions, focusing on highest-impact changes] +5. **Educational content with fixed template** — the \`educationalContent\` field must be a Markdown-formatted string that follows this exact structure: + + ### Query Execution Overview + [2-3 sentences providing a high-level summary of the aggregation pipeline execution flow and strategy] + + ### Execution Stages Breakdown + [Detailed explanation of each stage in the execution plan. For each stage mentioned in executionStats, explain: + - What the stage does (e.g., $MATCH filters documents, $GROUP aggregates data, $SORT orders results, $PROJECT reshapes documents, COLLSCAN/IXSCAN for initial data access) + - Key metrics for that stage (documents examined/returned, memory usage if applicable) + - Why this stage was necessary in the pipeline + Use bullet points or numbered list for clarity. Be specific about the stage names from the actual execution plan.] + + ### Index Usage Analysis + [2-3 sentences explaining which indexes were used in early pipeline stages (if any), why they were chosen, or why a collection scan occurred. Mention the specific index name and key pattern if applicable.] + + ### Performance Metrics + [Analyze key performance indicators using bullet points: + - **Pipeline Efficiency**: [documents processed at each stage vs final results] + - **Index Effectiveness**: [how well indexes reduced the working set in early stages] + - **Blocking Operations**: [list any inefficiencies like large in-memory sorts, blocking stages, memory-intensive operations, etc.] + Keep each bullet point concise but specific with actual metrics from the execution plan.] + + ### Key Findings + [1-2 sentences summarizing the most critical performance bottlenecks or optimization opportunities identified] + +6. **Runnable shell commands** — any index changes you recommend must be provided as **mongosh/mongo shell** commands (runnable). Use \`db.getCollection("{collectionName}")\` to reference the collection (replace \`{collectionName}\` with the actual name from \`collectionStats\`). +7. **Modify operations format** — for any \`modify\` action (e.g., hiding/unhiding indexes, modifying index properties), you MUST use the \`db.getCollection('').operation()\` pattern (e.g., \`db.getCollection('users').hideIndex('index_name')\`). Do NOT use \`db.runCommand()\` format for modify actions. If the modify operation cannot be expressed in this format, set \`action\` to \`"none"\` and explain the limitation in the \`analysis\` field. +8. **Index identification for drop/modify** — for \`drop\` and \`modify\` actions, you MUST use the index **name** (e.g., \`'age_1'\`, \`'name_1_email_1'\`) rather than the index fields/specification. The \`mongoShell\` command should reference the index by name (e.g., \`db.getCollection('users').dropIndex('age_1')\` or \`db.getCollection('users').hideIndex('age_1')\`). +9. **Justify every index command** — each \`create\`/\`drop\` recommendation must include a one-sentence justification that references concrete fields/metrics from \`executionStats\` or \`indexStats\`. +10. **Prefer minimal, safe changes** — prefer a single, high-impact index over many small ones; avoid suggesting drops unless the benefit is clear and justified. +11. **Include priority** — each suggested improvement must include a \`priority\` (\`high\`/\`medium\`/\`low\`) so an engineer can triage. +12. **Priority of modify and drop actions** — priority of modify and drop actions should always be set to \`low\`. +13. **Be explicit about risks** — if a suggested index could increase write cost or large index size, include that as a short risk note in the improvement. +14. **Verification array requirement** — the \`verification\` field must be an **array** with **exactly one verification item per improvement item**. Each verification item must be a Markdown string containing \`\`\`javascript code blocks\`\`\` with valid mongosh commands to verify that specific improvement. If \`improvements\` is an empty array, \`verification\` must also be an empty array. +15. **Do not change input objects** — echo input objects only under \`metadata\`; do not mutate \`{collectionStats}\`, \`{indexStats}\`, or \`{executionStats}\`—just include them as-is (and add computed helper fields if needed). +16. **Be brave to say no** — if you confirm an index change is not beneficial, or not relates to the query, feel free to return empty improvements. +17. **Limited confidence** — if the Indexes_Stats or Collection_Stats is not available ('N/A'), add the following sentence as the first line in your analysis: "Note: Limited confidence in recommendations due to missing optional statistics.\n" + +Thinking / analysis tips (for your reasoning; do not output these tips): +- **\\$match priority**: Place match stages early and check if indexes can accelerate filtering. +- **\\$sort optimization**: Match sort order to index order to avoid blocking in-memory sorts. +- **\\$group / \\$project coverage**: Check if fields used in group or project stages are covered by indexes for potential index-only plans. +- **\\$lookup / \\$unwind**: Evaluate whether join or array-unwind stages can benefit from supporting indexes. +- **Multi-branch match**: For \\$or or \\$in conditions, consider compound indexes or index intersection. +- **Multikey / sparse / partial indexes**: Ensure indexes on array or sparse fields still support coverage without excessive size or write amplification. +- **Index size and write cost**: Avoid high-cardinality indexes that rarely match queries; prefer selective prefixes or partial indexes. +- **Projection coverage**: If all projected fields are indexed, prioritize index-only scan opportunities. +- If you identify indexes related to the query that have **not been accessed for a long time** or **are not selective**, consider recommending **dropping** them to reduce write and storage overhead. +- **Small collection**: If you identify query is on a **small collection** (e.g., <1000 documents), do not recommend creating new indexes. +- **Vector recall rule** — If the **Azure_Cluster_Type** is "vCore" and uses a cosmosSearch with index has \`"kind": "vector-ivf"\`, but the collection contains many documents (over 10k) or the vector dimensionality is high, recommend replacing it with a \`vector-hnsw\` index for better recall and retrieval quality. The recommended creation command format is: + { + "createIndexes": "", + "indexes": [ + { + "name": "", + "key": { + "": "cosmosSearch" + }, + "cosmosSearchOptions": { + "kind": "vector-hnsw", + "m": , + "efConstruction": , + "similarity": "", + "dimensions": + } + } + ] + } + +Output JSON schema (required shape; adhere exactly): +\`\`\` +{ + "metadata": { + "collectionName": "", + "collectionStats": { ... }, + "indexStats": [ ... ], + "executionStats": { ... }, + "derived": { + "totalKeysExamined": , + "totalDocsExamined": , + "keysToDocsRatio": , + "usedIndex": "" + } + }, + "educationalContent": "", + "analysis": "", + "improvements": [ + { + "action": "create" | "drop" | "none" | "modify", + "indexSpec": { "": 1|-1, ... }, + "indexOptions": { }, + "indexName": "", + "mongoShell": "db.getCollection(\\"{collectionName}\\").createIndex({...}, {...})" , + "justification": "", + "priority": "high" | "medium" | "low", + "risks": "" + } + ], + "verification": [ + "", + "", + "... (one per improvement item, or empty array if no improvements)" + ] +} +\`\`\` +Additional rules for the JSON: +- \`metadata.collectionName\` must be filled from \`{collectionStats.ns}\` or a suitable field; if not available set to \`null\`. +- \`derived.totalKeysExamined\`, \`derived.totalDocsExamined\`, and \`derived.keysToDocsRatio\` should be filled from \`executionStats\` if present, otherwise \`null\`. \`keysToDocsRatio\` = \`totalKeysExamined / max(1, totalDocsExamined)\`. +- \`educationalContent\` must be a Markdown string following the fixed template structure with five sections: **Query Execution Overview**, **Execution Stages Breakdown**, **Index Usage Analysis**, **Performance Metrics**, and **Key Findings**. Use proper markdown headings (###) and write detailed, specific explanations. For the Execution Stages Breakdown section, analyze each pipeline stage from the execution plan individually with its specific metrics and purpose. +- \`analysis\` must be a Markdown string following the fixed template structure with three sections: **Performance Summary**, **Key Issues**, and **Recommendations**. Use proper markdown headings (###) and concise, actionable content. +- \`mongoShell\` commands must **only** use double quotes and valid JS object notation. +- \`verification\` must be an **array** with the **same length as improvements**. Each element is a Markdown string containing \`\`\`javascript code blocks\`\`\` with verification commands for the corresponding improvement. If \`improvements\` is empty, \`verification\` must be \`[]\`. +`; + +export const COUNT_QUERY_PROMPT_TEMPLATE = ` +You are an expert MongoDB assistant to provide index suggestions for the following count query: +- **Query**: {query} +The query is executed against a MongoDB collection with the following details: +## Cluster Information +- **Is_Azure_Cluster**: {isAzureCluster} +- **Azure_Cluster_Type**: {AzureClusterType} +## Collection Information +- **Collection_Stats**: {collectionStats} +## Index Information of Current Collection +- **Indexes_Stats**: {indexStats} +## Query Execution Stats +- **Execution_Stats**: {executionStats} +## Cluster Information +- **Cluster_Type**: {clusterType} // e.g., "Azure DocumentDB", "Atlas", "Self-managed" +Follow these strict instructions (must obey): +1. **Single JSON output only** — your response MUST be a single valid JSON object and **nothing else** (no surrounding text, no code fences, no explanation). +2. **Do not hallucinate** — only use facts present in the sections Query, Collection_Stats, Indexes_Stats, Execution_Stats, Cluster_Type. If a required metric is absent, set the corresponding field to \`null\` in \`metadata\`. +3. **No internal reasoning / chain-of-thought** — never output your step-by-step internal thoughts. Give concise, evidence-based conclusions only. +4. **Analysis with fixed structure** — the \`analysis\` field must be a Markdown-formatted string following this exact structure: + + ### Performance Summary + [1-2 sentences summarizing the overall count operation performance (excellent/good/poor) and primary bottleneck] + + ### Key Issues + [Bullet points listing 2-3 most critical count performance problems identified, each with specific metrics from execution stats] + + ### Recommendations + [Brief bullet points listing 2-3 prioritized optimization actions, focusing on highest-impact changes] +5. **Educational content with fixed template** — the \`educationalContent\` field must be a Markdown-formatted string that follows this exact structure: + + ### Query Execution Overview + [2-3 sentences providing a high-level summary of the count operation execution flow and strategy] + + ### Execution Stages Breakdown + [Detailed explanation of each stage in the execution plan. For each stage mentioned in executionStats, explain: + - What the stage does (e.g., COUNT_SCAN uses index for counting, COLLSCAN scans all documents, IXSCAN uses index scan, FETCH retrieves documents) + - Key metrics for that stage (documents/keys examined, count result) + - Why this stage was necessary for the count operation + Use bullet points or numbered list for clarity. Be specific about the stage names from the actual execution plan.] + + ### Index Usage Analysis + [2-3 sentences explaining which indexes were used for the count operation (if any), why they were chosen, or why a collection scan occurred. Mention the specific index name and key pattern if applicable. Note whether the count could be satisfied by index-only scan.] + + ### Performance Metrics + [Analyze key performance indicators using bullet points: + - **Documents Examined**: [total number examined for the count operation] + - **Index-Only Count**: [whether count was satisfied without fetching documents] + - **Operation Efficiency**: [ratio of documents examined vs collection size, scan type used] + Keep each bullet point concise but specific with actual metrics from the execution plan.] + + ### Key Findings + [1-2 sentences summarizing the most critical performance bottlenecks or optimization opportunities identified] + +6. **Runnable shell commands** — any index changes you recommend must be provided as **mongosh/mongo shell** commands (runnable). Use \`db.getCollection("{collectionName}")\` to reference the collection (replace \`{collectionName}\` with the actual name from \`collectionStats\`). +7. **Modify operations format** — for any \`modify\` action (e.g., hiding/unhiding indexes, modifying index properties), you MUST use the \`db.getCollection('').operation()\` pattern (e.g., \`db.getCollection('users').hideIndex('index_name')\`). Do NOT use \`db.runCommand()\` format for modify actions. If the modify operation cannot be expressed in this format, set \`action\` to \`"none"\` and explain the limitation in the \`analysis\` field. +8. **Index identification for drop/modify** — for \`drop\` and \`modify\` actions, you MUST use the index **name** (e.g., \`'age_1'\`, \`'name_1_email_1'\`) rather than the index fields/specification. The \`mongoShell\` command should reference the index by name (e.g., \`db.getCollection('users').dropIndex('age_1')\` or \`db.getCollection('users').hideIndex('age_1')\`). +9. **Justify every index command** — each \`create\`/\`drop\` recommendation must include a one-sentence justification that references concrete fields/metrics from \`executionStats\` or \`indexStats\`. +10. **Prefer minimal, safe changes** — prefer a single, high-impact index over many small ones; avoid suggesting drops unless the benefit is clear and justified. +11. **Include priority** — each suggested improvement must include a \`priority\` (\`high\`/\`medium\`/\`low\`) so an engineer can triage. +12. **Priority of modify and drop actions** — priority of modify and drop actions should always be set to \`low\`. +13. **Be explicit about risks** — if a suggested index could increase write cost or large index size, include that as a short risk note in the improvement. +14. **Verification array requirement** — the \`verification\` field must be an **array** with **exactly one verification item per improvement item**. Each verification item must be a Markdown string containing \`\`\`javascript code blocks\`\`\` with valid mongosh commands to verify that specific improvement. If \`improvements\` is an empty array, \`verification\` must also be an empty array. +15. **Do not change input objects** — echo input objects only under \`metadata\`; do not mutate \`{collectionStats}\`, \`{indexStats}\`, or \`{executionStats}\`—just include them as-is (and add computed helper fields if needed). +16. **Be brave to say no** — if you confirm an index change is not beneficial, or not relates to the query, feel free to return empty improvements. +17. **Limited confidence** — if the Indexes_Stats or Collection_Stats is not available ('N/A'), add the following sentence as the first line in your analysis: "Note: Limited confidence in recommendations due to missing optional statistics.\n" + +Thinking / analysis tips (for your reasoning; do not output these tips): +- **Index-only optimization**: The best count performance occurs when all filter fields are indexed, allowing a covered query that avoids document fetches entirely. +- **Filter coverage**: Ensure all equality and range predicates in the count query are covered by an index; if not, suggest a compound index with equality fields first, range fields last. +- **COLLSCAN detection**: If totalDocsExamined is close to collection document count and no index is used, a full collection scan occurred — propose an index that minimizes this. +- **Sparse and partial indexes**: If the query filters on a field that only exists in some documents, consider a sparse or partial index to reduce index size and scan scope. +- **Equality and range ordering**: For compound indexes, equality filters should precede range filters for optimal selectivity. +- **Index-only count**: If projected or returned fields are all indexed (e.g., just counting documents matching criteria), prefer a covered plan for index-only count. +- **Write cost tradeoff**: Avoid over-indexing — recommend only indexes that materially improve count query performance or prevent full collection scans. +- If you identify indexes related to the query that have **not been accessed for a long time** or **are not selective**, consider recommending **dropping** them to reduce write and storage overhead. +- - **Small collection**: If you identify query is on a **small collection** (e.g., <1000 documents), do not recommend creating new indexes. +- If the **Azure_Cluster_Type** is "vCore" and a **composite index** is being created, include in \`indexOptions\` the setting: \`"storageEngine": { "enableOrderedIndex": true }\`. +Output JSON schema (required shape; adhere exactly): +\`\`\` +{ + "metadata": { + "collectionName": "", + "collectionStats": { ... }, + "indexStats": [ ... ], + "executionStats": { ... }, + "derived": { + "totalKeysExamined": , + "totalDocsExamined": , + "keysToDocsRatio": , + "usedIndex": "" + } + }, + "educationalContent": "", + "analysis": "", + "improvements": [ + { + "action": "create" | "drop" | "none" | "modify", + "indexSpec": { "": 1|-1, ... }, + "indexOptions": { }, + "indexName": "", + "mongoShell": "db.getCollection(\\"{collectionName}\\").createIndex({...}, {...})" , + "justification": "", + "priority": "high" | "medium" | "low", + "risks": "" + } + ], + "verification": [ + "", + "", + "... (one per improvement item, or empty array if no improvements)" + ] +} +\`\`\` +Additional rules for the JSON: +- \`metadata.collectionName\` must be filled from \`{collectionStats.ns}\` or a suitable field; if not available set to \`null\`. +- \`derived.totalKeysExamined\`, \`derived.totalDocsExamined\`, and \`derived.keysToDocsRatio\` should be filled from \`executionStats\` if present, otherwise \`null\`. \`keysToDocsRatio\` = \`totalKeysExamined / max(1, totalDocsExamined)\`. +- \`educationalContent\` must be a Markdown string following the fixed template structure with five sections: **Query Execution Overview**, **Execution Stages Breakdown**, **Index Usage Analysis**, **Performance Metrics**, and **Key Findings**. Use proper markdown headings (###) and write detailed, specific explanations. For the Execution Stages Breakdown section, analyze each stage from the execution plan individually with its specific metrics and purpose in the count operation. +- \`analysis\` must be a Markdown string following the fixed template structure with three sections: **Performance Summary**, **Key Issues**, and **Recommendations**. Use proper markdown headings (###) and concise, actionable content. +- \`mongoShell\` commands must **only** use double quotes and valid JS object notation. +- \`verification\` must be an **array** with the **same length as improvements**. Each element is a Markdown string containing \`\`\`javascript code blocks\`\`\` with verification commands for the corresponding improvement. If \`improvements\` is empty, \`verification\` must be \`[]\`. +`; + +export const CROSS_COLLECTION_QUERY_PROMPT_TEMPLATE = ` +You are an expert MongoDB assistant. Generate a MongoDB query based on the user's natural language request. +## Database Context +- **Database Name**: {databaseName} +- **User Request**: {naturalLanguageQuery} +## Available Collections and Their Schemas +{schemaInfo} + +## Query Type Requirement +- **Required Query Type**: {targetQueryType} +- You MUST generate a query of this exact type. Do not use other query types even if they might seem more appropriate. + +## Instructions +1. **Single JSON output only** — your response MUST be a single valid JSON object matching the schema below. No code fences, no surrounding text. +2. **MongoDB shell commands** — all queries must be valid MongoDB shell commands (mongosh) that can be executed directly, not javaScript functions or pseudo-code. +3. **Strict query type adherence** — you MUST generate a **{targetQueryType}** query as specified above. Ignore this requirement only if the user explicitly requests a different query type. +4. **Cross-collection queries** — the user has NOT specified a collection name, so you may need to generate queries that work across multiple collections. Consider using: + - Multiple separate queries (one per collection) if the request is collection-specific + - Aggregation pipelines with $lookup if joining data from multiple collections + - Union operations if combining results from different collections +5. **Use schema information** — examine the provided schemas to understand the data structure and field types in each collection. +6. **Respect data types** — use appropriate MongoDB operators based on the field types shown in the schema. +7. **Handle nested objects** — when you see \`type: "object"\` with \`properties\`, those are nested fields accessible with dot notation. +8. **Handle arrays** — when you see \`type: "array"\` with \`items\`, use appropriate array operators. If \`vectorLength\` is present, that's a fixed-size numeric array. +9. **Generate runnable queries** — output valid MongoDB shell syntax (mongosh) that can be executed directly. +10. **Provide clear explanation** — explain which collection(s) you're querying and why, and describe the query logic. +11. **Use db. syntax** — reference collections using \`db.collectionName\` or \`db.getCollection("collectionName")\` format. +12. **Prefer simple queries** — start with the simplest query that meets the user's needs; avoid over-complication. +13. **Consider performance** — if multiple approaches are possible, prefer the one that's more likely to be efficient. +## Query Generation Guidelines for {targetQueryType} +{queryTypeGuidelines} + +## Output JSON Schema +{outputSchema} + +## Examples +User request: "Find all users who signed up in the last 7 days" +\`\`\`json +{ + "explanation": "This query searches the 'users' collection for documents where the createdAt field is greater than or equal to 7 days ago. It uses the $gte operator to filter dates.", + "command": { + "filter": "{ \\"createdAt\\": { \\"$gte\\": { \\"$date\\": \\"<7_days_ago_ISO_string>\\" } } }", + "project": "{}", + "sort": "{}", + "skip": 0, + "limit": 0 + } +} +\`\`\` +User request: "Get total revenue by product category" +\`\`\`json +{ + "explanation": "This aggregation pipeline joins orders with products using $lookup, unwinds the product array, groups by product category, and calculates the sum of order amounts for each category, sorted by revenue descending.", + "command": { + "pipeline": "[{ \\"$lookup\\": { \\"from\\": \\"products\\", \\"localField\\": \\"productId\\", \\"foreignField\\": \\"_id\\", \\"as\\": \\"product\\" } }, { \\"$unwind\\": \\"$product\\" }, { \\"$group\\": { \\"_id\\": \\"$product.category\\", \\"totalRevenue\\": { \\"$sum\\": \\"$amount\\" } } }, { \\"$sort\\": { \\"totalRevenue\\": -1 } }]" + } +} +\`\`\` +Now generate the query based on the user's request and the provided schema information. +`; + +export const SINGLE_COLLECTION_QUERY_PROMPT_TEMPLATE = ` +You are an expert MongoDB assistant. Generate a MongoDB query based on the user's natural language request. +## Database Context +- **Database Name**: {databaseName} +- **Collection Name**: {collectionName} +- **User Request**: {naturalLanguageQuery} +## Collection Schema +{schemaInfo} +## Query Type Requirement +- **Required Query Type**: {targetQueryType} +- You MUST generate a query of this exact type. Do not use other query types even if they might seem more appropriate. + +## Instructions +1. **Single JSON output only** — your response MUST be a single valid JSON object matching the schema below. No code fences, no surrounding text. +2. **MongoDB shell commands** — all queries must be valid MongoDB shell commands (mongosh) that can be executed directly, not javaScript functions or pseudo-code. +3. **Strict query type adherence** — you MUST generate a **{targetQueryType}** query as specified above. +4. **One-sentence query** — your response must be a single, concise query that directly addresses the user's request. +5. **Return error** — When query generation is not possible (e.g., the input is invalid, contradictory, unrelated to the data schema, or incompatible with the expected query type), output an error message starts with \`Error:\` in the explanation field and \`null\` as command. +6. **Single-collection query** — the user has specified a collection name, so generate a query that works on this collection only. +7. **Use schema information** — examine the provided schema to understand the data structure and field types. +8. **Respect data types** — use appropriate MongoDB operators based on the field types shown in the schema. +9. **Handle nested objects** — when you see \`type: "object"\` with \`properties\`, those are nested fields accessible with dot notation (e.g., \`address.city\`). +10. **Handle arrays** — when you see \`type: "array"\` with \`items\`, use appropriate array operators like $elemMatch, $size, $all, etc. If \`vectorLength\` is present, that's a fixed-size numeric array (vector/embedding). +11. **Handle unions** — when you see \`type: "union"\` with \`variants\`, the field can be any of those types (handle null cases appropriately). +12. **Generate runnable queries** — output valid MongoDB shell syntax (mongosh) that can be executed directly on the specified collection. +13. **Provide clear explanation** — describe what the query does and the operators/logic used. +14. **Use db.{collectionName} syntax** — reference the collection using \`db.{collectionName}\` or \`db.getCollection("{collectionName}")\` format. +15. **Prefer simple queries** — start with the simplest query that meets the user's needs; avoid over-complication. +16. **Consider performance** — if multiple approaches are possible, prefer the one that's more likely to use indexes efficiently. +## Query Generation Guidelines for {targetQueryType} +{queryTypeGuidelines} + +## Common MongoDB Operators Reference +- **Comparison**: $eq, $ne, $gt, $gte, $lt, $lte, $in, $nin +- **Logical**: $and, $or, $not, $nor +- **Element**: $exists, $type +- **Array**: $elemMatch, $size, $all +- **Evaluation**: $regex, $text, $where, $expr +- **Aggregation**: $match, $group, $project, $sort, $limit, $lookup, $unwind +## Output JSON Schema +{outputSchema} + +## Examples +User request: "Find all documents where price is greater than 100" +\`\`\`json +{ + "explanation": "This query filters documents where the price field is greater than 100 using the $gt comparison operator.", + "command": { + "filter": "{ \\"price\\": { \\"$gt\\": 100 } }", + "project": "{}", + "sort": "{}", + "skip": 0, + "limit": 0 + } +} +\`\`\` +User request: "Get the average rating grouped by category" +\`\`\`json +{ + "explanation": "This aggregation pipeline groups documents by the category field, calculates the average rating for each group using $avg, and sorts the results by average rating in descending order.", + "command": { + "pipeline": "[{ \\"$group\\": { \\"_id\\": \\"$category\\", \\"avgRating\\": { \\"$avg\\": \\"$rating\\" } } }, { \\"$sort\\": { \\"avgRating\\": -1 } }]" + } +} +\`\`\` +User request: "Find documents with tags array containing 'featured' and status is 'active', sorted by createdAt, limit 10" +\`\`\`json +{ + "explanation": "This query finds documents where the tags array contains 'featured' and the status field equals 'active'. MongoDB's default array behavior matches any element in the array. Results are sorted by createdAt in descending order and limited to 10 documents.", + "command": { + "filter": "{ \\"tags\\": \\"featured\\", \\"status\\": \\"active\\" }", + "project": "{}", + "sort": "{ \\"createdAt\\": -1 }", + "skip": 0, + "limit": 10 + } +} +\`\`\` +Now generate the query based on the user's request and the provided collection schema. +`; + +/** + * Gets query type specific configuration (guidelines and output schema) + * @param queryType The type of query + * @returns Configuration object with guidelines and outputSchema + */ +export function getQueryTypeConfig(queryType: string): { guidelines: string; outputSchema: string } { + switch (queryType) { + case 'Find': + return { + guidelines: `- Generate a find query with appropriate filters, projections, sort, skip, and limit +- Use MongoDB query operators for filtering: $eq, $ne, $gt, $gte, $lt, $lte, $in, $nin, $regex, etc. +- Use projection to specify which fields to include or exclude +- Use sort to order results (1 for ascending, -1 for descending) +- Use skip and limit for pagination +- All fields should be valid JSON strings except skip and limit which are numbers`, + outputSchema: `\`\`\`json +{ + "explanation": "", + "command": { + "filter": "", + "project": "", + "sort": "", + "skip": , + "limit": + } +} +\`\`\``, + }; + + case 'Aggregation': + return { + guidelines: `- Generate an aggregation pipeline with appropriate stages +- Common stages: $match (filtering), $group (grouping/aggregation), $project (field selection/transformation) +- Use $sort and $limit for ordering and limiting results +- Use $lookup for joining with other collections +- Use $unwind for array expansion +- Prefer $match early in the pipeline for performance +- Pipeline should be a JSON string representing an array of stages`, + outputSchema: `\`\`\`json +{ + "explanation": "", + "command": { + "pipeline": "" + } +} +\`\`\``, + }; + + case 'Count': + return { + guidelines: `- Generate a count query with appropriate filter +- Use MongoDB query operators for filtering +- Filter should be a valid JSON string`, + outputSchema: `\`\`\`json +{ + "explanation": "", + "command": { + "filter": "" + } +} +\`\`\``, + }; + + case 'Update': + return { + guidelines: `- Generate an update query with filter and update operations +- Use update operators: $set (set field), $inc (increment), $push (add to array), $pull (remove from array) +- Specify whether to update one or many documents +- Use options like upsert if needed +- All fields should be valid JSON strings`, + outputSchema: `\`\`\`json +{ + "explanation": "", + "command": { + "filter": "", + "update": "", + "options": "" + } +} +\`\`\``, + }; + + case 'Delete': + return { + guidelines: `- Generate a delete query with appropriate filter +- Be careful with filters to avoid unintended deletions +- Filter should be a valid JSON string`, + outputSchema: `\`\`\`json +{ + "explanation": "", + "command": { + "filter": "" + } +} +\`\`\``, + }; + + default: + throw new Error(l10n.t('Unsupported query type: {queryType}', { queryType })); + } +} diff --git a/src/commands/llmEnhancedCommands/queryGenerationCommands.ts b/src/commands/llmEnhancedCommands/queryGenerationCommands.ts new file mode 100644 index 000000000..c89044aa8 --- /dev/null +++ b/src/commands/llmEnhancedCommands/queryGenerationCommands.ts @@ -0,0 +1,288 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { type Document } from 'mongodb'; +import * as vscode from 'vscode'; +import { ClustersClient } from '../../documentdb/ClustersClient'; +import { ext } from '../../extensionVariables'; +import { CopilotService } from '../../services/copilotService'; +import { PromptTemplateService } from '../../services/promptTemplateService'; +import { generateSchemaDefinition, type SchemaDefinition } from '../../utils/schemaInference'; +import { FALLBACK_MODELS, PREFERRED_MODEL, getQueryTypeConfig } from './promptTemplates'; + +/** + * Type of query generation + */ +export enum QueryGenerationType { + CrossCollection = 'cross-collection', + SingleCollection = 'single-collection', +} + +/** + * Context information needed for query generation + */ +export interface QueryGenerationContext { + // The cluster/connection ID + clusterId: string; + // Database name + databaseName: string; + // Collection name (only for single-collection queries) + collectionName?: string; + // Query type of generated query + targetQueryType?: 'Find' | 'Aggregation'; + // Natural language description of the query + naturalLanguageQuery: string; + // The type of query generation + generationType: QueryGenerationType; +} + +/** + * Schema information for a collection + */ +export interface CollectionSchema { + // Collection name + collectionName: string; + // Sample documents with schema information + sampleDocuments: Array; + // Inferred schema structure + schemaDescription: string; +} + +/** + * Result from query generation + */ +export interface QueryGenerationResult { + // The generated query + generatedQuery: string; + // Explanation of the query + explanation: string; + // The model used to generate the query + modelUsed: string; +} + +/** + * Gets the prompt template for a given query generation type + * @param generationType The type of query generation + * @returns The prompt template string + */ +async function getPromptTemplate(generationType: QueryGenerationType): Promise { + return PromptTemplateService.getQueryGenerationPromptTemplate(generationType); +} + +/** + * Fills a prompt template with actual data + * @param templateType The type of template to use + * @param context The query generation context + * @param schemas Collection schemas + * @returns The filled prompt template + */ +async function fillPromptTemplate( + templateType: QueryGenerationType, + context: QueryGenerationContext, + schemas: Array, +): Promise { + // Get the template for this generation type + const template = await getPromptTemplate(templateType); + + // Determine target query type (default to Find if not specified) + const targetQueryType = context.targetQueryType || 'Find'; + + // Get query type specific guidelines and output schema + const { guidelines, outputSchema } = getQueryTypeConfig(targetQueryType); + + // Prepare schema information + let schemaInfo: string; + if (schemas.length > 0) { + if (templateType === QueryGenerationType.CrossCollection) { + schemaInfo = schemas + .map( + (schema) => + `### Collection: ${schema.collectionName || 'Unknown'}\n\nData Schema:\n\`\`\`json\n${JSON.stringify(schema.fields, null, 2)}\n\`\`\``, + ) + .join('\n\n---\n\n'); + } else { + const schema = schemas[0]; + schemaInfo = `Data Schema:\n\`\`\`json\n${JSON.stringify(schema.fields, null, 2)}\n\`\`\`\n\n`; + } + } else { + schemaInfo = `No schema information available.\n\n`; + } + + const filled = template + .replace('{databaseName}', context.databaseName) + .replace('{collectionName}', context.collectionName || 'N/A') + .replace(/{targetQueryType}/g, targetQueryType) + .replace('{queryTypeGuidelines}', guidelines) + .replace('{outputSchema}', outputSchema) + .replace('{schemaInfo}', schemaInfo) + .replace('{naturalLanguageQuery}', context.naturalLanguageQuery); + + return filled; +} + +/** + * Generates a MongoDB query based on natural language input + * @param context Action context for telemetry + * @param queryContext Query generation context + * @returns Generated query and explanation + */ +export async function generateQuery( + context: IActionContext, + queryContext: QueryGenerationContext, +): Promise { + ext.outputChannel.trace( + l10n.t('[Query Generation] Started: type={type}, targetQueryType={targetQueryType}', { + type: queryContext.generationType, + targetQueryType: queryContext.targetQueryType || 'Find', + }), + ); + + // Check if Copilot is available + const copilotAvailable = await CopilotService.isAvailable(); + if (!copilotAvailable) { + throw new Error( + l10n.t( + 'GitHub Copilot is not available. Please install the GitHub Copilot extension and ensure you have an active subscription.', + ), + ); + } + + // Get the MongoDB client + const getClientStart = Date.now(); + const client = await ClustersClient.getClient(queryContext.clusterId); + context.telemetry.measurements.getClientDurationMs = Date.now() - getClientStart; + + // Gather schema information + const schemas: Array = []; + const schemaGatheringStart = Date.now(); + + try { + if (queryContext.generationType === QueryGenerationType.CrossCollection) { + // Get all collections in the database + const listCollectionsStart = Date.now(); + const collections = await client.listCollections(queryContext.databaseName); + context.telemetry.measurements.listCollectionsDurationMs = Date.now() - listCollectionsStart; + ext.outputChannel.trace( + l10n.t('[Query Generation] listCollections completed in {ms}ms ({count} collections)', { + ms: context.telemetry.measurements.listCollectionsDurationMs.toString(), + count: collections.length.toString(), + }), + ); + + let collectionIndex = 0; + for (const collection of collections) { + collectionIndex++; + const sampleDocsStart = Date.now(); + const sampleDocs = await client.getSampleDocuments(queryContext.databaseName, collection.name, 3); + const sampleDocsDuration = Date.now() - sampleDocsStart; + context.telemetry.measurements[`sampleDocs_${collectionIndex}_DurationMs`] = sampleDocsDuration; + ext.outputChannel.trace( + l10n.t('[Query Generation] Schema sampling for {collection} completed in {ms}ms', { + collection: collection.name, + ms: sampleDocsDuration.toString(), + }), + ); + + if (sampleDocs.length > 0) { + const schema = generateSchemaDefinition(sampleDocs, collection.name); + schemas.push(schema); + } else { + schemas.push({ collectionName: collection.name, fields: {} }); + } + } + } else { + if (!queryContext.collectionName) { + throw new Error(l10n.t('Collection name is required for single-collection query generation')); + } + + const sampleDocsStart = Date.now(); + const sampleDocs = await client.getSampleDocuments( + queryContext.databaseName, + queryContext.collectionName, + 10, + ); + context.telemetry.measurements.sampleDocsDurationMs = Date.now() - sampleDocsStart; + ext.outputChannel.trace( + l10n.t('[Query Generation] Schema sampling completed in {ms}ms', { + ms: context.telemetry.measurements.sampleDocsDurationMs.toString(), + }), + ); + + const schema = generateSchemaDefinition(sampleDocs, queryContext.collectionName); + schemas.push(schema); + } + context.telemetry.measurements.schemaGatheringDurationMs = Date.now() - schemaGatheringStart; + } catch (error) { + context.telemetry.measurements.schemaGatheringDurationMs = Date.now() - schemaGatheringStart; + throw new Error( + l10n.t('Failed to gather schema information: {message}', { + message: error instanceof Error ? error.message : String(error), + }), + ); + } + + // Fill the prompt template + const promptContent = await fillPromptTemplate(queryContext.generationType, queryContext, schemas); + + // Send to Copilot with configured models + const llmCallStart = Date.now(); + ext.outputChannel.trace( + l10n.t('[Query Generation] Calling Copilot (model: {model})...', { + model: PREFERRED_MODEL || 'default', + }), + ); + const response = await CopilotService.sendMessage([vscode.LanguageModelChatMessage.User(promptContent)], { + preferredModel: PREFERRED_MODEL, + fallbackModels: FALLBACK_MODELS, + }); + context.telemetry.measurements.llmCallDurationMs = Date.now() - llmCallStart; + ext.outputChannel.trace( + l10n.t('[Query Generation] Copilot response received in {ms}ms (model: {model})', { + ms: context.telemetry.measurements.llmCallDurationMs.toString(), + model: response.modelUsed, + }), + ); + + // Check if the preferred model was used + if (response.modelUsed !== PREFERRED_MODEL && PREFERRED_MODEL) { + // Show warning if not using preferred model + void vscode.window.showWarningMessage( + l10n.t( + 'Query generation is using model "{actualModel}" instead of preferred "{preferredModel}". Results may vary.', + { + actualModel: response.modelUsed, + preferredModel: PREFERRED_MODEL, + }, + ), + ); + } + + // Add telemetry for the model used + context.telemetry.properties.modelUsed = response.modelUsed; + context.telemetry.properties.generationType = queryContext.targetQueryType || 'Find'; + + // Parse the response + try { + const result = JSON.parse(response.text) as { explanation: string; command: Record }; + if (result.command === undefined || result.command === null || result.explanation.startsWith('Error:')) { + throw new Error(result.explanation); + } + + ext.outputChannel.trace(l10n.t('[Query Generation] Completed successfully')); + return { + generatedQuery: JSON.stringify(result.command, null, 2), + explanation: result.explanation, + modelUsed: response.modelUsed, + }; + } catch { + throw new Error( + l10n.t('Failed to parse the response from the language model. LLM output:\n{output}', { + output: response.text, + }), + ); + } +} diff --git a/src/documentdb/ClusterSession.ts b/src/documentdb/ClusterSession.ts index 88113d8ff..2eb9a2d63 100644 --- a/src/documentdb/ClusterSession.ts +++ b/src/documentdb/ClusterSession.ts @@ -5,12 +5,13 @@ import * as l10n from '@vscode/l10n'; import { EJSON } from 'bson'; -import { ObjectId, type Document, type WithId } from 'mongodb'; +import { ObjectId, type Document, type Filter, type WithId } from 'mongodb'; import { type JSONSchema } from '../utils/json/JSONSchema'; import { getPropertyNamesAtLevel, updateSchemaWithDocument } from '../utils/json/mongo/SchemaAnalyzer'; import { getDataAtPath } from '../utils/slickgrid/mongo/toSlickGridTable'; import { toSlickGridTree, type TreeData } from '../utils/slickgrid/mongo/toSlickGridTree'; -import { ClustersClient } from './ClustersClient'; +import { ClustersClient, type FindQueryParams } from './ClustersClient'; +import { toFilterQueryObj } from './utils/toFilterQuery'; export type TableDataEntry = { /** @@ -34,6 +35,28 @@ export interface TableData { data: TableDataEntry[]; } +/** + * Parsed query parameters with BSON objects + * This extends FindQueryParams by providing parsed Document objects + * in addition to the string representations + */ +export interface ParsedFindQueryParams extends FindQueryParams { + /** + * Parsed filter object with BSON types (UUID, Date, etc.) properly converted + */ + filterObj: Filter; + + /** + * Parsed projection object, or undefined if no projection + */ + projectionObj?: Document; + + /** + * Parsed sort object, or undefined if no sort + */ + sortObj?: Document; +} + export class ClusterSession { // cache of active/existing sessions static _sessions: Map = new Map(); @@ -51,54 +74,233 @@ export class ClusterSession { } /** - * Tracks the known JSON schema for the current query - * and updates it with everything we see until the query text changes. + * Accumulated JSON schema across all pages seen for the current query. + * Updates progressively as users navigate through different pages. + * Reset when the query or page size changes. + */ + private _accumulatedJsonSchema: JSONSchema = {}; + + /** + * Tracks the highest page number that has been accumulated into the schema. + * Users navigate sequentially starting from page 1, so any page ≤ this value + * has already been accumulated and should be skipped. + * Reset when the query or page size changes. + */ + private _highestPageAccumulated: number = 0; + + /** + * Stores the user's original query parameters (filter, project, sort, skip, limit). + * This represents what the user actually queried for, independent of pagination. + * Used for returning query info to consumers via getCurrentFindQueryParams(). + */ + private _currentUserQueryParams: FindQueryParams | null = null; + + /** + * The page size used for the current accumulation strategy. + * If this changes, we need to reset accumulated data since page boundaries differ. + */ + private _currentPageSize: number | null = null; + + /** + * Raw documents from the most recently fetched page. + * This is NOT accumulated - it only contains the current page's data. */ - private _currentJsonSchema: JSONSchema = {}; - private _currentQueryText: string = ''; private _currentRawDocuments: WithId[] = []; /** - * This is a basic approach for now, we can improve this later. - * It's important to react to an updated query and to invalidate local caches if the query has changed. - * @param query - * @returns + * Query Insights caching + * Note: QueryInsightsApis instance is accessed via this._client.queryInsightsApis + * + * Timestamps are included for potential future features: + * - Time-based cache invalidation (e.g., expire after N seconds) + * - Diagnostics (show when explain was collected) + * - Performance monitoring + * + * Currently, cache invalidation is purely query-based via resetCachesIfQueryChanged() */ - private resetCachesIfQueryChanged(query: string) { - if (this._currentQueryText.localeCompare(query.trim(), undefined, { sensitivity: 'base' }) === 0) { - return; + private _queryPlannerCache?: { result: Document; timestamp: number }; + private _executionStatsCache?: { result: Document; timestamp: number }; + private _aiRecommendationsCache?: { result: unknown; timestamp: number }; + + /** + * Last query execution time in milliseconds + * Measured server-side during runFindQueryWithCache execution + */ + private _lastExecutionTimeMs?: number; + + /** + * Resets internal caches when the user's query changes. + * Only compares the semantic query parameters (filter, project, sort) + * and the user's original skip/limit, NOT the pagination-derived skip/limit. + * + * @param userQueryParams - The user's original query parameters + */ + private resetCachesIfUserQueryChanged(userQueryParams: FindQueryParams): void { + // Create a stable key from user's query params (not pagination) + const userQueryKey = JSON.stringify({ + filter: userQueryParams.filter || '{}', + project: userQueryParams.project || '{}', + sort: userQueryParams.sort || '{}', + skip: userQueryParams.skip ?? 0, + limit: userQueryParams.limit ?? 0, + }); + + // Check if this is the same query as before + if (this._currentUserQueryParams) { + const previousQueryKey = JSON.stringify({ + filter: this._currentUserQueryParams.filter || '{}', + project: this._currentUserQueryParams.project || '{}', + sort: this._currentUserQueryParams.sort || '{}', + skip: this._currentUserQueryParams.skip ?? 0, + limit: this._currentUserQueryParams.limit ?? 0, + }); + + if (previousQueryKey.localeCompare(userQueryKey, undefined, { sensitivity: 'base' }) === 0) { + // Same query, no need to reset caches + return; + } } - // the query text has changed, caches are now invalid and have to be purged - this._currentJsonSchema = {}; + // The user's query has changed, invalidate all caches + this._accumulatedJsonSchema = {}; + this._highestPageAccumulated = 0; + this._currentPageSize = null; this._currentRawDocuments = []; + this._lastExecutionTimeMs = undefined; - this._currentQueryText = query.trim(); + // Clear query insights caches + this.clearQueryInsightsCaches(); + + // Update the stored user query params + this._currentUserQueryParams = { ...userQueryParams }; } - public async runQueryWithCache( + /** + * Resets accumulated data when the page size changes. + * This is necessary because page boundaries differ with different page sizes, + * making previously accumulated pages incompatible. + * + * @param newPageSize - The new page size + */ + private resetAccumulationIfPageSizeChanged(newPageSize: number): void { + if (this._currentPageSize !== null && this._currentPageSize !== newPageSize) { + // Page size changed, reset accumulation tracking + this._accumulatedJsonSchema = {}; + this._highestPageAccumulated = 0; + } + this._currentPageSize = newPageSize; + } + + /** + * Clears all query insights caches + * Called automatically by resetCachesIfQueryChanged() + */ + private clearQueryInsightsCaches(): void { + this._queryPlannerCache = undefined; + this._executionStatsCache = undefined; + this._aiRecommendationsCache = undefined; + } + + /** + * Executes a MongoDB find query with caching support and pagination. + * + * @param databaseName - The name of the database + * @param collectionName - The name of the collection + * @param queryParams - Find query parameters (filter, project, sort, skip, limit) + * @param pageNumber - The page number (1-based) for pagination within the result window + * @param pageSize - The number of documents per page + * @param executionIntent - The intent of the query execution ('initial', 'refresh', or 'pagination') + * @returns The number of documents returned + * + * @remarks + * The skip/limit logic works as follows: + * - Query skip/limit define the overall "window" of data (e.g., skip: 0, limit: 100) + * - Pagination navigates within that window (e.g., page 1 with size 10 shows docs 0-9) + * - If query limit is smaller than pageSize, it takes precedence (e.g., limit: 5 caps pageSize: 10) + * - If query limit is 0 (no limit), pagination uses pageSize directly + * + * The executionIntent parameter controls cache behavior: + * - 'initial': Clear query insights caches (user clicked "Find Query" button) + * - 'refresh': Clear query insights caches (user clicked "Refresh" button) + * - 'pagination': Preserve caches (user navigated to a different page) + * + * Examples: + * 1. Query: skip=0, limit=100 | Page 1, size=10 → dbSkip=0, dbLimit=10 + * 2. Query: skip=0, limit=5 | Page 1, size=10 → dbSkip=0, dbLimit=5 (query limit caps it) + * 3. Query: skip=20, limit=0 | Page 2, size=10 → dbSkip=30, dbLimit=10 + */ + public async runFindQueryWithCache( databaseName: string, collectionName: string, - query: string, + queryParams: FindQueryParams, pageNumber: number, pageSize: number, - ) { - this.resetCachesIfQueryChanged(query); + executionIntent: 'initial' | 'refresh' | 'pagination' = 'pagination', + ): Promise { + const querySkip = queryParams.skip ?? 0; + const queryLimit = queryParams.limit ?? 0; + + // Calculate pagination offset within the query window + const pageOffset = (pageNumber - 1) * pageSize; + + // Combine query skip with pagination offset + const dbSkip = querySkip + pageOffset; + + // Early return if trying to skip beyond the query limit + // Note: queryLimit=0 means no limit, so only check when queryLimit > 0 + if (queryLimit > 0 && pageOffset >= queryLimit) { + // We're trying to page beyond the query's limit - return empty results + this._currentRawDocuments = []; + return 0; + } + + // Calculate effective limit: + // - If query has no limit (0), use pageSize + // - If query has a limit, cap by remaining documents after pageOffset + let dbLimit = pageSize; + if (queryLimit > 0) { + const remainingInWindow = queryLimit - pageOffset; + dbLimit = Math.min(pageSize, remainingInWindow); + } + + // Check if the user's query has changed (not pagination, just the actual query) + this.resetCachesIfUserQueryChanged(queryParams); - const documents: WithId[] = await this._client.runQuery( + // Check if page size has changed (invalidates accumulation strategy) + this.resetAccumulationIfPageSizeChanged(pageSize); + + // Force clear query insights caches for initial/refresh operations + // This ensures fresh performance data when user explicitly requests it + if (executionIntent === 'initial' || executionIntent === 'refresh') { + this.clearQueryInsightsCaches(); + } + + // Build final query parameters with computed skip/limit + const paginatedQueryParams: FindQueryParams = { + ...queryParams, + skip: dbSkip, + limit: dbLimit, + }; + + // Track execution time for query insights + const startTime = performance.now(); + const documents: WithId[] = await this._client.runFindQuery( databaseName, collectionName, - query, - (pageNumber - 1) * pageSize, // converting page number to amount of documents to skip - pageSize, + paginatedQueryParams, ); + this._lastExecutionTimeMs = performance.now() - startTime; - // now, here we can do caching, data conversions, schema tracking and everything else we need to do - // the client can be simplified and we can move some of the logic here, especially all data conversions + // Update current page documents (always replace, not accumulate) this._currentRawDocuments = documents; - // JSON Schema - this._currentRawDocuments.map((doc) => updateSchemaWithDocument(this._currentJsonSchema, doc)); + // Accumulate schema only if this page hasn't been seen before + // Since navigation is sequential and starts at page 1, we only need to track + // the highest page number accumulated + if (pageNumber > this._highestPageAccumulated) { + this._currentRawDocuments.map((doc) => updateSchemaWithDocument(this._accumulatedJsonSchema, doc)); + this._highestPageAccumulated = pageNumber; + } return documents.length; } @@ -153,7 +355,7 @@ export class ClusterSession { public getCurrentPageAsTable(path: string[]): TableData { const responsePack: TableData = { path: path, - headers: getPropertyNamesAtLevel(this._currentJsonSchema, path), + headers: getPropertyNamesAtLevel(this._accumulatedJsonSchema, path), data: getDataAtPath(this._currentRawDocuments, path), }; @@ -161,11 +363,274 @@ export class ClusterSession { } public getCurrentSchema(): JSONSchema { - return this._currentJsonSchema; + return this._accumulatedJsonSchema; + } + + // ============================================================================ + // Query Insights Methods + // ============================================================================ + + /** + * Gets query planner information - uses explain("queryPlanner") + * Caches the result until the query changes + * + * Note: This method intentionally excludes skip/limit to get insights for the full query scope, + * not just a single page. For page-specific explain plans, use client.queryInsightsApis.explainFind() directly. + * + * @param databaseName - Database name + * @param collectionName - Collection name + * @param filter - Query filter + * @param options - Query options (sort, projection, skip, limit) + * @returns Explain output from queryPlanner verbosity + */ + public async getQueryPlannerInfo( + databaseName: string, + collectionName: string, + filter: Document, + options?: { + sort?: Document; + projection?: Document; + skip?: number; + limit?: number; + }, + ): Promise { + // Check cache + if (this._queryPlannerCache) { + return this._queryPlannerCache.result; + } + + // Execute explain("queryPlanner") using QueryInsightsApis from ClustersClient + const explainResult = await this._client.queryInsightsApis.explainFind(databaseName, collectionName, filter, { + verbosity: 'queryPlanner', + sort: options?.sort, + projection: options?.projection, + skip: options?.skip, + limit: options?.limit, + }); + + // Cache result + this._queryPlannerCache = { + result: explainResult, + timestamp: Date.now(), + }; + + return explainResult; + } + + /** + * Gets execution statistics - uses explain("executionStats") + * Re-runs the query with execution stats and caches the result + * + * Note: This method intentionally excludes skip/limit to get insights for the full query scope, + * not just a single page. For page-specific explain plans, use client.queryInsightsApis.explainFind() directly. + * + * @param databaseName - Database name + * @param collectionName - Collection name + * @param filter - Query filter + * @param options - Query options (sort, projection, skip, limit) + * @returns Explain output with executionStats + */ + public async getExecutionStats( + databaseName: string, + collectionName: string, + filter: Document, + options?: { + sort?: Document; + projection?: Document; + skip?: number; + limit?: number; + }, + ): Promise { + // Check cache + if (this._executionStatsCache) { + return this._executionStatsCache.result; + } + + // Execute explain("executionStats") using QueryInsightsApis from ClustersClient + // This re-runs the query to get authoritative execution metrics + const explainResult = await this._client.queryInsightsApis.explainFind(databaseName, collectionName, filter, { + verbosity: 'executionStats', + sort: options?.sort, + projection: options?.projection, + skip: options?.skip, + limit: options?.limit, + }); + + // Cache result + this._executionStatsCache = { + result: explainResult, + timestamp: Date.now(), + }; + + return explainResult; + } + + /** + * Gets the last query execution time in milliseconds + * This is tracked during runFindQueryWithCache execution + * + * @returns Execution time in milliseconds, or 0 if no query has been executed yet + */ + public getLastExecutionTimeMs(): number { + return this._lastExecutionTimeMs ?? 0; + } + + /** + * Gets the current query parameters from the session + * This returns the user's original query parameters (not pagination-derived values) + * + * @returns FindQueryParams object containing the user's original filter, project, sort, skip, and limit + * + * @remarks + * Returns the query parameters exactly as provided by the user when calling runFindQueryWithCache(). + * This does NOT include internal pagination calculations (those are tracked separately in _currentDataCacheKey). + * If no query has been executed yet, returns default empty values. + */ + public getCurrentFindQueryParams(): FindQueryParams { + if (!this._currentUserQueryParams) { + return { + filter: '{}', + project: '{}', + sort: '{}', + skip: 0, + limit: 0, + }; + } + + return { ...this._currentUserQueryParams }; + } + + /** + * Gets the current query parameters with parsed BSON objects + * This returns both the string representations AND the parsed Document objects + * + * @returns ParsedFindQueryParams object containing string params plus parsed filterObj, projectionObj, sortObj + * @throws Error if the current query text cannot be parsed + * + * @remarks + * This method uses the same BSON parsing logic as ClustersClient.runFindQuery(): + * - filter is parsed with toFilterQueryObj() which handles UUID(), Date(), MinKey(), MaxKey() constructors + * - projection and sort are parsed with EJSON.parse() + * + * Use this method when you need the actual MongoDB Document objects for query execution. + * Use getCurrentFindQueryParams() when you only need the string representations. + */ + public getCurrentFindQueryParamsWithObjects(): ParsedFindQueryParams { + const stringParams = this.getCurrentFindQueryParams(); + + // Parse filter using toFilterQueryObj (handles BSON constructors like UUID, Date, etc.) + const filterObj: Filter = toFilterQueryObj(stringParams.filter ?? '{}'); + + // Parse projection if present and not empty + let projectionObj: Document | undefined; + if (stringParams.project && stringParams.project.trim() !== '{}') { + try { + projectionObj = EJSON.parse(stringParams.project) as Document; + } catch (error) { + throw new Error( + l10n.t('Invalid projection syntax: {0}', error instanceof Error ? error.message : String(error)), + ); + } + } + + // Parse sort if present and not empty + let sortObj: Document | undefined; + if (stringParams.sort && stringParams.sort.trim() !== '{}') { + try { + sortObj = EJSON.parse(stringParams.sort) as Document; + } catch (error) { + throw new Error( + l10n.t('Invalid sort syntax: {0}', error instanceof Error ? error.message : String(error)), + ); + } + } + + return { + ...stringParams, + filterObj, + projectionObj, + sortObj, + }; + } + + /** + * Gets the raw explain output from the most recent execution stats call + * Used for displaying the complete explain plan in JSON format + * + * @param _databaseName - Database name (for future use) + * @param _collectionName - Collection name (for future use) + * @returns Raw explain output document, or null if not available + */ + public getRawExplainOutput(_databaseName: string, _collectionName: string): Document | null { + // If we have cached execution stats, return it + if (this._executionStatsCache) { + return this._executionStatsCache.result; + } + + // No cached data available + return null; } /** - * Initializes a new session for the MongoDB vCore cluster. + * Gets AI-powered query optimization recommendations + * Caches the result until the query changes + * + * This method follows the same pattern as getQueryPlannerInfo() and getExecutionStats(): + * - Check cache first + * - If not cached, call the AI service via ClustersClient + * - Cache the result with timestamp + * - Return typed recommendations + * + * @param _databaseName - Database name (unused in mock, will be used in Phase 3) + * @param _collectionName - Collection name (unused in mock, will be used in Phase 3) + * @param _filter - Query filter (unused in mock, will be used in Phase 3) + * @param _executionStats - Execution statistics from Stage 2 (unused in mock, will be used in Phase 3) + * @returns AI recommendations for query optimization + * + * @remarks + * This method will be implemented in Phase 3. The AI service is accessed via + * this._client.queryInsightsAIService (similar to queryInsightsApis pattern). + */ + public getAIRecommendations( + _databaseName: string, + _collectionName: string, + _filter: Document, + _executionStats: Document, + ): unknown { + // Check cache + if (this._aiRecommendationsCache) { + return this._aiRecommendationsCache.result; + } + + // TODO: Phase 3 implementation + // const recommendations = await this._client.queryInsightsAIService.generateRecommendations( + // databaseName, + // collectionName, + // filter, + // executionStats + // ); + + // Temporary mock implementation + const recommendations = { + analysis: 'AI recommendations not yet implemented', + suggestions: [], + }; + + // Cache result + this._aiRecommendationsCache = { + result: recommendations, + timestamp: Date.now(), + }; + + return recommendations; + } + + // ============================================================================ + // Static Session Management Methods + // ============================================================================ + + /** + * Initializes a new session for the MongoDB DocumentDB cluster. * * @param credentialId - The ID of the credentials used to authenticate the MongoDB client. * @returns A promise that resolves to the session ID of the newly created session. diff --git a/src/documentdb/ClustersClient.ts b/src/documentdb/ClustersClient.ts index a173f5481..f4914dd51 100644 --- a/src/documentdb/ClustersClient.ts +++ b/src/documentdb/ClustersClient.ts @@ -34,7 +34,18 @@ import { type AuthHandler } from './auth/AuthHandler'; import { AuthMethodId } from './auth/AuthMethod'; import { MicrosoftEntraIDAuthHandler } from './auth/MicrosoftEntraIDAuthHandler'; import { NativeAuthHandler } from './auth/NativeAuthHandler'; +import { QueryInsightsApis, type ExplainVerbosity } from './client/QueryInsightsApis'; import { CredentialCache, type CachedClusterCredentials } from './CredentialCache'; +import { + llmEnhancedFeatureApis, + type CollectionStats, + type CreateIndexResult, + type DropIndexResult, + type ExplainOptions, + type ExplainResult, + type IndexSpecification, + type IndexStats, +} from './LlmEnhancedFeatureApis'; import { getHostsFromConnectionString, hasAzureDomain } from './utils/connectionStringHelpers'; import { getClusterMetadata, type ClusterMetadata } from './utils/getClusterMetadata'; import { toFilterQueryObj } from './utils/toFilterQuery'; @@ -48,17 +59,61 @@ export interface DatabaseItemModel { export interface CollectionItemModel { name: string; type?: string; - info?: { - readOnly?: false; - }; +} + +/** + * Find query parameters for MongoDB find operations. + * Each field accepts a JSON string representation of the MongoDB query syntax. + */ +export interface FindQueryParams { + /** + * The filter/query to match documents. + * @default '{}' + */ + filter?: string; + + /** + * The projection to determine which fields to include/exclude. + * @default '{}' + */ + project?: string; + + /** + * The sort specification for ordering results. + * @default '{}' + */ + sort?: string; + + /** + * Number of documents to skip. + * @default 0 + */ + skip?: number; + + /** + * Maximum number of documents to return. + * @default 0 (unlimited) + */ + limit?: number; } export interface IndexItemModel { name: string; - key: { + type: 'traditional' | 'search'; + key?: { [key: string]: number | string; }; version?: number; + unique?: boolean; + sparse?: boolean; + background?: boolean; + hidden?: boolean; + expireAfterSeconds?: number; + partialFilterExpression?: Document; + status?: string; + queryable?: boolean; + fields?: unknown[]; + [key: string]: unknown; // Allow additional index properties } // Currently we only return insertedCount, but we can add more fields in the future if needed @@ -73,6 +128,9 @@ export class ClustersClient { static _clients: Map = new Map(); private _mongoClient: MongoClient; + private _llmEnhancedFeatureApis: llmEnhancedFeatureApis | null = null; + private _queryInsightsApis: QueryInsightsApis | null = null; + private _clusterMetadataPromise: Promise | null = null; /** * Use getClient instead of a constructor. Connections/Client are being cached and reused. @@ -142,10 +200,12 @@ export class ClustersClient { // Connect with the configured options await this.connect(connectionString, options, credentials.emulatorConfiguration); - // Collect telemetry (non-blocking) - void callWithTelemetryAndErrorHandling('connect.getmetadata', async (context) => { - const metadata: ClusterMetadata = await getClusterMetadata(this._mongoClient, hosts); + // Start metadata collection and store the promise + this._clusterMetadataPromise = getClusterMetadata(this._mongoClient, hosts); + // Collect telemetry (non-blocking) - reuses the same promise + void callWithTelemetryAndErrorHandling('connect.getmetadata', async (context) => { + const metadata: ClusterMetadata = await this._clusterMetadataPromise!; context.telemetry.properties = { authmethod: authMethod, ...context.telemetry.properties, @@ -161,6 +221,8 @@ export class ClustersClient { ): Promise { try { this._mongoClient = await MongoClient.connect(connectionString, options); + this._llmEnhancedFeatureApis = new llmEnhancedFeatureApis(this._mongoClient); + this._queryInsightsApis = new QueryInsightsApis(this._mongoClient); } catch (error) { const message = parseError(error).message; if (emulatorConfiguration?.isEmulator && message.includes('ECONNREFUSED')) { @@ -197,6 +259,7 @@ export class ClustersClient { await client._mongoClient.connect(); } else { client = new ClustersClient(credentialId); + // Cluster metadata is set in initClient await client.initClient(); ClustersClient._clients.set(credentialId, client); } @@ -204,6 +267,21 @@ export class ClustersClient { return client; } + /** + * Retrieves cluster metadata for this client instance. + * + * @returns A promise that resolves to cluster metadata. + */ + public async getClusterMetadata(): Promise { + if (this._clusterMetadataPromise) { + return this._clusterMetadataPromise; + } + + // This should not happen as the promise is initialized in initClient, + // but if it does, we throw an error rather than trying to recover + throw new Error(l10n.t('Cluster metadata not initialized. Client may not be properly connected.')); + } + /** * Determines whether a client for the given credential identifier is present in the internal cache. */ @@ -241,6 +319,17 @@ export class ClustersClient { return CredentialCache.getCredentials(this.credentialId) as CachedClusterCredentials | undefined; } + /** + * Gets the Query Insights APIs instance for explain operations + * @returns QueryInsightsApis instance or throws if not initialized + */ + public get queryInsightsApis(): QueryInsightsApis { + if (!this._queryInsightsApis) { + throw new Error(l10n.t('Query Insights APIs not initialized. Client may not be properly connected.')); + } + return this._queryInsightsApis; + } + async listDatabases(): Promise { const rawDatabases: ListDatabasesResult = await this._mongoClient.db().admin().listDatabases(); const databases: DatabaseItemModel[] = rawDatabases.databases.filter( @@ -285,12 +374,87 @@ export class ClustersClient { const collection = this._mongoClient.db(databaseName).collection(collectionName); const indexes = await collection.indexes(); - let i = 0; // backup for indexes with no names + let i = 0; return indexes.map((index) => { - return { name: index.name ?? 'idx_' + (i++).toString(), key: index.key, version: index.v }; + const { v, ...indexWithoutV } = index; + return { + ...indexWithoutV, + name: index.name ?? 'idx_' + (i++).toString(), + version: v, + type: 'traditional' as const, + }; }); } + async listSearchIndexesForAtlas(databaseName: string, collectionName: string): Promise { + try { + const collection = this._mongoClient.db(databaseName).collection(collectionName); + const searchIndexes = await collection.aggregate([{ $listSearchIndexes: {} }]).toArray(); + let i = 0; // backup for indexes with no names + return searchIndexes.map((index: Document) => ({ + ...index, + name: (index.name as string | undefined) ?? 'search_idx_' + (i++).toString(), + type: ((index.type as string | undefined) ?? 'search') as 'traditional' | 'search', + fields: index.fields as unknown[] | undefined, + })); + } catch { + // $listSearchIndexes not supported on this platform (e.g., non-Atlas deployments) + // Return empty array silently + return []; + } + } + + /** + * Executes a MongoDB find query with support for filter, projection, sort, skip, and limit. + * + * @param databaseName - The name of the database + * @param collectionName - The name of the collection + * @param queryParams - Find query parameters (filter, project, sort, skip, limit) + * @returns Array of matching documents + */ + async runFindQuery( + databaseName: string, + collectionName: string, + queryParams: FindQueryParams, + ): Promise[]> { + // Parse filter query + const filterStr = queryParams.filter?.trim() || '{}'; + const filterObj: Filter = toFilterQueryObj(filterStr); + + // Build find options + const options: FindOptions = { + skip: queryParams.skip ?? 0, + limit: queryParams.limit ?? 0, + }; + + // Parse and add projection if provided + if (queryParams.project && queryParams.project.trim() !== '{}') { + try { + options.projection = EJSON.parse(queryParams.project) as Document; + } catch (error) { + throw new Error(`Invalid projection syntax: ${parseError(error).message}`); + } + } + + // Parse and add sort if provided + if (queryParams.sort && queryParams.sort.trim() !== '{}') { + try { + options.sort = EJSON.parse(queryParams.sort) as Document; + } catch (error) { + throw new Error(`Invalid sort syntax: ${parseError(error).message}`); + } + } + + const collection = this._mongoClient.db(databaseName).collection(collectionName); + const documents = await collection.find(filterObj, options).toArray(); + + return documents; + } + + /** + * @deprecated Use runFindQuery() instead which supports filter, projection, sort, skip, and limit parameters. + * This method will be removed in a future version. + */ //todo: this is just a to see how it could work, we need to use a cursor here for paging async runQuery( databaseName: string, @@ -318,41 +482,65 @@ export class ClustersClient { return documents; } - async *streamDocuments( + /** + * Streams documents from a collection with full query support (filter, projection, sort, skip, limit). + * + * @param databaseName - The name of the database + * @param collectionName - The name of the collection + * @param abortSignal - Signal to abort the streaming operation + * @param queryParams - Find query parameters (filter, project, sort, skip, limit) + * @returns AsyncGenerator yielding documents one at a time + */ + async *streamDocumentsWithQuery( databaseName: string, collectionName: string, abortSignal: AbortSignal, - findQuery: string = '{}', - skip: number = 0, - limit: number = 0, + queryParams: FindQueryParams = {}, ): AsyncGenerator { /** * Configuration */ - if (findQuery === undefined || findQuery.trim().length === 0) { - findQuery = '{}'; - } - - const findQueryObj: Filter = toFilterQueryObj(findQuery); + // Parse filter query + const filterStr = queryParams.filter?.trim() || '{}'; + const filterObj: Filter = toFilterQueryObj(filterStr); + // Build find options const options: FindOptions = { - skip: skip > 0 ? skip : undefined, - limit: limit > 0 ? limit : undefined, + skip: queryParams.skip && queryParams.skip > 0 ? queryParams.skip : undefined, + limit: queryParams.limit && queryParams.limit > 0 ? queryParams.limit : undefined, }; + // Parse and add projection if provided + if (queryParams.project && queryParams.project.trim() !== '{}') { + try { + options.projection = EJSON.parse(queryParams.project) as Document; + } catch (error) { + throw new Error(`Invalid projection syntax: ${parseError(error).message}`); + } + } + + // Parse and add sort if provided + if (queryParams.sort && queryParams.sort.trim() !== '{}') { + try { + options.sort = EJSON.parse(queryParams.sort) as Document; + } catch (error) { + throw new Error(`Invalid sort syntax: ${parseError(error).message}`); + } + } + const collection = this._mongoClient.db(databaseName).collection(collectionName); /** * Streaming */ - const cursor = collection.find(findQueryObj, options).batchSize(100); + const cursor = collection.find(filterObj, options).batchSize(100); try { while (await cursor.hasNext()) { if (abortSignal.aborted) { - console.debug('streamDocuments: Aborted by an abort signal.'); + console.debug('streamDocumentsWithQuery: Aborted by an abort signal.'); return; } @@ -534,4 +722,158 @@ export class ClustersClient { }; } } + + // ========================================== + // LLM Enhanced Feature APIs + // ========================================== + + /** + * Get detailed index statistics for a collection + * @param databaseName - Name of the database + * @param collectionName - Name of the collection + * @returns Array of index statistics including usage information + */ + async getIndexStats(databaseName: string, collectionName: string): Promise { + if (!this._llmEnhancedFeatureApis) { + throw new Error('LLM Enhanced Feature APIs not initialized. Ensure the client is connected.'); + } + return this._llmEnhancedFeatureApis.getIndexStats(databaseName, collectionName); + } + + /** + * Get detailed collection statistics + * @param databaseName - Name of the database + * @param collectionName - Name of the collection + * @returns Collection statistics including size, count, and index information + */ + async getCollectionStats(databaseName: string, collectionName: string): Promise { + if (!this._llmEnhancedFeatureApis) { + throw new Error('LLM Enhanced Feature APIs not initialized. Ensure the client is connected.'); + } + return this._llmEnhancedFeatureApis.getCollectionStats(databaseName, collectionName); + } + + /** + * Explain a find query with full execution statistics + * Supports sort, projection, skip, and limit options + * @param databaseName - Name of the database + * @param collectionName - Name of the collection + * @param verbosity - Explain verbosity level ('queryPlanner', 'executionStats', 'allPlansExecution') + * @param options - Query options including filter, sort, projection, skip, and limit + * @returns Detailed explain result with execution statistics + */ + async explainFind( + databaseName: string, + collectionName: string, + verbosity: ExplainVerbosity, + options: ExplainOptions = {}, + ): Promise { + if (!this._llmEnhancedFeatureApis) { + throw new Error('LLM Enhanced Feature APIs not initialized. Ensure the client is connected.'); + } + return this._llmEnhancedFeatureApis.explainFind(databaseName, collectionName, verbosity, options); + } + + /** + * Explain an aggregation pipeline with full execution statistics + * @param databaseName - Name of the database + * @param collectionName - Name of the collection + * @param pipeline - Aggregation pipeline stages + * @returns Detailed explain result with execution statistics + */ + async explainAggregate(databaseName: string, collectionName: string, pipeline: Document[]): Promise { + if (!this._llmEnhancedFeatureApis) { + throw new Error('LLM Enhanced Feature APIs not initialized. Ensure the client is connected.'); + } + return this._llmEnhancedFeatureApis.explainAggregate(databaseName, collectionName, pipeline); + } + + /** + * Explain a count operation with full execution statistics + * @param databaseName - Name of the database + * @param collectionName - Name of the collection + * @param filter - Query filter for the count operation + * @returns Detailed explain result with execution statistics + */ + async explainCount(databaseName: string, collectionName: string, filter: Filter = {}): Promise { + if (!this._llmEnhancedFeatureApis) { + throw new Error('LLM Enhanced Feature APIs not initialized. Ensure the client is connected.'); + } + return this._llmEnhancedFeatureApis.explainCount(databaseName, collectionName, filter); + } + + /** + * Create an index on a collection + * Supports both simple and composite indexes with various options + * @param databaseName - Name of the database + * @param collectionName - Name of the collection + * @param indexSpec - Index specification including key and options + * @returns Result of the index creation operation + */ + async createIndex( + databaseName: string, + collectionName: string, + indexSpec: IndexSpecification, + ): Promise { + if (!this._llmEnhancedFeatureApis) { + throw new Error('LLM Enhanced Feature APIs not initialized. Ensure the client is connected.'); + } + return this._llmEnhancedFeatureApis.createIndex(databaseName, collectionName, indexSpec); + } + + /** + * Drop an index from a collection + * @param databaseName - Name of the database + * @param collectionName - Name of the collection + * @param indexName - Name of the index to drop (use "*" to drop all non-_id indexes) + * @returns Result of the index drop operation + */ + async dropIndex(databaseName: string, collectionName: string, indexName: string): Promise { + if (!this._llmEnhancedFeatureApis) { + throw new Error('LLM Enhanced Feature APIs not initialized. Ensure the client is connected.'); + } + return this._llmEnhancedFeatureApis.dropIndex(databaseName, collectionName, indexName); + } + + /** + * Get sample documents from a collection using random sampling + * @param databaseName - Name of the database + * @param collectionName - Name of the collection + * @param limit - Maximum number of documents to sample (default: 10) + * @returns Array of sample documents + */ + async getSampleDocuments(databaseName: string, collectionName: string, limit: number = 10): Promise { + if (!this._llmEnhancedFeatureApis) { + throw new Error('LLM Enhanced Feature APIs not initialized. Ensure the client is connected.'); + } + return this._llmEnhancedFeatureApis.getSampleDocuments(databaseName, collectionName, limit); + } + + /** + * Hide an index in a collection + * @param databaseName - Name of the database + * @param collectionName - Name of the collection + * @param indexName - Name of the index to hide + * @returns Result of the hide index operation + */ + async hideIndex(databaseName: string, collectionName: string, indexName: string): Promise { + if (!this._llmEnhancedFeatureApis) { + throw new Error('LLM Enhanced Feature APIs not initialized. Ensure the client is connected.'); + } + return this._llmEnhancedFeatureApis.hideIndex(databaseName, collectionName, indexName); + } + + /** + * Unhide an index in a collection + * @param databaseName - Name of the database + * @param collectionName - Name of the collection + * @param indexName - Name of the index to unhide + * @returns Result of the unhide index operation + */ + async unhideIndex(databaseName: string, collectionName: string, indexName: string): Promise { + if (!this._llmEnhancedFeatureApis) { + throw new Error('LLM Enhanced Feature APIs not initialized. Ensure the client is connected.'); + } + return this._llmEnhancedFeatureApis.unhideIndex(databaseName, collectionName, indexName); + } } diff --git a/src/documentdb/ClustersExtension.ts b/src/documentdb/ClustersExtension.ts index bfa06a545..ff9ce37c9 100644 --- a/src/documentdb/ClustersExtension.ts +++ b/src/documentdb/ClustersExtension.ts @@ -32,6 +32,9 @@ import { manageCredentials } from '../commands/discoveryService.manageCredential import { exportEntireCollection, exportQueryResults } from '../commands/exportDocuments/exportDocuments'; import { openHelpAndFeedbackUrl } from '../commands/helpAndFeedback.openUrl/openUrl'; import { importDocuments } from '../commands/importDocuments/importDocuments'; +import { dropIndex } from '../commands/index.dropIndex/dropIndex'; +import { hideIndex } from '../commands/index.hideIndex/hideIndex'; +import { unhideIndex } from '../commands/index.unhideIndex/unhideIndex'; import { launchShell } from '../commands/launchShell/launchShell'; import { learnMoreAboutServiceProvider } from '../commands/learnMoreAboutServiceProvider/learnMoreAboutServiceProvider'; import { newConnection } from '../commands/newConnection/newConnection'; @@ -284,6 +287,10 @@ export class ClustersExtension implements vscode.Disposable { registerCommandWithTreeNodeUnwrapping('vscode-documentdb.command.dropCollection', deleteCollection); registerCommandWithTreeNodeUnwrapping('vscode-documentdb.command.dropDatabase', deleteAzureDatabase); + registerCommandWithTreeNodeUnwrapping('vscode-documentdb.command.hideIndex', hideIndex); + registerCommandWithTreeNodeUnwrapping('vscode-documentdb.command.unhideIndex', unhideIndex); + registerCommandWithTreeNodeUnwrapping('vscode-documentdb.command.dropIndex', dropIndex); + registerCommandWithTreeNodeUnwrapping('vscode-documentdb.command.createCollection', createCollection); registerCommandWithTreeNodeUnwrapping('vscode-documentdb.command.createDocument', createMongoDocument); diff --git a/src/documentdb/LlmEnhancedFeatureApis.ts b/src/documentdb/LlmEnhancedFeatureApis.ts new file mode 100644 index 000000000..890626086 --- /dev/null +++ b/src/documentdb/LlmEnhancedFeatureApis.ts @@ -0,0 +1,678 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * LLM Enhanced Feature APIs + */ + +import * as l10n from '@vscode/l10n'; +import { type Document, type Filter, type MongoClient, type Sort } from 'mongodb'; +import { ext } from '../extensionVariables'; +import { type ExplainVerbosity } from './client/QueryInsightsApis'; + +/** + * Options for explain operations + */ +export interface ExplainOptions { + // The query filter + filter?: Filter; + // Sort specification + sort?: Sort; + // Projection specification + projection?: Document; + // Number of documents to skip + skip?: number; + // Maximum number of documents to return + limit?: number; +} + +/** + * Index specification for creating indexes + * Supports both simple and composite indexes + */ +export interface IndexSpecification { + // Index key specification + key: Record; + // Index name + name?: string; + // Create a unique index + unique?: boolean; + // Create index in the background + background?: boolean; + // Create a sparse index + sparse?: boolean; + // TTL for documents in seconds + expireAfterSeconds?: number; + // Partial index filter expression + partialFilterExpression?: Document; + // Additional index options + [key: string]: unknown; +} + +/** + * Result of index creation operation + */ +export interface CreateIndexResult { + // Operation status + ok: number; + // Name of the created index + indexName?: string; + // Number of indexes after creation + numIndexesAfter?: number; + // Number of indexes before creation + numIndexesBefore?: number; + // Notes or warnings + note?: string; +} + +/** + * Result of index drop operation + */ +export interface DropIndexResult { + // Operation status + ok: number; + // Number of indexes after dropping + nIndexesWas?: number; + // Notes or warnings + note?: string; +} + +/** + * Collection statistics result + */ +export interface CollectionStats { + // Namespace (database.collection) + ns: string; + // Number of documents in the collection + count: number; + // Total size of all documents in bytes + size: number; + // Average object size in bytes + avgObjSize: number; + // Storage size in bytes + storageSize: number; + // Number of indexes + nindexes: number; + // Total index size in bytes + totalIndexSize: number; + // Individual index sizes + indexSizes: Record; +} + +/** + * Index statistics for a single index + */ +export interface IndexStats { + // Index name + name: string; + // Index key specification + key: Record; + // Host information + host: string; + + // Access statistics + accesses: + | { + // Number of times the index has been used + ops: number; + // Timestamp of last access + since: Date; + } + | 'N/A'; // N/A only for fallback when getIndexStats fails and merging with basic index info +} + +/** + * Explain plan result with execution statistics + */ +export interface ExplainResult { + // Query planner information + queryPlanner: { + // MongoDB version + mongodbVersion?: string; + // Namespace + namespace: string; + // Whether index was used + indexFilterSet: boolean; + // Parsed query + parsedQuery?: Document; + // Winning plan + winningPlan: Document; + // Rejected plans + rejectedPlans?: Document[]; + }; + // Execution statistics + executionStats?: { + // Execution success status + executionSuccess: boolean; + // Number of documents returned + nReturned: number; + // Execution time in milliseconds + executionTimeMillis: number; + // Total number of keys examined + totalKeysExamined: number; + // Total number of documents examined + totalDocsExamined: number; + // Detailed execution stages + executionStages: Document; + }; + // Server information + serverInfo?: { + host: string; + port: number; + version: string; + }; + // Operation status + ok: number; +} + +/** + * LLM Enhanced Feature APIs + */ +export class llmEnhancedFeatureApis { + constructor(private readonly mongoClient: MongoClient) {} + + /** + * Get statistics for all indexes in a collection + * @param databaseName - Name of the database + * @param collectionName - Name of the collection + * @returns Array of index statistics + */ + async getIndexStats(databaseName: string, collectionName: string): Promise { + const collection = this.mongoClient.db(databaseName).collection(collectionName); + + const indexStatsResult = await collection + .aggregate([ + { + $indexStats: {}, + }, + ]) + .toArray(); + + return indexStatsResult.map((stat) => { + const accesses = stat.accesses as { ops?: number; since?: Date } | undefined; + + return { + name: stat.name as string, + key: stat.key as Record, + host: stat.host as string, + accesses: { + ops: accesses?.ops ?? 0, + since: accesses?.since ?? new Date(), + }, + }; + }); + } + + /** + * Get statistics for a collection + * @param databaseName - Name of the database + * @param collectionName - Name of the collection + * @returns Collection statistics + */ + async getCollectionStats(databaseName: string, collectionName: string): Promise { + const db = this.mongoClient.db(databaseName); + + // Use the collStats command to get detailed collection statistics + const stats = await db.command({ + collStats: collectionName, + }); + + return { + ns: stats.ns as string, + count: (stats.count as number) ?? 0, + size: (stats.size as number) ?? 0, + avgObjSize: (stats.avgObjSize as number) ?? 0, + storageSize: (stats.storageSize as number) ?? 0, + nindexes: (stats.nindexes as number) ?? 0, + totalIndexSize: (stats.totalIndexSize as number) ?? 0, + indexSizes: (stats.indexSizes as Record) ?? {}, + }; + } + + /** + * Explain a find query with full execution statistics + * @param databaseName - Name of the database + * @param collectionName - Name of the collection + * @param verbosity - Explain verbosity level ('queryPlanner', 'executionStats', 'allPlansExecution') + * @param options - Query options including filter, sort, projection, skip, and limit + * @returns Detailed explain result with execution statistics + */ + async explainFind( + databaseName: string, + collectionName: string, + verbosity: ExplainVerbosity = 'executionStats', + options: ExplainOptions = {}, + ): Promise { + ext.outputChannel.trace( + l10n.t('Executing explain(find) for collection: {collection}', { + collection: `${databaseName}.${collectionName}`, + }), + ); + + const startTime = Date.now(); + const db = this.mongoClient.db(databaseName); + + const { filter = {}, sort, projection, skip, limit } = options; + + const findCmd: Document = { + find: collectionName, + filter, + }; + + // Add optional fields if they are defined + if (sort !== undefined && Object.keys(sort).length > 0) { + findCmd.sort = sort; + } + + if (projection !== undefined && Object.keys(projection).length > 0) { + findCmd.projection = projection; + } + + if (skip !== undefined && skip >= 0) { + findCmd.skip = skip; + } + + if (limit !== undefined && limit >= 0) { + findCmd.limit = limit; + } + + const command: Document = { + explain: findCmd, + verbosity: verbosity, + }; + + const explainResult = await db.command(command); + const duration = Date.now() - startTime; + + ext.outputChannel.trace( + l10n.t('Explain(find) completed [{durationMs}ms]', { + durationMs: duration.toString(), + }), + ); + + return explainResult as ExplainResult; + } + + /** + * Explain an aggregation pipeline with full execution statistics + * @param databaseName - Name of the database + * @param collectionName - Name of the collection + * @param pipeline - Aggregation pipeline stages + * @returns Detailed explain result with execution statistics + */ + async explainAggregate(databaseName: string, collectionName: string, pipeline: Document[]): Promise { + ext.outputChannel.trace( + l10n.t('Executing explain(aggregate) for collection: {collection}, pipeline stages: {stageCount}', { + collection: `${databaseName}.${collectionName}`, + stageCount: pipeline.length.toString(), + }), + ); + + const startTime = Date.now(); + const db = this.mongoClient.db(databaseName); + + const command: Document = { + explain: { + aggregate: collectionName, + pipeline, + cursor: {}, + }, + verbosity: 'executionStats', + }; + + const explainResult = await db.command(command); + const duration = Date.now() - startTime; + + ext.outputChannel.trace( + l10n.t('Explain(aggregate) completed [{durationMs}ms]', { + durationMs: duration.toString(), + }), + ); + + return explainResult as ExplainResult; + } + + /** + * Explain a count operation with full execution statistics + * @param databaseName - Name of the database + * @param collectionName - Name of the collection + * @param filter - Query filter for the count operation + * @returns Detailed explain result with execution statistics + */ + async explainCount(databaseName: string, collectionName: string, filter: Filter = {}): Promise { + ext.outputChannel.trace( + l10n.t('Executing explain(count) for collection: {collection}', { + collection: `${databaseName}.${collectionName}`, + }), + ); + + const startTime = Date.now(); + const db = this.mongoClient.db(databaseName); + + const command: Document = { + explain: { + count: collectionName, + query: filter, + }, + verbosity: 'executionStats', + }; + + const explainResult = await db.command(command); + const duration = Date.now() - startTime; + + ext.outputChannel.trace( + l10n.t('Explain(count) completed [{durationMs}ms]', { + durationMs: duration.toString(), + }), + ); + + return explainResult; + } + + /** + * Create an index on a collection + * @param databaseName - Name of the database + * @param collectionName - Name of the collection + * @param indexSpec - Index specification including key and options + * @returns Result of the index creation operation + */ + async createIndex( + databaseName: string, + collectionName: string, + indexSpec: IndexSpecification, + ): Promise { + const db = this.mongoClient.db(databaseName); + + // Handle two cases: + // 1. indexSpec.key exists: {key: {age: 1}, name: "age_1", ...} + // 2. indexSpec is the key itself: {age: 1} + let indexKey: Record; + let indexOptions: Partial = {}; + + if ('key' in indexSpec && indexSpec.key) { + const { key, ...options } = indexSpec; + indexKey = key; + indexOptions = options; + } else { + indexKey = indexSpec as Record; + } + + const indexDefinition: Document = { + key: indexKey, + }; + + // Generate index name if not provided + let indexName = indexOptions.name; + if (!indexName) { + indexName = Object.entries(indexKey) + .map(([field, direction]) => `${field}_${direction}`) + .join('_'); + } + indexDefinition.name = indexName; + + ext.outputChannel.trace( + l10n.t('Creating index "{indexName}" on collection: {collection}', { + indexName, + collection: `${databaseName}.${collectionName}`, + }), + ); + + // Add optional fields only if they are defined + if (indexOptions.unique !== undefined) { + indexDefinition.unique = indexOptions.unique; + } + + if (indexOptions.background !== undefined) { + indexDefinition.background = indexOptions.background; + } + + if (indexOptions.sparse !== undefined) { + indexDefinition.sparse = indexOptions.sparse; + } + + if (indexOptions.expireAfterSeconds !== undefined) { + indexDefinition.expireAfterSeconds = indexOptions.expireAfterSeconds; + } + + if (indexOptions.partialFilterExpression !== undefined) { + indexDefinition.partialFilterExpression = indexOptions.partialFilterExpression; + } + + // Add any other options (excluding properties we've already handled above) + const handledProps = new Set([ + 'key', + 'name', + 'unique', + 'background', + 'sparse', + 'expireAfterSeconds', + 'partialFilterExpression', + ]); + for (const [key, value] of Object.entries(indexOptions)) { + if (!handledProps.has(key)) { + indexDefinition[key] = value; + } + } + + const command: Document = { + createIndexes: collectionName, + indexes: [indexDefinition], + }; + + const startTime = Date.now(); + try { + const result = await db.command(command); + const duration = Date.now() - startTime; + + if (result.ok === 1) { + ext.outputChannel.trace( + l10n.t('Index "{indexName}" created successfully [{durationMs}ms]', { + indexName, + durationMs: duration.toString(), + }), + ); + } else { + ext.outputChannel.warn( + l10n.t('Index creation completed with warning: {note}', { + note: (result.note as string) ?? 'Unknown status', + }), + ); + } + + return { + ok: (result.ok as number) ?? 0, + indexName: indexName, + numIndexesAfter: result.numIndexesAfter as number | undefined, + numIndexesBefore: result.numIndexesBefore as number | undefined, + note: result.note as string | undefined, + }; + } catch (error: unknown) { + const duration = Date.now() - startTime; + const errorMessage = error instanceof Error ? error.message : String(error); + ext.outputChannel.error( + l10n.t('Index creation failed for "{indexName}": {error} [{durationMs}ms]', { + indexName, + error: errorMessage, + durationMs: duration.toString(), + }), + ); + return { + ok: 0, + note: `Index creation failed: ${errorMessage}`, + }; + } + } + + /** + * Drop an index from a collection + * @param databaseName - Name of the database + * @param collectionName - Name of the collection + * @param indexName - Name of the index to drop (use "*" to drop all non-_id indexes) + * @returns Result of the index drop operation + */ + async dropIndex(databaseName: string, collectionName: string, indexName: string): Promise { + ext.outputChannel.trace( + l10n.t('Dropping index "{indexName}" from collection: {collection}', { + indexName, + collection: `${databaseName}.${collectionName}`, + }), + ); + + const startTime = Date.now(); + const db = this.mongoClient.db(databaseName); + + const command: Document = { + dropIndexes: collectionName, + index: indexName, + }; + + try { + const result = await db.command(command); + const duration = Date.now() - startTime; + + if (result.ok === 1) { + ext.outputChannel.trace( + l10n.t('Index "{indexName}" dropped successfully [{durationMs}ms]', { + indexName, + durationMs: duration.toString(), + }), + ); + } else { + ext.outputChannel.warn(l10n.t('Index drop completed with warning', {})); + } + + return { + ok: (result.ok as number) ?? 0, + nIndexesWas: result.nIndexesWas as number | undefined, + }; + } catch (error: unknown) { + const duration = Date.now() - startTime; + const errorMessage = error instanceof Error ? error.message : String(error); + ext.outputChannel.error( + l10n.t('Index drop failed for "{indexName}": {error} [{durationMs}ms]', { + indexName, + error: errorMessage, + durationMs: duration.toString(), + }), + ); + return { + ok: 0, + note: `Index drop failed: ${errorMessage}`, + }; + } + } + + /** + * Get sample documents from a collection using random sampling + * @param databaseName - Name of the database + * @param collectionName - Name of the collection + * @param limit - Maximum number of documents to sample (default: 10) + * @returns Array of sample documents + */ + async getSampleDocuments(databaseName: string, collectionName: string, limit: number = 10): Promise { + const collection = this.mongoClient.db(databaseName).collection(collectionName); + + const sampleDocuments = await collection + .aggregate([ + { + $sample: { size: limit }, + }, + ]) + .toArray(); + + return sampleDocuments; + } + + /** + * Modify index visibility in a collection + * @param databaseName - Name of the database + * @param collectionName - Name of the collection + * @param indexName - Name of the index to modify + * @param hidden - Whether to hide (true) or unhide (false) the index + * @returns Result of the modify index operation + */ + async modifyIndexVisibility( + databaseName: string, + collectionName: string, + indexName: string, + hidden: boolean, + ): Promise { + const action = hidden ? 'hide' : 'unhide'; + ext.outputChannel.trace( + l10n.t('Modifying index visibility ({action}) for "{indexName}" on collection: {collection}', { + action, + indexName, + collection: `${databaseName}.${collectionName}`, + }), + ); + + const startTime = Date.now(); + const db = this.mongoClient.db(databaseName); + + const command: Document = { + collMod: collectionName, + index: { + name: indexName, + hidden, + }, + }; + + try { + const result = await db.command(command); + const duration = Date.now() - startTime; + + if (result.ok === 1) { + ext.outputChannel.trace( + l10n.t('Index "{indexName}" {action} successfully [{durationMs}ms]', { + indexName, + action: hidden ? 'hidden' : 'unhidden', + durationMs: duration.toString(), + }), + ); + } else { + ext.outputChannel.warn(l10n.t('Index visibility modification completed with warning', {})); + } + + return result; + } catch (error: unknown) { + const duration = Date.now() - startTime; + const errorMessage = error instanceof Error ? error.message : String(error); + ext.outputChannel.error( + l10n.t('Failed to {action} index "{indexName}": {error} [{durationMs}ms]', { + action, + indexName, + error: errorMessage, + durationMs: duration.toString(), + }), + ); + return { + ok: 0, + errmsg: `Failed to ${action} index: ${errorMessage}`, + }; + } + } + + /** + * Hide an index in a collection + * @param databaseName - Name of the database + * @param collectionName - Name of the collection + * @param indexName - Name of the index to hide + * @returns Result of the hide index operation + */ + async hideIndex(databaseName: string, collectionName: string, indexName: string): Promise { + return this.modifyIndexVisibility(databaseName, collectionName, indexName, true); + } + + /** + * Unhide an index in a collection + * @param databaseName - Name of the database + * @param collectionName - Name of the collection + * @param indexName - Name of the index to unhide + * @returns Result of the unhide index operation + */ + async unhideIndex(databaseName: string, collectionName: string, indexName: string): Promise { + return this.modifyIndexVisibility(databaseName, collectionName, indexName, false); + } +} diff --git a/src/documentdb/auth/AuthMethod.ts b/src/documentdb/auth/AuthMethod.ts index bd62f80aa..db42b9c33 100644 --- a/src/documentdb/auth/AuthMethod.ts +++ b/src/documentdb/auth/AuthMethod.ts @@ -39,7 +39,7 @@ export const NativeAuthMethod: AuthMethodInfo = { export const MicrosoftEntraIDAuthMethod: AuthMethodInfo = { id: AuthMethodId.MicrosoftEntraID, - label: vscode.l10n.t('Entra ID for Azure Cosmos DB for MongoDB (vCore)'), + label: vscode.l10n.t('Entra ID for Azure DocumentDB'), detail: vscode.l10n.t('Authenticate using Microsoft Entra ID (Azure AD)'), // iconName: 'Microsoft-Entra-ID-BW-icon.svg', } as const; diff --git a/src/documentdb/client/QueryInsightsApis.ts b/src/documentdb/client/QueryInsightsApis.ts new file mode 100644 index 000000000..b20581246 --- /dev/null +++ b/src/documentdb/client/QueryInsightsApis.ts @@ -0,0 +1,85 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Query Insights APIs for explain command execution + * Follows the LlmEnhancedFeatureApis.ts pattern for consistent architecture + */ + +import { type Document, type MongoClient } from 'mongodb'; + +/** + * Options for explain operations on find queries + */ +export interface ExplainFindOptions { + // Query filter + filter: Document; + // Explain verbosity level + verbosity: 'queryPlanner' | 'executionStats' | 'allPlansExecution'; + // Sort specification + sort?: Document; + // Projection specification + projection?: Document; + // Number of documents to skip + skip?: number; + // Maximum number of documents to return + limit?: number; +} + +/** + * Explain verbosity levels + */ +export type ExplainVerbosity = 'queryPlanner' | 'executionStats' | 'allPlansExecution'; + +/** + * Query Insights APIs for explain operations + * Provides explain command execution for query performance analysis + */ +export class QueryInsightsApis { + constructor(private readonly client: MongoClient) {} + + /** + * Executes explain command on a find query + * Returns detailed query execution plan and statistics + * + * @param databaseName - Target database name + * @param collectionName - Target collection name + * @param filter - Query filter + * @param options - Explain options including verbosity level + * @returns Explain result from MongoDB/DocumentDB + */ + public async explainFind( + databaseName: string, + collectionName: string, + filter: Document, + options: { + verbosity: ExplainVerbosity; + sort?: Document; + projection?: Document; + skip?: number; + limit?: number; + }, + ): Promise { + const db = this.client.db(databaseName); + const collection = db.collection(collectionName); + + const cursor = collection.find(filter); + + if (options.sort) { + cursor.sort(options.sort); + } + if (options.projection) { + cursor.project(options.projection); + } + if (options.skip !== undefined) { + cursor.skip(options.skip); + } + if (options.limit !== undefined) { + cursor.limit(options.limit); + } + + return await cursor.explain(options.verbosity); + } +} diff --git a/src/documentdb/queryInsights/ExplainPlanAnalyzer.ts b/src/documentdb/queryInsights/ExplainPlanAnalyzer.ts new file mode 100644 index 000000000..095ac365b --- /dev/null +++ b/src/documentdb/queryInsights/ExplainPlanAnalyzer.ts @@ -0,0 +1,694 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { ExplainPlan } from '@mongodb-js/explain-plan-helper'; +import { type Document } from 'mongodb'; +import { type ExtendedStageInfo } from '../../webviews/documentdb/collectionView/types/queryInsights'; + +/** + * Diagnostic detail about query performance + */ +export interface PerformanceDiagnostic { + type: 'positive' | 'negative' | 'neutral'; + /** Short message for badge text (e.g., "Low efficiency ratio") */ + message: string; + /** Detailed explanation shown in tooltip (e.g., "You return 2% of examined documents. This is bad because...") */ + details: string; +} + +/** + * Performance rating with score and detailed diagnostics + */ +export interface PerformanceRating { + score: 'excellent' | 'good' | 'fair' | 'poor'; + /** Diagnostic messages explaining the rating, highlighting strengths and issues */ + diagnostics: PerformanceDiagnostic[]; +} + +/** + * Analyzes explain plan outputs using @mongodb-js/explain-plan-helper + * Provides extraction and analysis for both queryPlanner and executionStats verbosity levels + */ +export class ExplainPlanAnalyzer { + /** + * Extracts all stage names that have failed:true from the execution tree + * MongoDB propagates failed:true up the tree, so we need to mark all of them + * @param explainResult - Raw MongoDB explain result + * @returns Array of stage names that have failed:true + */ + public static extractFailedStageNames(explainResult: Document): string[] { + const failedStages: string[] = []; + + // Get execution stages from the explain result + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + const executionStages = explainResult?.executionStats?.executionStages as Document | undefined; + + if (!executionStages) { + return failedStages; + } + + // Recursively traverse the stage tree + function traverseStage(stage: Document): void { + const stageName = stage.stage as string | undefined; + const failed = stage.failed as boolean | undefined; + + if (stageName && failed === true) { + failedStages.push(stageName); + } + + // Traverse child stages + if (stage.inputStage) { + traverseStage(stage.inputStage as Document); + } + + if (stage.inputStages && Array.isArray(stage.inputStages)) { + for (const inputStage of stage.inputStages) { + traverseStage(inputStage as Document); + } + } + + if (stage.shards && Array.isArray(stage.shards)) { + for (const shard of stage.shards) { + traverseStage(shard as Document); + } + } + } + + traverseStage(executionStages); + return failedStages; + } + + /** + * Analyzes explain("queryPlanner") output + * Provides basic query characteristics without execution metrics + * + * @param explainResult - Raw explain output from MongoDB/DocumentDB + * @returns Analysis object with query planner information + */ + public static analyzeQueryPlanner(explainResult: Document): QueryPlannerAnalysis { + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument + const explainPlan = new ExplainPlan(explainResult as any); + + // Extract metrics using helper methods + const usedIndexesInfo = explainPlan.usedIndexes || []; + const usedIndexes = usedIndexesInfo.map((idx) => (typeof idx === 'string' ? idx : idx.index || 'unknown')); + const isCollectionScan = explainPlan.isCollectionScan; + const isCovered = explainPlan.isCovered; + const hasInMemorySort = explainPlan.inMemorySort; + const namespace = explainPlan.namespace; + + // Build response structure + return { + usedIndexes, + isCollectionScan, + isCovered, + hasInMemorySort, + namespace, + rawPlan: explainResult, + }; + } + + /** + * Analyzes explain("executionStats") output + * Provides comprehensive execution metrics and performance analysis + * + * @param explainResult - Raw explain output with executionStats + * @returns Analysis object with execution statistics and performance rating + */ + public static analyzeExecutionStats(explainResult: Document): ExecutionStatsAnalysis { + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument + const explainPlan = new ExplainPlan(explainResult as any); + + // Extract query filter from command (for empty query detection) + const command = explainResult.command as Document | undefined; + const queryFilter = command?.filter as Document | undefined; + + // STEP 1: Check for execution errors FIRST + const executionStats = explainResult.executionStats as Document | undefined; + const executionError = this.extractExecutionError(executionStats); + + // Extract execution metrics + const executionTimeMillis = explainPlan.executionTimeMillis ?? 0; + const totalDocsExamined = explainPlan.totalDocsExamined ?? 0; + const totalKeysExamined = explainPlan.totalKeysExamined ?? 0; + const nReturned = explainPlan.nReturned ?? 0; + + // Calculate efficiency ratio + const efficiencyRatio = this.calculateEfficiencyRatio(nReturned, totalDocsExamined); + + // Extract query characteristics + const usedIndexesInfo = explainPlan.usedIndexes || []; + const usedIndexes = usedIndexesInfo.map((idx) => (typeof idx === 'string' ? idx : idx.index || 'unknown')); + const isCollectionScan = explainPlan.isCollectionScan; + const isCovered = explainPlan.isCovered; + const hasInMemorySort = explainPlan.inMemorySort; + // Note: isIndexScan is derived from whether indexes are used + const isIndexScan = usedIndexes.length > 0 && !isCollectionScan; + + // Check if sorting is being performed (either in-memory or index-based) + // We detect this by checking if there's a SORT stage in the execution plan + const hasSorting = this.detectSortingInPlan(explainResult); + + // Build response structure + return { + executionTimeMillis, + totalDocsExamined, + totalKeysExamined, + nReturned, + efficiencyRatio, + usedIndexes, + isCollectionScan, + isCovered, + hasInMemorySort, + isIndexScan, + performanceRating: executionError + ? this.createFailedQueryRating(executionError) + : this.calculatePerformanceRating( + executionTimeMillis, + efficiencyRatio, + hasInMemorySort, + hasSorting, + isIndexScan, + isCollectionScan, + queryFilter, + ), + rawStats: explainResult, + executionError, + }; + } + + /** + * Calculates performance rating with comprehensive diagnostics + * Based on design doc Section 3.2 thresholds + * + * Rating criteria: + * - Excellent: High efficiency (>=50%), indexed, no in-memory sort, fast (<100ms) + * - Good: Moderate efficiency (>=10%), indexed or fast (<500ms) + * - Fair: Low efficiency (>=1%) + * - Poor: Very low efficiency (<1%) or collection scan (for non-empty queries) + * + * Special handling for empty queries: + * - Collection scans on empty queries (no filter) are treated as neutral, not negative + * - Rating is based primarily on execution time and efficiency ratio + * + * Diagnostics always include: + * - Efficiency ratio assessment + * - Execution time assessment + * - Index usage assessment (adjusted for empty queries) + * - Sort strategy assessment (only when sorting is performed) + * + * @param executionTimeMs - Execution time in milliseconds + * @param efficiencyRatio - Ratio of documents returned to documents examined + * @param hasInMemorySort - Whether query performs in-memory sorting + * @param hasSorting - Whether query performs any sorting (in-memory or index-based) + * @param isIndexScan - Whether query uses index scan + * @param isCollectionScan - Whether query performs collection scan + * @param queryFilter - Optional query filter to detect empty queries + * @returns Performance rating with score and diagnostics + */ + private static calculatePerformanceRating( + executionTimeMs: number, + efficiencyRatio: number, + hasInMemorySort: boolean, + hasSorting: boolean, + isIndexScan: boolean, + isCollectionScan: boolean, + queryFilter?: Document, + ): PerformanceRating { + const diagnostics: PerformanceDiagnostic[] = []; + + // Check if this is an empty query (no filter criteria) + const isEmptyQuery = !queryFilter || Object.keys(queryFilter).length === 0; + + // 1. Efficiency Ratio Assessment (always included) + if (efficiencyRatio >= 0.5) { + diagnostics.push({ + type: 'positive', + message: 'High efficiency ratio', + details: `You return ${(efficiencyRatio * 100).toFixed(1)}% of examined documents.\n\nThis indicates excellent query selectivity and optimal index usage.`, + }); + } else if (efficiencyRatio >= 0.1) { + diagnostics.push({ + type: 'neutral', + message: 'Moderate efficiency ratio', + details: `You return ${(efficiencyRatio * 100).toFixed(1)}% of examined documents.\n\nThis is acceptable but could be improved with better index coverage or more selective filters.`, + }); + } else if (efficiencyRatio >= 0.01) { + diagnostics.push({ + type: 'negative', + message: 'Low efficiency ratio', + details: `You return only ${(efficiencyRatio * 100).toFixed(1)}% of examined documents.\n\nThis indicates poor query selectivity - the database examines many documents that don't match your query criteria.\n\nConsider adding more selective indexes or refining your query filters.`, + }); + } else { + diagnostics.push({ + type: 'negative', + message: 'Very low efficiency ratio', + details: `You return only ${(efficiencyRatio * 100).toFixed(2)}% of examined documents.\n\nThis is extremely inefficient - the database examines thousands of documents for each result returned.\n\nThis severely impacts performance and should be addressed with better indexing strategies.`, + }); + } + + // 2. Execution Time Assessment (always included) + if (executionTimeMs < 100) { + diagnostics.push({ + type: 'positive', + message: 'Fast execution', + details: `Query completed in ${executionTimeMs.toFixed(1)}ms.\n\nThis is excellent performance and provides a responsive user experience.`, + }); + } else if (executionTimeMs < 500) { + diagnostics.push({ + type: 'neutral', + message: 'Acceptable execution time', + details: `Query completed in ${executionTimeMs.toFixed(1)}ms.\n\nThis is acceptable for most use cases, though optimization could improve responsiveness.`, + }); + } else if (executionTimeMs < 2000) { + diagnostics.push({ + type: 'negative', + message: 'Slow execution', + details: `Query took ${executionTimeMs.toFixed(1)}ms to complete.\n\nThis may impact user experience.\n\nConsider adding indexes or optimizing your query structure.`, + }); + } else { + diagnostics.push({ + type: 'negative', + message: 'Very slow execution', + details: `Query took ${(executionTimeMs / 1000).toFixed(2)}s to complete.\n\nThis significantly impacts performance and user experience.\n\nImmediate optimization is recommended.`, + }); + } + + // 3. Index Usage Assessment (always included) + if (isIndexScan) { + diagnostics.push({ + type: 'positive', + message: 'Index used', + details: + 'Your query uses an index.\n\nThis allows the database to efficiently locate matching documents without scanning the entire collection.', + }); + } else if (isCollectionScan) { + // For empty queries (no filter), collection scan is expected and neutral + if (isEmptyQuery) { + diagnostics.push({ + type: 'neutral', + message: 'Full collection scan', + details: + 'Your query performs a full collection scan since no filter criteria are specified.\n\nThis is expected behavior for queries that retrieve all documents. Consider adding filters if you only need a subset of documents.', + }); + } else { + diagnostics.push({ + type: 'negative', + message: 'Full collection scan', + details: + 'Your query performs a full collection scan, examining every document in the collection.\n\nThis is inefficient and slow, especially for large collections.\n\nAdd an index on the queried fields to improve performance.', + }); + } + } else { + diagnostics.push({ + type: 'neutral', + message: 'No index used', + details: + 'Your query does not use an index.\n\nWhile not necessarily a problem for small collections, adding appropriate indexes can significantly improve query performance.', + }); + } + + // 4. Sort Strategy Assessment (only if sorting is performed) + if (hasSorting) { + if (hasInMemorySort) { + diagnostics.push({ + type: 'negative', + message: 'In-memory sort required', + details: + 'Your query requires sorting data in memory, which is limited by available RAM and can fail for large result sets.\n\nConsider adding a compound index that includes your sort fields to enable index-based sorting.', + }); + } else { + diagnostics.push({ + type: 'positive', + message: 'Efficient sorting', + details: + 'Your query uses index-based sorting, which is efficient and avoids memory constraints.\n\nThis improves performance by leveraging the natural order of the index.', + }); + } + } else { + // No sorting required - add neutral diagnostic + diagnostics.push({ + type: 'neutral', + message: 'No sorting required', + details: 'Your query does not require sorting, which avoids additional processing overhead.', + }); + } + + // Determine overall score based on thresholds + let score: 'excellent' | 'good' | 'fair' | 'poor'; + + // For empty queries with collection scan, don't penalize - treat as neutral + if (isEmptyQuery && isCollectionScan) { + // Score based on execution time and efficiency only + if (efficiencyRatio >= 0.5 && executionTimeMs < 100) { + score = 'excellent'; + } else if (efficiencyRatio >= 0.1 && executionTimeMs < 500) { + score = 'good'; + } else if (executionTimeMs < 2000) { + score = 'fair'; + } else { + score = 'poor'; + } + } else if (isCollectionScan && efficiencyRatio < 0.01) { + // Non-empty query with poor efficiency and collection scan + score = 'poor'; + } else if (efficiencyRatio >= 0.5 && isIndexScan && !hasInMemorySort && executionTimeMs < 100) { + score = 'excellent'; + } else if (efficiencyRatio >= 0.1 && (isIndexScan || executionTimeMs < 500)) { + score = 'good'; + } else if (efficiencyRatio >= 0.01) { + score = 'fair'; + } else { + score = 'poor'; + } + + return { + score, + diagnostics, + }; + } + + /** + * Calculates the efficiency ratio (documents returned / documents examined) + * A ratio close to 1.0 indicates high efficiency + * + * @param returned - Number of documents returned + * @param examined - Number of documents examined + * @returns Efficiency ratio (0.0 to 1.0+) + */ + private static calculateEfficiencyRatio(returned: number, examined: number): number { + if (examined === 0) { + return returned === 0 ? 1.0 : 0.0; + } + return returned / examined; + } + + /** + * Detects if sorting is being performed in the execution plan + * Checks for SORT or SORT_KEY_GENERATOR stages in the execution tree + * + * @param explainResult - Raw explain output document + * @returns True if sorting is detected, false otherwise + */ + private static detectSortingInPlan(explainResult: Document): boolean { + // First, check if the command includes a sort specification + const command = explainResult.command as Document | undefined; + if (command?.sort) { + const sortSpec = command.sort as Document; + // Check if sort is non-empty (not just {}) + if (Object.keys(sortSpec).length > 0) { + return true; + } + } + + // Also check for explicit SORT stages (in-memory sort) + const executionStats = explainResult.executionStats as Document | undefined; + if (!executionStats) { + return false; + } + + const executionStages = executionStats.executionStages as Document | undefined; + if (!executionStages) { + return false; + } + + // Recursively check for SORT stages + const checkStageForSort = (stage: Document): boolean => { + const stageName = stage.stage as string | undefined; + + if (stageName === 'SORT' || stageName === 'SORT_KEY_GENERATOR') { + return true; + } + + // Check child stages + if (stage.inputStage) { + if (checkStageForSort(stage.inputStage as Document)) { + return true; + } + } + + if (stage.inputStages && Array.isArray(stage.inputStages)) { + for (const childStage of stage.inputStages) { + if (checkStageForSort(childStage as Document)) { + return true; + } + } + } + + if (stage.shards && Array.isArray(stage.shards)) { + for (const shard of stage.shards) { + if (checkStageForSort(shard as Document)) { + return true; + } + } + } + + return false; + }; + + return checkStageForSort(executionStages); + } + + /** + * Extracts execution error information from explain plan + * Returns undefined if query executed successfully + * + * @param executionStats - The executionStats section from explain result + * @returns Error information or undefined if successful + */ + private static extractExecutionError(executionStats: Document | undefined): QueryExecutionError | undefined { + if (!executionStats) { + return undefined; + } + + // Check primary indicator + const executionSuccess = executionStats.executionSuccess as boolean | undefined; + const failed = executionStats.failed as boolean | undefined; + + // Query succeeded + if (executionSuccess !== false && failed !== true) { + return undefined; + } + + // Query failed - extract error details + const errorMessage = executionStats.errorMessage as string | undefined; + const errorCode = executionStats.errorCode as number | undefined; + + // Find which stage failed + const failedStage = this.findFailedStage(executionStats.executionStages as Document | undefined); + + return { + failed: true, + executionSuccess: false, + errorMessage: errorMessage || 'Query execution failed (no error message provided)', + errorCode, + failedStage, + partialStats: { + docsExamined: (executionStats.totalDocsExamined as number) ?? 0, + executionTimeMs: (executionStats.executionTimeMillis as number) ?? 0, + }, + }; + } + + /** + * Finds the stage where execution failed by traversing the stage tree + * Returns the deepest stage with failed: true + * + * @param executionStages - The executionStages section from executionStats + * @returns Information about the failed stage or undefined + */ + private static findFailedStage( + executionStages: Document | undefined, + ): { stage: string; details?: Record } | undefined { + if (!executionStages) { + return undefined; + } + + const findFailedInStage = ( + stage: Document, + ): { stage: string; details?: Record } | undefined => { + const stageName = stage.stage as string | undefined; + const stageFailed = stage.failed as boolean | undefined; + + if (!stageName) { + return undefined; + } + + // Check input stages first (depth-first to find root cause) + if (stage.inputStage) { + const childResult = findFailedInStage(stage.inputStage as Document); + if (childResult) { + return childResult; // Return deepest failed stage + } + } + + if (stage.inputStages && Array.isArray(stage.inputStages)) { + for (const inputStage of stage.inputStages) { + const childResult = findFailedInStage(inputStage as Document); + if (childResult) { + return childResult; + } + } + } + + // If this stage failed and no child failed, this is the root cause + if (stageFailed) { + return { + stage: stageName, + details: this.extractStageErrorDetails(stageName, stage), + }; + } + + return undefined; + }; + + return findFailedInStage(executionStages); + } + + /** + * Extracts relevant error details from a failed stage + * + * @param stageName - Name of the failed stage + * @param stage - The stage document + * @returns Relevant details for the failed stage + */ + private static extractStageErrorDetails(stageName: string, stage: Document): Record | undefined { + switch (stageName) { + case 'SORT': + return { + memLimit: stage.memLimit, + sortPattern: stage.sortPattern, + usedDisk: stage.usedDisk, + }; + case 'GROUP': + return { + maxMemoryUsageBytes: stage.maxMemoryUsageBytes, + }; + default: + return undefined; + } + } + + /** + * Creates a performance rating for a failed query + * This provides clear diagnostics explaining the failure + * + * @param error - The execution error information + * @returns Performance rating with failure diagnostics + */ + private static createFailedQueryRating(error: QueryExecutionError): PerformanceRating { + const diagnostics: PerformanceDiagnostic[] = []; + + // Primary diagnostic: Query failed + diagnostics.push({ + type: 'negative', + message: 'Query execution failed', + details: `${error.errorMessage}\n\nThe query did not complete successfully. Performance metrics shown are partial and measured up to the failure point.`, + }); + + // Stage-specific diagnostics + if (error.failedStage) { + const stageDiagnostic = this.createStageFailureDiagnostic(error.failedStage, error.errorCode); + if (stageDiagnostic) { + diagnostics.push(stageDiagnostic); + } + } + + return { + score: 'poor', + diagnostics, + }; + } + + /** + * Creates stage-specific diagnostic with actionable guidance + * + * @param failedStage - Information about the failed stage + * @param errorCode - MongoDB error code + * @returns Diagnostic with solutions or undefined + */ + private static createStageFailureDiagnostic( + failedStage: { stage: string; details?: Record }, + errorCode?: number, + ): PerformanceDiagnostic | undefined { + const { stage, details } = failedStage; + + // Sort memory limit exceeded (Error 292) + if (stage === 'SORT' && errorCode === 292) { + const memLimit = details?.memLimit as number | undefined; + const sortPattern = details?.sortPattern as Document | undefined; + const memLimitMB = memLimit ? (memLimit / (1024 * 1024)).toFixed(1) : 'unknown'; + + return { + type: 'negative', + message: 'Sort exceeded memory limit', + details: + `The SORT stage exceeded the ${memLimitMB}MB memory limit.\n\n` + + `**Solutions:**\n` + + `1. Add .allowDiskUse(true) to allow disk-based sorting for large result sets\n` + + `2. Create an index matching the sort pattern: ${JSON.stringify(sortPattern)}\n` + + `3. Add filters to reduce the number of documents being sorted\n` + + `4. Increase server memory limit (requires server configuration)`, + }; + } + + // Generic stage failure + return { + type: 'negative', + message: `${stage} stage failed`, + details: `The ${stage} stage could not complete execution.\n\nReview the error message and query structure for potential issues.`, + }; + } +} + +/** + * Result from analyzing queryPlanner output + */ +export interface QueryPlannerAnalysis { + usedIndexes: string[]; + isCollectionScan: boolean; + isCovered: boolean; + hasInMemorySort: boolean; + namespace: string; + rawPlan: Document; +} + +/** + * Error information from a failed query execution + */ +export interface QueryExecutionError { + failed: true; + executionSuccess: false; + errorMessage: string; + errorCode?: number; + failedStage?: { + stage: string; + details?: Record; + }; + partialStats: { + docsExamined: number; + executionTimeMs: number; + }; +} + +/** + * Result from analyzing executionStats output + */ +export interface ExecutionStatsAnalysis { + executionTimeMillis: number; + totalDocsExamined: number; + totalKeysExamined: number; + nReturned: number; + efficiencyRatio: number; + usedIndexes: string[]; + isCollectionScan: boolean; + isCovered: boolean; + hasInMemorySort: boolean; + isIndexScan: boolean; + performanceRating: PerformanceRating; + rawStats: Document; + extendedStageInfo?: ExtendedStageInfo[]; + executionError?: QueryExecutionError; +} diff --git a/src/documentdb/queryInsights/StagePropertyExtractor.ts b/src/documentdb/queryInsights/StagePropertyExtractor.ts new file mode 100644 index 000000000..fd989c250 --- /dev/null +++ b/src/documentdb/queryInsights/StagePropertyExtractor.ts @@ -0,0 +1,282 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type Document } from 'mongodb'; +import { type ExtendedStageInfo } from '../../webviews/documentdb/collectionView/types/queryInsights'; + +/** + * Extracts stage-specific properties from execution plans for UI display + * Provides detailed information for each stage type in the query execution tree + */ +export class StagePropertyExtractor { + /** + * Extracts extended properties for all stages in execution plan + * Recursively traverses the stage tree and collects stage-specific properties + * + * @param executionStages - Root execution stage from explain output + * @returns Array of extended stage information for UI display + */ + public static extractAllExtendedStageInfo(executionStages: Document): ExtendedStageInfo[] { + const stageInfoList: ExtendedStageInfo[] = []; + + this.traverseStages(executionStages, stageInfoList); + + return stageInfoList; + } + + /** + * Recursively traverses execution stages and extracts properties + * Handles single inputStage, multiple inputStages, and sharded queries + * + * @param stage - Current stage to process + * @param accumulator - Array to accumulate stage information + */ + private static traverseStages(stage: Document, accumulator: ExtendedStageInfo[]): void { + if (!stage || !stage.stage) { + return; + } + + const properties = this.extractStageProperties(stage); + + accumulator.push({ + stageName: stage.stage as string, + properties, + }); + + // Recurse into child stages + if (stage.inputStage) { + this.traverseStages(stage.inputStage as Document, accumulator); + } + if (stage.inputStages) { + (stage.inputStages as Document[]).forEach((childStage: Document) => { + this.traverseStages(childStage, accumulator); + }); + } + if (stage.shards) { + (stage.shards as Document[]).forEach((shard: Document) => { + this.traverseStages(shard, accumulator); + }); + } + } + + /** + * Extracts stage-specific properties based on stage type + * Maps stage type to relevant properties for UI display + * + * Stage-specific properties: + * - IXSCAN/EXPRESS_IXSCAN: Index name, multi-key indicator, bounds, keys examined + * - PROJECTION: Transform specification + * - COLLSCAN: Documents examined, scan direction + * - FETCH: Documents examined + * - SORT: Sort pattern, memory usage, disk spill indicator + * - LIMIT/SKIP: Limit/skip amounts + * - TEXT stages: Search string, parsed query + * - GEO_NEAR: Key pattern, index info + * - COUNT/DISTINCT: Index usage, keys examined + * - IDHACK: Keys/docs examined + * - SHARDING_FILTER: Chunks skipped + * - SHARD_MERGE/SINGLE_SHARD: Shard count + * - DELETE/UPDATE: Documents modified + * + * @param stage - Stage object from explain plan + * @returns Record of properties for UI display + */ + private static extractStageProperties(stage: Document): Record { + const stageName = stage.stage as string; + const properties: Record = {}; + + switch (stageName) { + case 'IXSCAN': + case 'EXPRESS_IXSCAN': + if (stage.keyPattern) { + properties['Key Pattern'] = JSON.stringify(stage.keyPattern); + } + if (stage.indexName) { + properties['Index Name'] = stage.indexName as string; + } + if (stage.isMultiKey !== undefined) { + properties['Multi Key'] = stage.isMultiKey ? 'Yes' : 'No'; + } + if (stage.direction) { + properties['Direction'] = stage.direction as string; + } + if (stage.indexBounds) { + properties['Index Bounds'] = JSON.stringify(stage.indexBounds); + } + if (stage.keysExamined !== undefined) { + properties['Keys Examined'] = stage.keysExamined as number; + } + break; + + case 'PROJECTION': + case 'PROJECTION_SIMPLE': + case 'PROJECTION_DEFAULT': + case 'PROJECTION_COVERED': + if (stage.transformBy) { + properties['Transform by'] = JSON.stringify(stage.transformBy); + } + break; + + case 'COLLSCAN': + if (stage.direction) { + properties['Direction'] = stage.direction as string; + } + if (stage.filter) { + properties['Filter'] = JSON.stringify(stage.filter); + } + if (stage.docsExamined !== undefined) { + properties['Documents Examined'] = stage.docsExamined as number; + } + break; + + case 'FETCH': + if (stage.filter) { + properties['Filter'] = JSON.stringify(stage.filter); + } + if (stage.docsExamined !== undefined) { + properties['Documents Examined'] = stage.docsExamined as number; + } + break; + + case 'SORT': + case 'SORT_KEY_GENERATOR': + if (stage.sortPattern) { + properties['Sort Pattern'] = JSON.stringify(stage.sortPattern); + } + if (stage.memLimit !== undefined) { + const memLimitMB = ((stage.memLimit as number) / (1024 * 1024)).toFixed(1); + properties['Memory Limit'] = `${memLimitMB} MB`; + } + if (stage.memUsage !== undefined) { + const memUsageMB = ((stage.memUsage as number) / (1024 * 1024)).toFixed(1); + properties['Memory Usage'] = `${memUsageMB} MB`; + } + if (stage.usedDisk !== undefined) { + properties['Spilled to Disk'] = stage.usedDisk ? 'Yes' : 'No'; + } + if (stage.type) { + properties['Type'] = stage.type as string; + } + break; + + case 'LIMIT': + if (stage.limitAmount !== undefined) { + properties['Limit Amount'] = stage.limitAmount as number; + } + break; + + case 'SKIP': + if (stage.skipAmount !== undefined) { + properties['Skip Amount'] = stage.skipAmount as number; + } + break; + + case 'TEXT': + case 'TEXT_MATCH': + case 'TEXT_OR': + if (stage.searchString) { + properties['Search String'] = stage.searchString as string; + } + if (stage.parsedTextQuery) { + properties['Parsed Text Query'] = JSON.stringify(stage.parsedTextQuery); + } + break; + + case 'GEO_NEAR_2D': + case 'GEO_NEAR_2DSPHERE': + if (stage.keyPattern) { + properties['Key Pattern'] = JSON.stringify(stage.keyPattern); + } + if (stage.indexName) { + properties['Index Name'] = stage.indexName as string; + } + if (stage.indexVersion !== undefined) { + properties['Index Version'] = stage.indexVersion as number; + } + break; + + case 'COUNT': + case 'COUNT_SCAN': + if (stage.indexName) { + properties['Index Name'] = stage.indexName as string; + } + if (stage.keysExamined !== undefined) { + properties['Keys Examined'] = stage.keysExamined as number; + } + break; + + case 'DISTINCT_SCAN': + if (stage.indexName) { + properties['Index Name'] = stage.indexName as string; + } + if (stage.indexBounds) { + properties['Index Bounds'] = JSON.stringify(stage.indexBounds); + } + if (stage.keysExamined !== undefined) { + properties['Keys Examined'] = stage.keysExamined as number; + } + break; + + case 'IDHACK': + if (stage.keysExamined !== undefined) { + properties['Keys Examined'] = stage.keysExamined as number; + } + if (stage.docsExamined !== undefined) { + properties['Documents Examined'] = stage.docsExamined as number; + } + break; + + case 'SHARDING_FILTER': + if (stage.chunkSkips !== undefined) { + properties['Chunks Skipped'] = stage.chunkSkips as number; + } + break; + + case 'CACHED_PLAN': + properties['Cached'] = true; + break; + + case 'SUBPLAN': + if (stage.subplanType) { + properties['Subplan Type'] = stage.subplanType as string; + } + break; + + case 'SHARD_MERGE': + case 'SINGLE_SHARD': + if (stage.shards && Array.isArray(stage.shards)) { + properties['Shard Count'] = stage.shards.length; + } + break; + + case 'BATCHED_DELETE': + if (stage.batchSize !== undefined) { + properties['Batch Size'] = stage.batchSize as number; + } + if (stage.nWouldDelete !== undefined) { + properties['Documents Deleted'] = stage.nWouldDelete as number; + } + break; + + case 'DELETE': + if (stage.nWouldDelete !== undefined) { + properties['Documents Modified'] = stage.nWouldDelete as number; + } + break; + + case 'UPDATE': + if (stage.nWouldModify !== undefined) { + properties['Documents Modified'] = stage.nWouldModify as number; + } + break; + + default: + // Unknown stage type - return empty properties + break; + } + + return properties; + } +} diff --git a/src/documentdb/queryInsights/transformations.ts b/src/documentdb/queryInsights/transformations.ts new file mode 100644 index 000000000..851a39f5f --- /dev/null +++ b/src/documentdb/queryInsights/transformations.ts @@ -0,0 +1,753 @@ +/*--------------------------------------------------------------------------------------------- + * 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 Document } from 'mongodb'; +import { type AIIndexRecommendation, type AIOptimizationResponse } from '../../services/ai/types'; +import { + type ImprovementCard, + type QueryInsightsStage1Response, + type QueryInsightsStage2Response, + type QueryInsightsStage3Response, + type ShardInfo, + type StageInfo, +} from '../../webviews/documentdb/collectionView/types/queryInsights'; +import { ExplainPlanAnalyzer, type ExecutionStatsAnalysis, type QueryPlannerAnalysis } from './ExplainPlanAnalyzer'; + +/** + * Context from the router containing connection and collection info + */ +interface TransformationContext { + clusterId: string; + databaseName: string; + collectionName: string; +} + +/** + * Transforms AI optimization response to UI-friendly format + * Adds action buttons with complete payloads for execution + * + * @param aiResponse - Raw AI service response + * @param context - Router context with connection info + * @returns Transformed response ready for UI consumption + */ +export function transformAIResponseForUI( + aiResponse: AIOptimizationResponse, + context: TransformationContext, +): QueryInsightsStage3Response { + const analysisCard = { + type: 'analysis' as const, + content: aiResponse.analysis, + }; + + const improvementCards = aiResponse.improvements.map((improvement, index) => { + return createImprovementCard(improvement, index, context); + }); + + // Join verification steps into a single string + const verificationSteps = aiResponse.verification.join('\n'); + + return { + analysisCard, + improvementCards, + verificationSteps, + educationalContent: aiResponse.educationalContent, + }; +} + +/** + * Creates an improvement card from an AI recommendation + */ +function createImprovementCard( + improvement: AIIndexRecommendation, + index: number, + context: TransformationContext, +): ImprovementCard { + const cardTitle = getCardTitle(improvement.action); + const indexSpecStr = JSON.stringify(improvement.indexSpec, null, 2); + const indexOptionsStr = + improvement.indexOptions && Object.keys(improvement.indexOptions).length > 0 + ? JSON.stringify(improvement.indexOptions, null, 2) + : undefined; + const primaryButtonLabel = getPrimaryButtonLabel(improvement.action, improvement.mongoShell); + + return { + type: 'improvement', + cardId: `improvement-${index}`, + title: cardTitle, + priority: improvement.priority, + description: improvement.justification, + recommendedIndex: indexSpecStr, + indexName: improvement.indexName, + recommendedIndexDetails: generateIndexExplanation(improvement), + indexOptions: indexOptionsStr, + details: improvement.risks || l10n.t('Additional write and storage overhead for maintaining a new index.'), + mongoShellCommand: improvement.mongoShell, + primaryButton: { + label: primaryButtonLabel, + actionId: getPrimaryActionId(improvement.action), + payload: { + clusterId: context.clusterId, + databaseName: context.databaseName, + collectionName: context.collectionName, + action: improvement.action, + indexSpec: improvement.indexSpec, + indexOptions: improvement.indexOptions, + mongoShell: improvement.mongoShell, + }, + }, + // TODO: Temporarily removing secondary button until we have relevant content + // secondaryButton: { + // label: l10n.t('Learn More'), + // actionId: 'learnMore', + // payload: { + // topic: 'index-optimization', + // }, + // }, + }; +} + +/** + * Gets the primary button label based on action and mongoShell command + */ +function getPrimaryButtonLabel(action: string, mongoShell: string): string { + switch (action) { + case 'create': + return l10n.t('Create Index…'); + case 'drop': + return l10n.t('Drop Index…'); + case 'modify': + if (mongoShell.includes('.hideIndex(')) { + return l10n.t('Hide Index…'); + } else if (mongoShell.includes('.unhideIndex(')) { + return l10n.t('Unhide Index…'); + } + return l10n.t('Modify Index…'); + default: + return l10n.t('No Action'); + } +} + +/** + * Gets the card title based on the action type + */ +function getCardTitle(action: string): string { + switch (action) { + case 'create': + return l10n.t('Recommendation: Create Index'); + case 'drop': + return l10n.t('Recommendation: Drop Index'); + case 'modify': + return l10n.t('Recommendation: Modify Index'); + default: + return l10n.t('Query Performance Insight'); + } +} + +/** + * Gets the primary action ID for the button + */ +function getPrimaryActionId(action: string): string { + switch (action) { + case 'create': + return 'createIndex'; + case 'drop': + return 'dropIndex'; + case 'modify': + return 'modifyIndex'; + default: + return 'noAction'; + } +} + +/** + * Generates a user-friendly explanation of what the index does + */ +function generateIndexExplanation(improvement: AIIndexRecommendation): string { + const fields = Object.keys(improvement.indexSpec).join(', '); + + switch (improvement.action) { + case 'create': + return l10n.t( + 'An index on {0} would allow direct lookup of matching documents and eliminate full collection scans.', + fields, + ); + case 'drop': + return l10n.t( + 'This index on {0} is not being used and adds unnecessary overhead to write operations.', + fields, + ); + case 'modify': + return l10n.t( + 'Optimizing the index on {0} can improve query performance by better matching the query pattern.', + fields, + ); + default: + return l10n.t('No index changes needed at this time.'); + } +} + +/** + * Transforms query planner analysis to Stage 1 response format + * + * @param analyzed - Query planner analysis from ExplainPlanAnalyzer + * @param executionTime - Execution time in milliseconds (from ClusterSession) + * @returns Stage 1 response ready for UI + * + * @remarks + * Stage 1 uses explain("queryPlanner") which does NOT execute the query. + * Therefore, documentsReturned is not available and will show as 0. + * The actual document count is only available in Stage 2 with explain("executionStats"). + */ +export function transformStage1Response( + analyzed: QueryPlannerAnalysis, + executionTime: number, +): QueryInsightsStage1Response { + // Check if this is a sharded query + const shardedInfo = extractShardedInfoFromDocument(analyzed.rawPlan); + + if (shardedInfo.isSharded && shardedInfo.shards) { + // Return sharded response + return { + executionTime, + stages: [], // Top-level stages are in individual shards + efficiencyAnalysis: { + executionStrategy: 'Sharded Query', + indexUsed: null, // Per-shard info + hasInMemorySort: shardedInfo.shards.some((s) => s.hasBlockedSort || false), + }, + isSharded: true, + shards: shardedInfo.shards, + }; + } + + // Non-sharded query - extract stages normally + const stages = extractStagesFromDocument(analyzed.rawPlan); + + // Determine execution strategy + let executionStrategy = 'Unknown'; + if (analyzed.isCovered) { + executionStrategy = 'Covered Query (Index Only)'; + } else if (analyzed.usedIndexes.length > 0) { + executionStrategy = 'Index Scan + Fetch'; + } else if (analyzed.isCollectionScan) { + executionStrategy = 'Collection Scan'; + } + + return { + executionTime, + stages, + efficiencyAnalysis: { + executionStrategy, + indexUsed: analyzed.usedIndexes.length > 0 ? analyzed.usedIndexes[0] : null, + hasInMemorySort: analyzed.hasInMemorySort, + }, + }; +} + +/** + * Transforms execution stats analysis to Stage 2 response format + * + * @param analyzed - Execution stats analysis from ExplainPlanAnalyzer + * @returns Stage 2 response ready for UI + */ +export function transformStage2Response(analyzed: ExecutionStatsAnalysis): QueryInsightsStage2Response { + // Check if this is a sharded query + const shardedInfo = extractShardedInfoFromDocument(analyzed.rawStats, true); + + if (shardedInfo.isSharded && shardedInfo.shards) { + // Calculate examined-to-returned ratio from aggregated data + const examinedToReturnedRatio = + analyzed.nReturned > 0 ? analyzed.totalDocsExamined / analyzed.nReturned : Infinity; + const keysToDocsRatio = + analyzed.totalDocsExamined > 0 ? analyzed.totalKeysExamined / analyzed.totalDocsExamined : null; + + return { + executionTimeMs: analyzed.executionTimeMillis, + totalKeysExamined: analyzed.totalKeysExamined, + totalDocsExamined: analyzed.totalDocsExamined, + documentsReturned: analyzed.nReturned, + examinedToReturnedRatio, + keysToDocsRatio, + executionStrategy: 'Sharded Query', + indexUsed: analyzed.usedIndexes.length > 0, + usedIndexNames: analyzed.usedIndexes, + hadInMemorySort: shardedInfo.shards.some((s) => s.hasBlockedSort || false), + hadCollectionScan: shardedInfo.shards.some((s) => s.hasCollscan || false), + isCoveringQuery: analyzed.isCovered, + concerns: buildConcernsForShardedQuery(shardedInfo.shards, examinedToReturnedRatio), + efficiencyAnalysis: { + executionStrategy: 'Sharded Query', + indexUsed: analyzed.usedIndexes.length > 0 ? analyzed.usedIndexes[0] : null, + examinedReturnedRatio: formatRatioForDisplay(examinedToReturnedRatio), + hasInMemorySort: shardedInfo.shards.some((s) => s.hasBlockedSort || false), + performanceRating: analyzed.performanceRating, + }, + stages: [], // Per-shard stages + rawExecutionStats: analyzed.rawStats, + isSharded: true, + shards: shardedInfo.shards, + extendedStageInfo: analyzed.extendedStageInfo, // Pass through extended stage properties for UI + }; + } + + // Non-sharded query - extract stages normally + const stages = extractStagesFromDocument(analyzed.rawStats); + + // Calculate examined-to-returned ratio (inverse of efficiency ratio) + const examinedToReturnedRatio = analyzed.nReturned > 0 ? analyzed.totalDocsExamined / analyzed.nReturned : Infinity; + + // Calculate keys-to-docs ratio + const keysToDocsRatio = + analyzed.totalDocsExamined > 0 ? analyzed.totalKeysExamined / analyzed.totalDocsExamined : null; + + // Determine execution strategy + let executionStrategy = 'Unknown'; + if (analyzed.isCovered) { + executionStrategy = 'Covered Query (Index Only)'; + } else if (analyzed.usedIndexes.length > 0 && analyzed.isCollectionScan) { + executionStrategy = 'Index Scan + Collection Scan'; + } else if (analyzed.usedIndexes.length > 0) { + executionStrategy = 'Index Scan + Fetch'; + } else if (analyzed.isCollectionScan) { + executionStrategy = 'Collection Scan'; + } + + // Build top-level concerns array + const concerns: string[] = []; + if (analyzed.isCollectionScan) { + concerns.push('Collection scan detected - query examines all documents'); + } + if (analyzed.hasInMemorySort) { + concerns.push('In-memory sort required - memory intensive operation'); + } + if (examinedToReturnedRatio > 100) { + concerns.push( + `High selectivity issue: examining ${examinedToReturnedRatio.toFixed(0)}x more documents than returned`, + ); + } + + // Format examined-to-returned ratio for display + const examinedReturnedRatioFormatted = formatRatioForDisplay(examinedToReturnedRatio); + + return { + executionTimeMs: analyzed.executionTimeMillis, + totalKeysExamined: analyzed.totalKeysExamined, + totalDocsExamined: analyzed.totalDocsExamined, + documentsReturned: analyzed.nReturned, + examinedToReturnedRatio, + keysToDocsRatio, + executionStrategy, + indexUsed: analyzed.usedIndexes.length > 0, + usedIndexNames: analyzed.usedIndexes, + hadInMemorySort: analyzed.hasInMemorySort, + hadCollectionScan: analyzed.isCollectionScan, + isCoveringQuery: analyzed.isCovered, + concerns, + efficiencyAnalysis: { + executionStrategy, + indexUsed: analyzed.usedIndexes.length > 0 ? analyzed.usedIndexes[0] : null, + examinedReturnedRatio: examinedReturnedRatioFormatted, + hasInMemorySort: analyzed.hasInMemorySort, + performanceRating: analyzed.performanceRating, + }, + stages: stages.map((stage) => ({ + ...stage, + // Stage 2 has access to execution metrics + })), + rawExecutionStats: analyzed.rawStats, + extendedStageInfo: analyzed.extendedStageInfo, // Pass through extended stage properties for UI + }; +} + +/** + * Extracts stages from explain result document for UI display + * Recursively traverses the stage tree and flattens it + * + * @param explainResult - Raw explain output document + * @returns Array of stage info for UI + */ +export function extractStagesFromDocument(explainResult: Document): StageInfo[] { + const stages: StageInfo[] = []; + + // Try to get execution stages first (from executionStats), fall back to query planner + const executionStages = + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + (explainResult.executionStats?.executionStages as Document | undefined) || + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + (explainResult.queryPlanner?.winningPlan as Document | undefined); + + if (!executionStages) { + return stages; + } + + // Recursively traverse stages + function traverseStage(stage: Document): void { + const stageName: string = (stage.stage as string | undefined) || 'UNKNOWN'; + + stages.push({ + stage: stageName, + name: (stage.name as string | undefined) || stageName, + nReturned: (stage.nReturned as number | undefined) ?? 0, + executionTimeMs: + (stage.executionTimeMillis as number | undefined) ?? + (stage.executionTimeMillisEstimate as number | undefined), + indexName: stage.indexName as string | undefined, + keysExamined: stage.keysExamined as number | undefined, + docsExamined: stage.docsExamined as number | undefined, + }); + + // Traverse child stages + if (stage.inputStage) { + traverseStage(stage.inputStage as Document); + } + + if (stage.inputStages && Array.isArray(stage.inputStages)) { + stage.inputStages.forEach((s: Document) => traverseStage(s)); + } + + if (stage.shards && Array.isArray(stage.shards)) { + stage.shards.forEach((s: Document) => traverseStage(s)); + } + } + + traverseStage(executionStages); + return stages; +} + +/** + * Formats a ratio for display in the UI + * + * @param ratio - The numeric ratio (e.g., 50.5) + * @returns Formatted string (e.g., "50 : 1", "1 : 1", "∞") + */ +function formatRatioForDisplay(ratio: number): string { + if (!isFinite(ratio)) { + return '∞'; + } + if (ratio < 1) { + return '1 : 1'; + } + return `${Math.round(ratio)} : 1`; +} + +/** + * Extracts sharded query information from explain result + * + * @param explainResult - Raw explain output document + * @param hasExecutionStats - Whether this is from executionStats (true) or queryPlanner (false) + * @returns Sharded query information or indication it's not sharded + */ +function extractShardedInfoFromDocument( + explainResult: Document, + hasExecutionStats = false, +): { isSharded: boolean; shards?: ShardInfo[] } { + // Check for sharded query structure + const queryPlanner = explainResult.queryPlanner as Document | undefined; + const executionStats = explainResult.executionStats as Document | undefined; + + // Look for SHARD_MERGE or shards array in winning plan + const winningPlan = queryPlanner?.winningPlan as Document | undefined; + const isShardMerge = winningPlan?.stage === 'SHARD_MERGE'; + const shardsArray = winningPlan?.shards as Document[] | undefined; + + if (!isShardMerge || !shardsArray || shardsArray.length === 0) { + return { isSharded: false }; + } + + // Extract per-shard information + const shards = shardsArray.map((shardDoc) => { + const shardName = (shardDoc.shardName as string) || 'unknown'; + + // Extract stages from this shard's plan + let shardStages: StageInfo[] = []; + if (hasExecutionStats) { + // Get from executionStats + const execStatsShards = (executionStats?.executionStages as Document | undefined)?.shards as + | Document[] + | undefined; + const shardExecStats = execStatsShards?.find((s) => (s.shardName as string) === shardName); + if (shardExecStats) { + shardStages = extractStagesFromShard(shardExecStats.executionStages as Document); + } + } else { + // Get from queryPlanner + const shardPlan = shardDoc.winningPlan as Document | undefined; + if (shardPlan) { + shardStages = extractStagesFromShard(shardPlan); + } + } + + // Extract shard-level metrics from executionStats if available + let nReturned: number | undefined; + let keysExamined: number | undefined; + let docsExamined: number | undefined; + let executionTimeMs: number | undefined; + let hasCollscan = false; + let hasBlockedSort = false; + + if (hasExecutionStats) { + const execStatsShards = (executionStats?.executionStages as Document | undefined)?.shards as + | Document[] + | undefined; + const shardExecStats = execStatsShards?.find((s) => (s.shardName as string) === shardName); + if (shardExecStats) { + nReturned = shardExecStats.nReturned as number | undefined; + keysExamined = shardExecStats.totalKeysExamined as number | undefined; + docsExamined = shardExecStats.totalDocsExamined as number | undefined; + executionTimeMs = shardExecStats.executionTimeMillis as number | undefined; + + // Check for COLLSCAN and SORT in this shard's stages + const checkStages = (stage: Document): void => { + const stageName = stage.stage as string | undefined; + if (stageName === 'COLLSCAN') { + hasCollscan = true; + } + if (stageName === 'SORT') { + hasBlockedSort = true; + } + if (stage.inputStage) { + checkStages(stage.inputStage as Document); + } + }; + const shardExecStagesDoc = shardExecStats.executionStages as Document | undefined; + if (shardExecStagesDoc) { + checkStages(shardExecStagesDoc); + } + } + } else { + // For queryPlanner, check for COLLSCAN and SORT in plan + const checkPlanStages = (stage: Document): void => { + const stageName = stage.stage as string | undefined; + if (stageName === 'COLLSCAN') { + hasCollscan = true; + } + if (stageName === 'SORT') { + hasBlockedSort = true; + } + if (stage.inputStage) { + checkPlanStages(stage.inputStage as Document); + } + }; + const shardPlan = shardDoc.winningPlan as Document | undefined; + if (shardPlan) { + checkPlanStages(shardPlan); + } + } + + return { + shardName, + stages: shardStages, + nReturned, + keysExamined, + docsExamined, + executionTimeMs, + hasCollscan, + hasBlockedSort, + }; + }); + + return { isSharded: true, shards }; +} + +/** + * Extracts stages from a shard's execution plan + * Note: Stage-specific properties are provided separately via extendedStageInfo at the response level + */ +function extractStagesFromShard(shardPlan: Document): StageInfo[] { + const stages: StageInfo[] = []; + + function traverseStage(stage: Document): void { + const stageName: string = (stage.stage as string | undefined) || 'UNKNOWN'; + + stages.push({ + stage: stageName, + name: (stage.name as string | undefined) || stageName, + nReturned: (stage.nReturned as number | undefined) ?? 0, + executionTimeMs: + (stage.executionTimeMillis as number | undefined) ?? + (stage.executionTimeMillisEstimate as number | undefined), + indexName: stage.indexName as string | undefined, + keysExamined: stage.keysExamined as number | undefined, + docsExamined: stage.docsExamined as number | undefined, + }); + + // Traverse child stages + if (stage.inputStage) { + traverseStage(stage.inputStage as Document); + } + + if (stage.inputStages && Array.isArray(stage.inputStages)) { + stage.inputStages.forEach((s: Document) => traverseStage(s)); + } + } + + traverseStage(shardPlan); + return stages; +} + +/** + * Builds concerns array for sharded query + */ +function buildConcernsForShardedQuery(shards: ShardInfo[], examinedToReturnedRatio: number): string[] { + const concerns: string[] = []; + + const hasCollscan = shards.some((s) => s.hasCollscan); + const hasBlockedSort = shards.some((s) => s.hasBlockedSort); + + if (hasCollscan) { + concerns.push('Collection scan detected on one or more shards - query examines all documents'); + } + if (hasBlockedSort) { + concerns.push('In-memory sort required on one or more shards - memory intensive operation'); + } + if (examinedToReturnedRatio > 100) { + concerns.push( + `High selectivity issue: examining ${examinedToReturnedRatio.toFixed(0)}x more documents than returned`, + ); + } + + return concerns; +} + +/** + * Enhances stage info with failure indicators for all failed stages + * MongoDB propagates failed:true up the execution tree, so we mark all of them + */ +export function enhanceStagesWithFailureIndicators( + analyzed: ExecutionStatsAnalysis, + explainResult: Document, +): QueryInsightsStage2Response['extendedStageInfo'] { + const enhancedStageInfo = analyzed.extendedStageInfo ? [...analyzed.extendedStageInfo] : []; + const failedStageNames = ExplainPlanAnalyzer.extractFailedStageNames(explainResult); + + for (const failedStageName of failedStageNames) { + const stageIndex = enhancedStageInfo.findIndex((s) => s.stageName === failedStageName); + + if (stageIndex >= 0) { + // Update existing stage entry + enhancedStageInfo[stageIndex] = updateStageWithFailureInfo( + enhancedStageInfo[stageIndex], + failedStageName, + analyzed.executionError, + ); + } else { + // Create new stage entry + enhancedStageInfo.push(createFailedStageEntry(failedStageName, analyzed.executionError)); + } + } + + return enhancedStageInfo; +} + +/** + * Updates an existing stage with failure indicators + */ +function updateStageWithFailureInfo( + stageInfo: { stageName: string; properties: Record }, + failedStageName: string, + executionError: ExecutionStatsAnalysis['executionError'], +): { stageName: string; properties: Record } { + // Convert properties to Map to avoid duplicates + const propsMap = new Map(Object.entries(stageInfo.properties)); + + // Add failure indicator + propsMap.set('Failed', true); + + // Add error details only for root cause stage + if (executionError?.failedStage?.stage === failedStageName) { + propsMap.set('Error Code', executionError.errorCode || 'N/A'); + propsMap.set('Error Message', executionError.errorMessage); + } + + return { + ...stageInfo, + properties: Object.fromEntries(propsMap), + }; +} + +/** + * Creates a new stage entry for a failed stage + */ +function createFailedStageEntry( + failedStageName: string, + executionError: ExecutionStatsAnalysis['executionError'], +): { stageName: string; properties: Record } { + const props: Record = { + Failed: true, + }; + + // Add error details only for root cause + if (executionError?.failedStage?.stage === failedStageName) { + props['Error Code'] = executionError.errorCode || 'N/A'; + props['Error Message'] = executionError.errorMessage; + + // Add stage-specific details if available + const details = executionError.failedStage?.details; + if (details) { + for (const [key, value] of Object.entries(details)) { + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + props[key] = value; + } + } + } + } + + return { + stageName: failedStageName, + properties: props, + }; +} + +/** + * Creates a Stage 2 response for a failed query execution + */ +export function createFailedQueryResponse( + analyzed: ExecutionStatsAnalysis, + explainResult: Document, +): QueryInsightsStage2Response { + const examinedToReturnedRatio = analyzed.nReturned > 0 ? analyzed.totalDocsExamined / analyzed.nReturned : Infinity; + const keysToDocsRatio = + analyzed.totalDocsExamined > 0 ? analyzed.totalKeysExamined / analyzed.totalDocsExamined : null; + + // Extract stages even when query failed - they contain partial execution info + const stages = extractStagesFromDocument(analyzed.rawStats); + + // Enhance stage info with failure indicators + const enhancedStageInfo = enhanceStagesWithFailureIndicators(analyzed, explainResult); + + return { + executionTimeMs: analyzed.executionTimeMillis, + totalKeysExamined: analyzed.totalKeysExamined, + totalDocsExamined: analyzed.totalDocsExamined, + documentsReturned: analyzed.nReturned, + examinedToReturnedRatio, + keysToDocsRatio, + executionStrategy: `Failed: ${analyzed.executionError?.failedStage?.stage || 'Unknown'}`, + indexUsed: analyzed.usedIndexes.length > 0, + usedIndexNames: analyzed.usedIndexes, + hadInMemorySort: analyzed.hasInMemorySort, + hadCollectionScan: analyzed.isCollectionScan, + isCoveringQuery: analyzed.isCovered, + concerns: [ + `Query Execution Failed: ${analyzed.executionError?.errorMessage}`, + `Failed Stage: ${analyzed.executionError?.failedStage?.stage || 'Unknown'}`, + `Error Code: ${analyzed.executionError?.errorCode || 'N/A'}`, + ], + efficiencyAnalysis: { + executionStrategy: `Failed at ${analyzed.executionError?.failedStage?.stage || 'Unknown'} stage`, + indexUsed: analyzed.usedIndexes.length > 0 ? analyzed.usedIndexes[0] : null, + examinedReturnedRatio: + examinedToReturnedRatio === Infinity + ? 'N/A (query failed)' + : `${Math.round(examinedToReturnedRatio)}:1`, + hasInMemorySort: analyzed.hasInMemorySort, + performanceRating: analyzed.performanceRating, + }, + stages, + rawExecutionStats: analyzed.rawStats, + extendedStageInfo: enhancedStageInfo, + }; +} diff --git a/src/extension.ts b/src/extension.ts index 1037f4d03..7cf55ab93 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -122,16 +122,16 @@ export function deactivateInternal(_context: vscode.ExtensionContext): void { } /** - * Checks if vCore and RU support is to be activated in this extension. + * Checks if DocumentDB and RU support is to be activated in this extension. * This introduces changes to the behavior of the extension. * - * This function is used to determine whether the vCore and RU features should be enabled in this extension. + * This function is used to determine whether the DocumentDB and RU features should be enabled in this extension. * * The result of this function depends on the version of the Azure Resources extension. * When a new version of the Azure Resources extension is released with the `AzureCosmosDbForMongoDbRu` and `MongoClusters` * resource types, this function will return true. * - * @returns True if vCore and RU features are enabled, false | undefined otherwise. + * @returns True if DocumentDB and RU features are enabled, false | undefined otherwise. */ export async function isVCoreAndRURolloutEnabled(): Promise { return callWithTelemetryAndErrorHandling('isVCoreAndRURolloutEnabled', async (context: IActionContext) => { @@ -155,7 +155,7 @@ export async function isVCoreAndRURolloutEnabled(): Promise context.telemetry.properties.vCoreAndRURolloutEnabled = 'false'; context.telemetry.properties.apiMethodAvailable = 'false'; ext.outputChannel.appendLog( - 'Expected Azure Resources API v3.0.0 is not available; VCore and RU support remains inactive.', + 'Expected Azure Resources API v3.0.0 is not available; DocumentDB and RU support remains inactive.', ); return false; }); diff --git a/src/extensionVariables.ts b/src/extensionVariables.ts index 623808eb2..00f9bc6b6 100644 --- a/src/extensionVariables.ts +++ b/src/extensionVariables.ts @@ -66,6 +66,7 @@ export namespace ext { export const showOperationSummaries = 'documentDB.userInterface.ShowOperationSummaries'; export const showUrlHandlingConfirmations = 'documentDB.confirmations.showUrlHandlingConfirmations'; export const localPort = 'documentDB.local.port'; + export const collectionViewDefaultPageSize = 'documentDB.collectionView.defaultPageSize'; export namespace vsCode { export const proxyStrictSSL = 'http.proxyStrictSSL'; diff --git a/src/plugins/service-azure-mongo-ru/discovery-tree/documentdb/MongoRUResourceItem.ts b/src/plugins/service-azure-mongo-ru/discovery-tree/documentdb/MongoRUResourceItem.ts index 9ef5e5c97..359058014 100644 --- a/src/plugins/service-azure-mongo-ru/discovery-tree/documentdb/MongoRUResourceItem.ts +++ b/src/plugins/service-azure-mongo-ru/discovery-tree/documentdb/MongoRUResourceItem.ts @@ -24,7 +24,7 @@ export class MongoRUResourceItem extends ClusterItemBase { 'vscode-azext-azureutils', 'resources', 'azureIcons', - 'MongoClusters.svg', + 'AzureCosmosDb.svg', ); constructor( diff --git a/src/plugins/service-azure-mongo-ru/discovery-wizard/SelectRUClusterStep.ts b/src/plugins/service-azure-mongo-ru/discovery-wizard/SelectRUClusterStep.ts index 7d36a4a0d..8b000b8fd 100644 --- a/src/plugins/service-azure-mongo-ru/discovery-wizard/SelectRUClusterStep.ts +++ b/src/plugins/service-azure-mongo-ru/discovery-wizard/SelectRUClusterStep.ts @@ -22,7 +22,7 @@ export class SelectRUClusterStep extends AzureWizardPromptStep { diff --git a/src/plugins/service-azure-mongo-vcore/AzureDiscoveryProvider.ts b/src/plugins/service-azure-mongo-vcore/AzureDiscoveryProvider.ts index 11b1e312b..d0ea936fe 100644 --- a/src/plugins/service-azure-mongo-vcore/AzureDiscoveryProvider.ts +++ b/src/plugins/service-azure-mongo-vcore/AzureDiscoveryProvider.ts @@ -19,8 +19,8 @@ import { SelectClusterStep } from './discovery-wizard/SelectClusterStep'; export class AzureDiscoveryProvider extends Disposable implements DiscoveryProvider { id = 'azure-mongo-vcore-discovery'; - label = l10n.t('Azure Cosmos DB for MongoDB (vCore)'); - description = l10n.t('Azure Service Discovery'); + label = l10n.t('Azure DocumentDB'); + description = l10n.t('Azure Service Discovery for Azure DocumentDB'); iconPath = new ThemeIcon('azure'); azureSubscriptionProvider: AzureSubscriptionProviderWithFilters; diff --git a/src/plugins/service-azure-mongo-vcore/discovery-tree/AzureServiceRootItem.ts b/src/plugins/service-azure-mongo-vcore/discovery-tree/AzureServiceRootItem.ts index 366d1e33d..580fa0907 100644 --- a/src/plugins/service-azure-mongo-vcore/discovery-tree/AzureServiceRootItem.ts +++ b/src/plugins/service-azure-mongo-vcore/discovery-tree/AzureServiceRootItem.ts @@ -97,7 +97,7 @@ export class AzureServiceRootItem implements TreeElement, TreeElementWithContext return { id: this.id, contextValue: this.contextValue, - label: l10n.t('Azure Cosmos DB for MongoDB (vCore)'), + label: l10n.t('Azure DocumentDB'), iconPath: new vscode.ThemeIcon('azure'), collapsibleState: vscode.TreeItemCollapsibleState.Collapsed, }; diff --git a/src/plugins/service-azure-mongo-vcore/discovery-tree/documentdb/DocumentDBResourceItem.ts b/src/plugins/service-azure-mongo-vcore/discovery-tree/documentdb/DocumentDBResourceItem.ts index f99943d27..377785c8b 100644 --- a/src/plugins/service-azure-mongo-vcore/discovery-tree/documentdb/DocumentDBResourceItem.ts +++ b/src/plugins/service-azure-mongo-vcore/discovery-tree/documentdb/DocumentDBResourceItem.ts @@ -13,6 +13,7 @@ import { import { type AzureSubscription } from '@microsoft/vscode-azureresources-api'; import * as l10n from '@vscode/l10n'; import * as vscode from 'vscode'; +import { getThemeAgnosticIconURI } from '../../../../constants'; import { AuthMethodId } from '../../../../documentdb/auth/AuthMethod'; import { ClustersClient } from '../../../../documentdb/ClustersClient'; import { CredentialCache } from '../../../../documentdb/CredentialCache'; @@ -28,16 +29,7 @@ import { nonNullValue } from '../../../../utils/nonNull'; import { extractCredentialsFromCluster, getClusterInformationFromAzure } from '../../utils/clusterHelpers'; export class DocumentDBResourceItem extends ClusterItemBase { - iconPath = vscode.Uri.joinPath( - ext.context.extensionUri, - 'resources', - 'from_node_modules', - '@microsoft', - 'vscode-azext-azureutils', - 'resources', - 'azureIcons', - 'MongoClusters.svg', - ); + iconPath = getThemeAgnosticIconURI('AzureDocumentDb.svg'); constructor( readonly subscription: AzureSubscription, diff --git a/src/plugins/service-azure-mongo-vcore/discovery-wizard/SelectClusterStep.ts b/src/plugins/service-azure-mongo-vcore/discovery-wizard/SelectClusterStep.ts index 4ccd5e2ec..01880f265 100644 --- a/src/plugins/service-azure-mongo-vcore/discovery-wizard/SelectClusterStep.ts +++ b/src/plugins/service-azure-mongo-vcore/discovery-wizard/SelectClusterStep.ts @@ -7,23 +7,14 @@ import { uiUtils } from '@microsoft/vscode-azext-azureutils'; import { AzureWizardPromptStep } from '@microsoft/vscode-azext-utils'; import { type AzureSubscription } from '@microsoft/vscode-azureresources-api'; import * as l10n from '@vscode/l10n'; -import { Uri, type QuickPickItem } from 'vscode'; +import { type QuickPickItem } from 'vscode'; import { type NewConnectionWizardContext } from '../../../commands/newConnection/NewConnectionWizardContext'; -import { ext } from '../../../extensionVariables'; +import { getThemeAgnosticIconURI } from '../../../constants'; import { createResourceManagementClient } from '../../../utils/azureClients'; import { AzureContextProperties } from '../../api-shared/azure/wizard/AzureContextProperties'; export class SelectClusterStep extends AzureWizardPromptStep { - iconPath = Uri.joinPath( - ext.context.extensionUri, - 'resources', - 'from_node_modules', - '@microsoft', - 'vscode-azext-azureutils', - 'resources', - 'azureIcons', - 'MongoClusters.svg', - ); + iconPath = getThemeAgnosticIconURI('AzureDocumentDb.svg'); public async prompt(context: NewConnectionWizardContext): Promise { if ( diff --git a/src/services/ai/QueryInsightsAIService.ts b/src/services/ai/QueryInsightsAIService.ts new file mode 100644 index 000000000..29710c108 --- /dev/null +++ b/src/services/ai/QueryInsightsAIService.ts @@ -0,0 +1,632 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { callWithTelemetryAndErrorHandling, type IActionContext } from '@microsoft/vscode-azext-utils'; +import * as l10n from '@vscode/l10n'; +import { type Document } from 'mongodb'; +import { + CommandType, + optimizeQuery, + type QueryObject, + type QueryOptimizationContext, +} from '../../commands/llmEnhancedCommands/indexAdvisorCommands'; +import { type FindQueryParams } from '../../documentdb/ClustersClient'; +import { ClusterSession } from '../../documentdb/ClusterSession'; +import { type IndexSpecification } from '../../documentdb/LlmEnhancedFeatureApis'; +import { ext } from '../../extensionVariables'; +import { getConfirmationAsInSettings, getConfirmationWithClick } from '../../utils/dialogs/getConfirmation'; +import { type AIOptimizationResponse } from './types'; + +/** + * Payload for create index action + */ +interface CreateIndexPayload { + sessionId: string; + databaseName: string; + collectionName: string; + indexSpec: IndexSpecification; + indexOptions?: Partial; +} + +/** + * Payload for drop index action + */ +interface DropIndexPayload { + sessionId: string; + databaseName: string; + collectionName: string; + indexName: string; +} + +/** + * Payload for modify index action + */ +interface ModifyIndexPayload { + sessionId: string; + databaseName: string; + collectionName: string; + mongoShell: string; +} + +/** + * AI service for query insights and optimization recommendations + * Uses the index advisor to provide index recommendations + */ +export class QueryInsightsAIService { + /** + * Gets optimization recommendations for a query + * + * @param sessionId - Session Id for accessing cached data + * @param query - The query string + * @param databaseName - Target database name + * @param collectionName - Target collection name + * @returns AI optimization recommendations + */ + public async getOptimizationRecommendations( + sessionId: string, + query: string | FindQueryParams, + databaseName: string, + collectionName: string, + ): Promise { + const result = await callWithTelemetryAndErrorHandling( + 'vscode-documentdb.queryInsights.getOptimizationRecommendations', + async (context: IActionContext) => { + // Prepare query optimization context + let queryContext: QueryOptimizationContext; + if (typeof query !== 'string') { + // Convert FindQueryParams to QueryObject + const queryObject = this.convertFindParamsToQueryObject(query); + queryContext = { + sessionId, + databaseName, + collectionName, + queryObject, + commandType: CommandType.Find, + }; + } else { + // handle string query for temporary compatibility + queryContext = { + sessionId, + databaseName, + collectionName, + query, + commandType: CommandType.Find, // For now, only support find queries + }; + } + + // Call the optimization service + const optimizationResult = await optimizeQuery(context, queryContext); + + // Parse the AI response to extract structured recommendations + const parsedResponse = this.parseAIResponse(optimizationResult.recommendations); + + return parsedResponse; + }, + ); + + if (!result) { + throw new Error(l10n.t('Failed to get optimization recommendations from index advisor.')); + } + + return result; + } + + /** + * Parses the generated recommendations text into structured format + * + * @param recommendationsText - The raw text from the AI model + * @returns Structured AI optimization response + */ + private parseAIResponse(recommendationsText: string): AIOptimizationResponse { + try { + const parsedJson = JSON.parse(recommendationsText) as { + analysis?: string; + improvements?: Array<{ + action: 'create' | 'drop' | 'none' | 'modify'; + indexSpec: Record; + indexOptions?: Record; + indexName: string; + mongoShell: string; + justification: string; + priority: 'high' | 'medium' | 'low'; + risks?: string; + }>; + verification?: string[]; + educationalContent?: string; + }; + + return { + analysis: parsedJson.analysis || 'No analysis provided.', + improvements: parsedJson.improvements || [], + verification: parsedJson.verification || [], + educationalContent: parsedJson.educationalContent, + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + throw new Error(l10n.t('Failed to parse AI optimization response. {error}', { error: errorMessage })); + } + } + + /** + * Converts FindQueryParams to QueryObject + * TODO: Later should support other command types as well + * @param params - FindQueryParams with string filter, sort, project + * @returns QueryObject with parsed Document objects + */ + private convertFindParamsToQueryObject(params: FindQueryParams): QueryObject { + const result: QueryObject = {}; + + try { + if (params.filter) { + result.filter = JSON.parse(params.filter) as Document; + } + + if (params.project) { + const projection = JSON.parse(params.project) as Document; + if (Object.keys(projection).length > 0) { + result.projection = projection; + } + } + + if (params.sort) { + const sort = JSON.parse(params.sort) as Document; + if (Object.keys(sort).length > 0) { + result.sort = sort; + } + } + + if (params.limit !== undefined) { + result.limit = params.limit; + } + + if (params.skip !== undefined) { + result.skip = params.skip; + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + throw new Error(l10n.t('Failed to convert query parameters: {error}', { error: errorMessage })); + } + + return result; + } + + /** + * Executes a recommendation action (create index, drop index, learn more, etc.) + * + * @param _clusterId - Cluster/connection identifier + * @param sessionId - Session identifier for accessing cached data + * @param actionId - The action to perform (e.g., 'createIndex', 'dropIndex', 'learnMore') + * @param payload - The action-specific payload + * @returns Success status and optional message + */ + public async executeQueryInsightsAction( + _clusterId: string, + sessionId: string | undefined, + actionId: string, + payload: unknown, + ): Promise<{ success: boolean; message?: string }> { + return await callWithTelemetryAndErrorHandling( + 'vscode-documentdb.queryInsights.action', + async (context: IActionContext) => { + // Track which action was executed + context.telemetry.properties.actionId = actionId; + + // Route to appropriate handler based on actionId + switch (actionId) { + case 'createIndex': + return this.handleCreateIndex(sessionId, payload); + case 'dropIndex': + return this.handleDropIndex(sessionId, payload); + case 'modifyIndex': + return this.handleModifyIndex(sessionId, payload); + case 'learnMore': + return this.handleLearnMore(payload); + default: + return { + success: false, + message: `Unknown action: ${actionId}`, + }; + } + }, + ).then((result) => result ?? { success: false, message: 'Unknown error' }); + } + + /** + * Type guard for CreateIndexPayload + */ + private isCreateIndexPayload(payload: unknown): payload is CreateIndexPayload { + return ( + typeof payload === 'object' && + payload !== null && + 'databaseName' in payload && + 'collectionName' in payload && + 'indexSpec' in payload && + typeof (payload as CreateIndexPayload).databaseName === 'string' && + typeof (payload as CreateIndexPayload).collectionName === 'string' && + typeof (payload as CreateIndexPayload).indexSpec === 'object' && + (!('indexOptions' in payload) || + (payload as CreateIndexPayload).indexOptions === undefined || + typeof (payload as CreateIndexPayload).indexOptions === 'object') + ); + } + + /** + * Type guard for DropIndexPayload + */ + private isDropIndexPayload(payload: unknown): payload is DropIndexPayload { + return ( + typeof payload === 'object' && + payload !== null && + 'databaseName' in payload && + 'collectionName' in payload && + 'indexName' in payload && + typeof (payload as DropIndexPayload).databaseName === 'string' && + typeof (payload as DropIndexPayload).collectionName === 'string' && + typeof (payload as DropIndexPayload).indexName === 'string' + ); + } + + /** + * Type guard for ModifyIndexPayload + */ + private isModifyIndexPayload(payload: unknown): payload is ModifyIndexPayload { + return ( + typeof payload === 'object' && + payload !== null && + 'databaseName' in payload && + 'collectionName' in payload && + 'mongoShell' in payload && + typeof (payload as ModifyIndexPayload).databaseName === 'string' && + typeof (payload as ModifyIndexPayload).collectionName === 'string' && + typeof (payload as ModifyIndexPayload).mongoShell === 'string' + ); + } + + /** + * Handles create index action + */ + private async handleCreateIndex( + sessionId: string | undefined, + payload: unknown, + ): Promise<{ success: boolean; message?: string }> { + try { + // Validate payload + if (!this.isCreateIndexPayload(payload)) { + ext.outputChannel.warn(l10n.t('[Query Insights Action] Invalid payload for create index action', {})); + return { + success: false, + message: l10n.t('Invalid payload for create index action'), + }; + } + + // Get session and client + const actualSessionId = sessionId ?? payload.sessionId; + if (!actualSessionId) { + ext.outputChannel.warn(l10n.t('[Query Insights Action] Session ID is required', {})); + return { + success: false, + message: l10n.t('Session ID is required'), + }; + } + + ext.outputChannel.trace( + l10n.t('[Query Insights Action] Executing createIndex action for collection: {collection}', { + collection: `${payload.databaseName}.${payload.collectionName}`, + }), + ); + + // Ask for confirmation before creating the index + const confirmed = await getConfirmationWithClick( + l10n.t('Create index?'), + payload.indexOptions?.name + ? l10n.t('Create index "{indexName}" on collection "{collectionName}"?', { + indexName: payload.indexOptions.name, + collectionName: payload.collectionName, + }) + : l10n.t('Create index on collection "{collectionName}"?', { + collectionName: payload.collectionName, + }), + ); + + if (!confirmed) { + return { + success: false, + message: l10n.t('Index creation cancelled'), + }; + } + + const session = ClusterSession.getSession(actualSessionId); + const client = session.getClient(); + + const result = await client.createIndex(payload.databaseName, payload.collectionName, payload.indexSpec); + + if (result.ok === 1) { + // Provide positive feedback with additional context from result.note if available + const baseMessage = l10n.t('Index "{indexName}" created successfully', { + indexName: result.indexName ?? 'unnamed', + }); + const message = + typeof result.note === 'string' && result.note ? `${baseMessage}. ${result.note}` : baseMessage; + + ext.outputChannel.trace(l10n.t('[Query Insights Action] Create index action completed successfully')); + + return { + success: true, + message, + }; + } else { + const errorMsg = typeof result.note === 'string' ? result.note : 'Unknown error'; + ext.outputChannel.error( + l10n.t('[Query Insights Action] Create index action failed: {error}', { error: errorMsg }), + ); + + return { + success: false, + message: l10n.t('Failed to create index: {error}', { error: errorMsg }), + }; + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + ext.outputChannel.error( + l10n.t('[Query Insights Action] Create index action error: {error}', { error: errorMessage }), + ); + + return { + success: false, + message: l10n.t('Error creating index: {error}', { error: errorMessage }), + }; + } + } + + /** + * Handles drop index action + */ + private async handleDropIndex( + sessionId: string | undefined, + payload: unknown, + ): Promise<{ success: boolean; message?: string }> { + try { + // Validate payload + if (!this.isDropIndexPayload(payload)) { + ext.outputChannel.warn(l10n.t('[Query Insights Action] Invalid payload for drop index action', {})); + return { + success: false, + message: l10n.t('Invalid payload for drop index action'), + }; + } + + // Get session and client + const actualSessionId = sessionId ?? payload.sessionId; + if (!actualSessionId) { + ext.outputChannel.warn(l10n.t('[Query Insights Action] Session ID is required', {})); + return { + success: false, + message: l10n.t('Session ID is required'), + }; + } + + ext.outputChannel.trace( + l10n.t( + '[Query Insights Action] Executing dropIndex action for "{indexName}" on collection: {collection}', + { + indexName: payload.indexName, + collection: `${payload.databaseName}.${payload.collectionName}`, + }, + ), + ); + + // Ask for confirmation before dropping the index (destructive action) + const confirmed = await getConfirmationAsInSettings( + l10n.t('Delete index?'), + (payload.indexName + ? l10n.t('Delete index "{indexName}" from collection "{collectionName}"?', { + indexName: payload.indexName, + collectionName: payload.collectionName, + }) + : l10n.t('Delete index from collection "{collectionName}"?', { + collectionName: payload.collectionName, + })) + + '\n' + + l10n.t('This cannot be undone.'), + payload.indexName || 'delete', + ); + + if (!confirmed) { + return { + success: false, + message: l10n.t('Index deletion cancelled'), + }; + } + + const session = ClusterSession.getSession(actualSessionId); + const client = session.getClient(); + + const result = await client.dropIndex(payload.databaseName, payload.collectionName, payload.indexName); + + if (result.ok === 1) { + // Provide positive feedback with additional context from result.note if available + const baseMessage = l10n.t('Index "{indexName}" dropped successfully', { + indexName: payload.indexName, + }); + const message = + typeof result.note === 'string' && result.note ? `${baseMessage}. ${result.note}` : baseMessage; + + ext.outputChannel.trace(l10n.t('[Query Insights Action] Drop index action completed successfully')); + + return { + success: true, + message, + }; + } else { + const errorMsg = typeof result.note === 'string' ? result.note : 'Unknown error'; + ext.outputChannel.error( + l10n.t('[Query Insights Action] Drop index action failed: {error}', { error: errorMsg }), + ); + + return { + success: false, + message: l10n.t('Failed to drop index: {error}', { error: errorMsg }), + }; + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + ext.outputChannel.error( + l10n.t('[Query Insights Action] Drop index action error: {error}', { error: errorMessage }), + ); + + return { + success: false, + message: l10n.t('Error dropping index: {error}', { error: errorMessage }), + }; + } + } + + /** + * Handles modify index action + */ + private async handleModifyIndex( + sessionId: string | undefined, + payload: unknown, + ): Promise<{ success: boolean; message?: string }> { + try { + // Validate payload + if (!this.isModifyIndexPayload(payload)) { + ext.outputChannel.warn(l10n.t('[Query Insights Action] Invalid payload for modify index action', {})); + return { + success: false, + message: l10n.t('Invalid payload for modify index action'), + }; + } + + const parseOperationPattern = /db\.getCollection\(['"]([^'"]+)['"]\)\.(\w+)\((.*)\)/; + const match = payload.mongoShell.match(parseOperationPattern); + if (!match || match.length < 3 || (match[2] !== 'hideIndex' && match[2] !== 'unhideIndex')) { + ext.outputChannel.warn( + l10n.t('[Query Insights Action] Invalid mongoShell command format: {command}', { + command: payload.mongoShell, + }), + ); + return { + success: false, + message: l10n.t('Invalid mongoShell command format'), + }; + } + + const operation = match[2]; + const indexName = match[3].replace(/['"]/g, '').trim(); + + // Get session and client + const actualSessionId = sessionId ?? payload.sessionId; + if (!actualSessionId) { + ext.outputChannel.warn(l10n.t('[Query Insights Action] Session ID is required', {})); + return { + success: false, + message: l10n.t('Session ID is required'), + }; + } + + ext.outputChannel.trace( + l10n.t( + '[Query Insights Action] Executing {operation} action for "{indexName}" on collection: {collection}', + { + operation, + indexName, + collection: `${payload.databaseName}.${payload.collectionName}`, + }, + ), + ); + + // Ask for confirmation before modifying the index + const operationText = operation === 'hideIndex' ? l10n.t('hide') : l10n.t('unhide'); + const confirmed = await getConfirmationWithClick( + l10n.t('Modify index?'), + indexName + ? l10n.t('This will {operation} the index "{indexName}" on collection "{collectionName}".', { + operation: operationText, + indexName, + collectionName: payload.collectionName, + }) + : l10n.t('This will {operation} an index on collection "{collectionName}".', { + operation: operationText, + collectionName: payload.collectionName, + }), + ); + + if (!confirmed) { + return { + success: false, + message: l10n.t('Index modification cancelled'), + }; + } + + const session = ClusterSession.getSession(actualSessionId); + const client = session.getClient(); + + // Execute the operation + let result: Document; + if (operation === 'hideIndex') { + result = await client.hideIndex(payload.databaseName, payload.collectionName, indexName); + } else { + // unhideIndex + result = await client.unhideIndex(payload.databaseName, payload.collectionName, indexName); + } + + if (result.ok === 1) { + // Provide positive feedback with additional context from result.note if available + const baseMessage = l10n.t('Index "{indexName}" {operation} successfully', { + indexName, + operation, + }); + const message = + typeof result.note === 'string' && result.note ? `${baseMessage}. ${result.note}` : baseMessage; + + ext.outputChannel.trace(l10n.t('[Query Insights Action] Modify index action completed successfully')); + + return { + success: true, + message, + }; + } else { + const errmsg = + typeof result.errmsg === 'string' + ? result.errmsg + : typeof result.note === 'string' + ? result.note + : 'Unknown error'; + ext.outputChannel.error( + l10n.t('[Query Insights Action] Modify index action failed: {error}', { error: errmsg }), + ); + + return { + success: false, + message: l10n.t('Failed to modify index: {error}', { error: errmsg }), + }; + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + ext.outputChannel.error( + l10n.t('[Query Insights Action] Modify index action error: {error}', { error: errorMessage }), + ); + + return { + success: false, + message: l10n.t('Error modifying index: {error}', { error: errorMessage }), + }; + } + } + + /** + * Handles learn more action + */ + private handleLearnMore(payload: unknown): { success: boolean; message?: string } { + // TODO: Open documentation link in browser + console.log('Opening documentation for:', payload); + + return { + success: true, + message: 'Documentation opened (mock)', + }; + } +} diff --git a/src/webviews/documentdb/sharedStyles.scss b/src/services/ai/index.ts similarity index 83% rename from src/webviews/documentdb/sharedStyles.scss rename to src/services/ai/index.ts index 4d67d1aeb..3ddb076e5 100644 --- a/src/webviews/documentdb/sharedStyles.scss +++ b/src/services/ai/index.ts @@ -3,6 +3,5 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -@use '../index.scss'; - -$media-breakpoint-query-control-area: 1024px; +export * from './QueryInsightsAIService'; +export * from './types'; diff --git a/src/services/ai/types.ts b/src/services/ai/types.ts new file mode 100644 index 000000000..1f9241d8b --- /dev/null +++ b/src/services/ai/types.ts @@ -0,0 +1,42 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * AI Service Types + * Internal types used by QueryInsightsAIService + */ + +/** + * AI backend request payload + */ +export interface AIOptimizationRequest { + query: string; // The DocumentDB query + databaseName: string; // Database name + collectionName: string; // Collection name +} + +/** + * AI backend response schema + */ +export interface AIOptimizationResponse { + analysis: string; + improvements: AIIndexRecommendation[]; + verification: string[]; + educationalContent?: string; // Optional markdown content for educational cards +} + +/** + * Individual index recommendation from AI + */ +export interface AIIndexRecommendation { + action: 'create' | 'drop' | 'none' | 'modify'; + indexSpec: Record; // e.g., { user_id: 1, status: 1 } + indexOptions?: Record; + indexName: string; // Name of the index + mongoShell: string; // MongoDB shell command + justification: string; // Why this recommendation + priority: 'high' | 'medium' | 'low'; + risks?: string; // Potential risks or side effects +} diff --git a/src/services/copilotService.ts b/src/services/copilotService.ts new file mode 100644 index 000000000..11cac68f7 --- /dev/null +++ b/src/services/copilotService.ts @@ -0,0 +1,151 @@ +/*--------------------------------------------------------------------------------------------- + * 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 * as vscode from 'vscode'; + +/** + * Options for sending a message to the language model + */ +export interface CopilotMessageOptions { + /* The preferred model to use + If the specified model is not available, will fall back to available models + */ + preferredModel?: string; + + /* List of fallback models */ + fallbackModels?: string[]; + + // TODO: + /* Temperature setting for the model (if supported later) */ + // temperature?: number; + + /* Maximum tokens for the response (if supported later) */ + // maxTokens?: number; +} + +/** + * Response from the Copilot service + */ +export interface CopilotResponse { + /* The generated text response */ + text: string; + /* The model used to generate the response */ + modelUsed: string; +} + +/** + * Service for interacting with GitHub Copilot's LLM + */ +export class CopilotService { + /** + * Sends a message to the Copilot LLM and returns the response + * + * @param messages - Array of chat messages to send to the model + * @param options - Options for the request + * @returns The response from the model + * @throws Error if no suitable model is available or if the user cancels + */ + static async sendMessage( + messages: vscode.LanguageModelChatMessage[], + options?: CopilotMessageOptions, + ): Promise { + // Get all available models from VS Code + const availableModels = await vscode.lm.selectChatModels({ vendor: 'copilot' }); + + const preferredModels = this.getPreferredModels(options); + const selectedModel = this.selectBestModel(availableModels, preferredModels); + + if (!selectedModel) { + throw new Error( + l10n.t( + 'No suitable language model found. Please ensure GitHub Copilot is installed and you have an active subscription.', + ), + ); + } + + const response = await this.sendToModel(selectedModel, messages, options); + return { + text: response, + modelUsed: selectedModel.id, + }; + } + + /** + * Builds the ordered list of preferred models + */ + private static getPreferredModels(options?: CopilotMessageOptions): string[] { + const models: string[] = []; + + if (options?.preferredModel) { + models.push(options.preferredModel); + } + + if (options?.fallbackModels && options.fallbackModels.length > 0) { + models.push(...options.fallbackModels); + } + + return models; + } + + /** + * Selects the best available model based on preference order + * + * @param availableModels - All available models from VS Code + * @param preferredModels - Ordered list of preferred model families + * @returns The best matching model, or the first available model if no matches + */ + private static selectBestModel( + availableModels: vscode.LanguageModelChat[], + preferredModels: string[], + ): vscode.LanguageModelChat | undefined { + if (availableModels.length === 0) { + return undefined; + } + + if (preferredModels.length !== 0) { + for (const preferredModel of preferredModels) { + const matchingModel = availableModels.find((model) => model.id === preferredModel); + if (matchingModel) { + return matchingModel; + } + } + } + return availableModels[0]; + } + + /** + * Sends messages to a specific model and collects the response + */ + private static async sendToModel( + model: vscode.LanguageModelChat, + messages: vscode.LanguageModelChatMessage[], + _options?: CopilotMessageOptions, + ): Promise { + // Github copilot LLM API currently doesn't support temperature or maxTokens in + // LanguageModelChatRequestOptions, but we keep them here for potential future use + const requestOptions: vscode.LanguageModelChatRequestOptions = {}; + + const chatResponse = await model.sendRequest(messages, requestOptions); + + // Collect the streaming response + let fullResponse = ''; + for await (const fragment of chatResponse.text) { + fullResponse += fragment; + } + + return fullResponse; + } + + /** + * Checks if LLMs are available + * + * @returns true if at least one model is available + */ + static async isAvailable(): Promise { + const models = await vscode.lm.selectChatModels({ vendor: 'copilot' }); + return models.length > 0; + } +} diff --git a/src/services/promptTemplateService.ts b/src/services/promptTemplateService.ts new file mode 100644 index 000000000..89624364c --- /dev/null +++ b/src/services/promptTemplateService.ts @@ -0,0 +1,248 @@ +/*--------------------------------------------------------------------------------------------- + * 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 * as fs from 'fs/promises'; +import * as path from 'path'; +import * as vscode from 'vscode'; +import { CommandType } from '../commands/llmEnhancedCommands/indexAdvisorCommands'; +import { + AGGREGATE_QUERY_PROMPT_TEMPLATE, + COUNT_QUERY_PROMPT_TEMPLATE, + CROSS_COLLECTION_QUERY_PROMPT_TEMPLATE, + FIND_QUERY_PROMPT_TEMPLATE, + SINGLE_COLLECTION_QUERY_PROMPT_TEMPLATE, +} from '../commands/llmEnhancedCommands/promptTemplates'; +import { QueryGenerationType } from '../commands/llmEnhancedCommands/queryGenerationCommands'; + +/** + * Service for loading prompt templates from custom files or built-in templates + */ +export class PromptTemplateService { + private static readonly configSection = 'documentDB.aiAssistant'; + private static readonly templateCache: Map = new Map(); + + /** + * Gets the prompt template for index advisor + * @param commandType The type of command (find, aggregate, or count) + * @returns The prompt template string + */ + public static async getIndexAdvisorPromptTemplate(commandType: CommandType): Promise { + // Get configuration + const config = vscode.workspace.getConfiguration(this.configSection); + const cacheEnabled = config.get('enablePromptCache', true); + + // Check if have a cached template + if (cacheEnabled) { + const cached = this.templateCache.get(commandType); + if (cached) { + return cached; + } + } + + // Get the configuration key for this command type + const configKey = this.getIndexAdvisorConfigKey(commandType); + + // Check if a custom template path is configured + const customTemplatePath = config.get(configKey); + + let template: string; + + if (customTemplatePath) { + try { + // Load custom template from file + template = await this.loadTemplateFromFile(customTemplatePath, commandType.toString()); + void vscode.window.showInformationMessage( + l10n.t('Using custom prompt template for {type} query: {path}', { + type: commandType, + path: customTemplatePath, + }), + ); + } catch (error) { + // Log error and fall back to built-in template + void vscode.window.showWarningMessage( + l10n.t('Failed to load custom prompt template from {path}: {error}. Using built-in template.', { + path: customTemplatePath, + error: error instanceof Error ? error.message : String(error), + }), + ); + template = this.getBuiltInIndexAdvisorTemplate(commandType); + } + } else { + // Use built-in template + template = this.getBuiltInIndexAdvisorTemplate(commandType); + } + + // Cache the template (if caching is enabled) + if (cacheEnabled) { + this.templateCache.set(commandType, template); + } + + return template; + } + + /** + * Gets the prompt template for query generation + * @param generationType The type of query generation (cross-collection or single-collection) + * @returns The prompt template string + */ + public static async getQueryGenerationPromptTemplate(generationType: QueryGenerationType): Promise { + // Get configuration + const config = vscode.workspace.getConfiguration(this.configSection); + const configKey = this.getQueryGenerationConfigKey(generationType); + const customTemplatePath = config.get(configKey); + + let template: string; + + if (customTemplatePath) { + try { + template = await this.loadTemplateFromFile(customTemplatePath, generationType.toString()); + void vscode.window.showInformationMessage( + l10n.t('Using custom prompt template for {type} query generation: {path}', { + type: generationType, + path: customTemplatePath, + }), + ); + } catch (error) { + void vscode.window.showWarningMessage( + l10n.t('Failed to load custom prompt template from {path}: {error}. Using built-in template.', { + path: customTemplatePath, + error: error instanceof Error ? error.message : String(error), + }), + ); + template = this.getBuiltInQueryGenerationTemplate(generationType); + } + } else { + // Use built-in template + template = this.getBuiltInQueryGenerationTemplate(generationType); + } + + return template; + } + + /** + * Clears the template cache, forcing templates to be reloaded on next use + */ + public static clearCache(): void { + this.templateCache.clear(); + } + + /** + * Gets the configuration key for a command type + * @param commandType The command type + * @returns The configuration key + */ + private static getIndexAdvisorConfigKey(commandType: CommandType): string { + switch (commandType) { + case CommandType.Find: + return 'findQueryPromptPath'; + case CommandType.Aggregate: + return 'aggregateQueryPromptPath'; + case CommandType.Count: + return 'countQueryPromptPath'; + default: + throw new Error(l10n.t('Unknown command type: {type}', { type: commandType })); + } + } + + /** + * Gets the configuration key for a query generation type + * @param generationType The query generation type + * @returns The configuration key + */ + private static getQueryGenerationConfigKey(generationType: QueryGenerationType): string { + switch (generationType) { + case QueryGenerationType.CrossCollection: + return 'crossCollectionQueryPromptPath'; + case QueryGenerationType.SingleCollection: + return 'singleCollectionQueryPromptPath'; + default: + throw new Error(l10n.t('Unknown query generation type: {type}', { type: generationType })); + } + } + + /** + * Gets the built-in prompt template for a command type + * @param commandType The command type + * @returns The built-in template + */ + private static getBuiltInIndexAdvisorTemplate(commandType: CommandType): string { + switch (commandType) { + case CommandType.Find: + return FIND_QUERY_PROMPT_TEMPLATE; + case CommandType.Aggregate: + return AGGREGATE_QUERY_PROMPT_TEMPLATE; + case CommandType.Count: + return COUNT_QUERY_PROMPT_TEMPLATE; + default: + throw new Error(l10n.t('Unknown command type: {type}', { type: commandType })); + } + } + + /** + * Gets the built-in prompt template for a query generation type + * @param generationType The query generation type + * @returns The built-in template + */ + private static getBuiltInQueryGenerationTemplate(generationType: QueryGenerationType): string { + switch (generationType) { + case QueryGenerationType.CrossCollection: + return CROSS_COLLECTION_QUERY_PROMPT_TEMPLATE; + case QueryGenerationType.SingleCollection: + return SINGLE_COLLECTION_QUERY_PROMPT_TEMPLATE; + default: + throw new Error(l10n.t('Unknown query generation type: {type}', { type: generationType })); + } + } + + /** + * Loads a template from a file path + * @param filePath The absolute or relative file path + * @param templateType The template type identifier (for error messages) + * @returns The template content + */ + private static async loadTemplateFromFile(filePath: string, templateType: string): Promise { + try { + let resolvedPath = filePath; + if (!path.isAbsolute(filePath)) { + const workspaceFolders = vscode.workspace.workspaceFolders; + if (workspaceFolders && workspaceFolders.length > 0) { + resolvedPath = path.join(workspaceFolders[0].uri.fsPath, filePath); + } + } + + // Check if file exists + try { + await fs.access(resolvedPath); + } catch { + throw new Error( + l10n.t('Template file not found: {path}', { + path: resolvedPath, + }), + ); + } + + // Read the file + const content = await fs.readFile(resolvedPath, 'utf-8'); + + if (!content || content.trim().length === 0) { + throw new Error( + l10n.t('Template file is empty: {path}', { + path: resolvedPath, + }), + ); + } + + return content; + } catch (error) { + throw new Error( + l10n.t('Failed to load template file for {type}: {error}', { + type: templateType, + error: error instanceof Error ? error.message : String(error), + }), + ); + } + } +} diff --git a/src/tree/azure-resources-view/documentdb/VCoreResourceItem.ts b/src/tree/azure-resources-view/documentdb/VCoreResourceItem.ts index 0f381fb78..530db9f02 100644 --- a/src/tree/azure-resources-view/documentdb/VCoreResourceItem.ts +++ b/src/tree/azure-resources-view/documentdb/VCoreResourceItem.ts @@ -13,6 +13,7 @@ import { import { type AzureSubscription } from '@microsoft/vscode-azureresources-api'; import * as l10n from '@vscode/l10n'; import * as vscode from 'vscode'; +import { getThemeAgnosticIconURI } from '../../../constants'; import { AuthMethodId } from '../../../documentdb/auth/AuthMethod'; import { ClustersClient } from '../../../documentdb/ClustersClient'; import { CredentialCache } from '../../../documentdb/CredentialCache'; @@ -31,16 +32,7 @@ import { ClusterItemBase, type EphemeralClusterCredentials } from '../../documen import { type ClusterModel } from '../../documentdb/ClusterModel'; export class VCoreResourceItem extends ClusterItemBase { - iconPath = vscode.Uri.joinPath( - ext.context.extensionUri, - 'resources', - 'from_node_modules', - '@microsoft', - 'vscode-azext-azureutils', - 'resources', - 'azureIcons', - 'MongoClusters.svg', - ); + iconPath = getThemeAgnosticIconURI('AzureDocumentDb.svg'); constructor( readonly subscription: AzureSubscription, diff --git a/src/tree/azure-resources-view/mongo-ru/RUCoreResourceItem.ts b/src/tree/azure-resources-view/mongo-ru/RUCoreResourceItem.ts index df387ece9..0f20b3fa2 100644 --- a/src/tree/azure-resources-view/mongo-ru/RUCoreResourceItem.ts +++ b/src/tree/azure-resources-view/mongo-ru/RUCoreResourceItem.ts @@ -28,7 +28,7 @@ export class RUResourceItem extends ClusterItemBase { 'vscode-azext-azureutils', 'resources', 'azureIcons', - 'MongoClusters.svg', + 'AzureCosmosDb.svg', ); constructor( diff --git a/src/tree/documentdb/ClusterItemBase.ts b/src/tree/documentdb/ClusterItemBase.ts index 61c9de917..203f9716f 100644 --- a/src/tree/documentdb/ClusterItemBase.ts +++ b/src/tree/documentdb/ClusterItemBase.ts @@ -104,7 +104,7 @@ export abstract class ClusterItemBase * Authenticates and connects to the cluster to list all available databases. * Here, the MongoDB client is created and cached for future use. * - * In case of the Azure environment (vCore), we might reach out to Azure to pull + * In case of the Azure environment (DocumentDB), we might reach out to Azure to pull * the list of users known to the cluster. * * (These operations can be slow as they involve network and authentication calls.) diff --git a/src/tree/documentdb/ClusterModel.ts b/src/tree/documentdb/ClusterModel.ts index 3c6859941..cc30c8138 100644 --- a/src/tree/documentdb/ClusterModel.ts +++ b/src/tree/documentdb/ClusterModel.ts @@ -62,7 +62,7 @@ interface ResourceModelInUse extends Resource { // introduced new properties resourceGroup?: string; - // adding support for MongoRU and vCore + // adding support for MongoRU and DocumentDB dbExperience: Experience; /** diff --git a/src/tree/documentdb/IndexItem.ts b/src/tree/documentdb/IndexItem.ts index ffc389594..996d6c865 100644 --- a/src/tree/documentdb/IndexItem.ts +++ b/src/tree/documentdb/IndexItem.ts @@ -32,18 +32,30 @@ export class IndexItem implements TreeElement, TreeElementWithExperience, TreeEl } async getChildren(): Promise { - return Object.keys(this.indexInfo.key).map((key) => { - const value = this.indexInfo.key[key]; - - return createGenericElement({ - contextValue: key, - id: `${this.id}/${key}`, - label: key, - // TODO: add a custom icons, and more options here - description: value === -1 ? 'desc' : value === 1 ? 'asc' : value.toString(), - iconPath: new vscode.ThemeIcon('combine'), - }) as TreeElement; - }); + // Use key if available, otherwise show not supported and will be handled in the future (for search indexes) + if (this.indexInfo.key) { + return Object.keys(this.indexInfo.key).map((key) => { + const value = this.indexInfo.key![key]; + + return createGenericElement({ + contextValue: key, + id: `${this.id}/${key}`, + label: key, + description: value === -1 ? 'desc' : value === 1 ? 'asc' : value.toString(), + iconPath: new vscode.ThemeIcon('combine'), + }) as TreeElement; + }); + } else { + return [ + createGenericElement({ + contextValue: 'indexField', + id: `${this.id}/notSupported`, + label: 'Support coming soon', + description: '', + iconPath: new vscode.ThemeIcon('combine'), + }) as TreeElement, + ]; + } } getTreeItem(): vscode.TreeItem { @@ -51,8 +63,169 @@ export class IndexItem implements TreeElement, TreeElementWithExperience, TreeEl id: this.id, contextValue: this.contextValue, label: this.indexInfo.name, + tooltip: this.buildTooltip(), iconPath: new vscode.ThemeIcon('combine'), // TODO: create our onw icon here, this one's shape can change collapsibleState: vscode.TreeItemCollapsibleState.Collapsed, }; } + + private buildTooltip(): vscode.MarkdownString { + const md = new vscode.MarkdownString(); + md.supportHtml = true; + md.isTrusted = true; + md.supportThemeIcons = true; + + md.appendMarkdown(`### ${this.indexInfo.name}\n\n`); + + const badges: string[] = []; + badges.push(`\`${this.indexInfo.type}\``); + if (this.indexInfo.unique) { + badges.push('`unique`'); + } + if (this.indexInfo.sparse) { + badges.push('`sparse`'); + } + if (this.indexInfo.hidden) { + badges.push('`hidden`'); + } + if (badges.length > 0) { + md.appendMarkdown(`${badges.join(' | ')}\n\n`); + } + + md.appendMarkdown('---\n\n'); + + const properties: Array<{ label: string; value: string }> = []; + + if (this.indexInfo.version !== undefined) { + properties.push({ label: 'Version', value: `v${this.indexInfo.version}` }); + } + + if (this.indexInfo.status) { + properties.push({ label: 'Status', value: this.indexInfo.status }); + } + + if (this.indexInfo.queryable !== undefined) { + properties.push({ label: 'Queryable', value: this.indexInfo.queryable ? 'Yes' : 'No' }); + } + + if (this.indexInfo.expireAfterSeconds !== undefined) { + properties.push({ label: 'TTL', value: `${this.indexInfo.expireAfterSeconds}s` }); + } + + // Additional boolean properties + const booleanProps = [ + { key: 'unique' as const, label: 'Unique' }, + { key: 'sparse' as const, label: 'Sparse' }, + { key: 'background' as const, label: 'Background Build' }, + { key: 'hidden' as const, label: 'Hidden' }, + { key: 'wildcardProjection' as const, label: 'Wildcard Projection' }, + { key: 'enableOrderedIndex' as const, label: 'Enable Ordered Index' }, + ]; + + for (const prop of booleanProps) { + if (this.indexInfo[prop.key] !== undefined) { + properties.push({ label: prop.label, value: this.indexInfo[prop.key] ? 'Yes' : 'No' }); + } + } + + // Render properties in a clean format + if (properties.length > 0) { + for (const prop of properties) { + md.appendMarkdown(`**${prop.label}:** ${prop.value} \n`); + } + md.appendMarkdown('\n'); + } + + // Index definition section + if (this.indexInfo.key) { + md.appendMarkdown('---\n\n'); + md.appendMarkdown('**Index Definition**\n\n'); + + // Format keys in a readable way + const keyEntries = Object.entries(this.indexInfo.key); + if (keyEntries.length <= 3) { + // For simple indexes, show inline + const keyStrings = keyEntries.map(([field, order]) => { + const orderStr = order === -1 ? 'desc' : order === 1 ? 'asc' : String(order); + return `\`${field}\`: ${orderStr}`; + }); + md.appendMarkdown(keyStrings.join(', ') + '\n\n'); + } else { + // For complex indexes, show as code block + md.appendMarkdown('```json\n'); + md.appendMarkdown(JSON.stringify(this.indexInfo.key, null, 2)); + md.appendMarkdown('\n```\n\n'); + } + } + + // Partial filter (if exists) + if (this.indexInfo.partialFilterExpression) { + md.appendMarkdown('---\n\n'); + md.appendMarkdown('**Partial Filter Expression**\n\n'); + md.appendMarkdown('```json\n'); + md.appendMarkdown(JSON.stringify(this.indexInfo.partialFilterExpression, null, 2)); + md.appendMarkdown('\n```\n\n'); + } + + // Fields (for search indexes) + if (this.indexInfo.fields && Array.isArray(this.indexInfo.fields) && this.indexInfo.fields.length > 0) { + md.appendMarkdown('---\n\n'); + md.appendMarkdown('**Search Fields**\n\n'); + md.appendMarkdown('```json\n'); + md.appendMarkdown(JSON.stringify(this.indexInfo.fields, null, 2)); + md.appendMarkdown('\n```\n\n'); + } + + // // Action buttons at the bottom + // md.appendMarkdown('---\n\n'); + // md.appendMarkdown('**Actions:**\n\n'); + + // // Create command URIs with encoded arguments + // const dropIndexArgs = encodeURIComponent( + // JSON.stringify([ + // { + // cluster: this.cluster.id, + // databaseInfo: this.databaseInfo.name, + // collectionInfo: this.collectionInfo.name, + // indexInfo: this.indexInfo.name, + // }, + // ]), + // ); + + // const hideUnhideArgs = encodeURIComponent( + // JSON.stringify([ + // { + // cluster: this.cluster.id, + // databaseInfo: this.databaseInfo.name, + // collectionInfo: this.collectionInfo.name, + // indexInfo: this.indexInfo.name, + // }, + // ]), + // ); + + // // TODO: wire up buttons with actual commands + // // Drop index button (only if not _id index) + // if (this.indexInfo.name !== '_id_') { + // md.appendMarkdown( + // `[$(trash) Drop Index](command:vscode-documentdb.command.dropIndex?${dropIndexArgs} "Delete this index")   `, + // ); + // } + + // // Hide/Unhide button + // if (this.indexInfo.name !== '_id_') { + // const hideUnhideText = this.indexInfo.hidden ? '$(eye) Unhide Index' : '$(eye-closed) Hide Index'; + // const hideUnhideCommand = this.indexInfo.hidden + // ? 'vscode-documentdb.command.unhideIndex' + // : 'vscode-documentdb.command.hideIndex'; + // const hideUnhideTooltip = this.indexInfo.hidden + // ? 'Make this index visible' + // : 'Hide this index from queries'; + + // md.appendMarkdown( + // `[${hideUnhideText}](command:${hideUnhideCommand}?${hideUnhideArgs} "${hideUnhideTooltip}")   `, + // ); + // } + + return md; + } } diff --git a/src/tree/documentdb/IndexesItem.ts b/src/tree/documentdb/IndexesItem.ts index 2308ea2d1..d278c3fb9 100644 --- a/src/tree/documentdb/IndexesItem.ts +++ b/src/tree/documentdb/IndexesItem.ts @@ -35,6 +35,21 @@ export class IndexesItem implements TreeElement, TreeElementWithExperience, Tree async getChildren(): Promise { const client: ClustersClient = await ClustersClient.getClient(this.cluster.id); const indexes = await client.listIndexes(this.databaseInfo.name, this.collectionInfo.name); + + // Try to get search indexes, but silently fail if not supported by the platform + try { + const searchIndexes = await client.listSearchIndexesForAtlas( + this.databaseInfo.name, + this.collectionInfo.name, + ); + indexes.push(...searchIndexes); + } catch { + // Search indexes not supported on this platform, continue without them + } + + // Sort indexes by name + indexes.sort((a, b) => a.name.localeCompare(b.name)); + return indexes.map((index) => { return new IndexItem(this.cluster, this.databaseInfo, this.collectionInfo, index); }); diff --git a/src/utils/schemaInference.ts b/src/utils/schemaInference.ts new file mode 100644 index 000000000..b7226affd --- /dev/null +++ b/src/utils/schemaInference.ts @@ -0,0 +1,339 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Binary, Decimal128, Double, Int32, Long, ObjectId, Timestamp, type Document } from 'mongodb'; + +type PrimitiveType = + | 'string' + | 'number' + | 'boolean' + | 'objectId' + | 'date' + | 'binary' + | 'regex' + | 'timestamp' + | 'undefined' + | 'unknown'; + +interface FieldSummary { + primitiveTypes: Set; + hasNull: boolean; + objectProperties?: Map; + arraySummary?: ArraySummary; +} + +interface ArraySummary { + elementSummary: FieldSummary; + vectorLengths: Set; + nonVectorObserved: boolean; + sawValues: boolean; +} + +export interface SchemaDefinition { + collectionName?: string; + fields: Record; +} + +export type SchemaFieldDefinition = string | SchemaObjectDefinition | SchemaArrayDefinition | SchemaUnionDefinition; + +export interface SchemaObjectDefinition { + type: 'object'; + properties: Record; +} + +export interface SchemaArrayDefinition { + type: 'array'; + items: SchemaFieldDefinition; + vectorLength?: number; +} + +export interface SchemaUnionDefinition { + type: 'union'; + variants: SchemaFieldDefinition[]; +} + +export function generateSchemaDefinition(documents: Array, collectionName?: string): SchemaDefinition { + const root = new Map(); + + for (const doc of documents) { + recordDocument(root, doc); + } + + const fields = convertProperties(root); + + const schema: SchemaDefinition = { fields }; + if (collectionName) { + schema.collectionName = collectionName; + } + + return schema; +} + +function recordDocument(target: Map, doc: Document): void { + for (const [key, value] of Object.entries(doc)) { + const summary = target.get(key) ?? createFieldSummary(); + recordValue(summary, value); + target.set(key, summary); + } +} + +function recordValue(summary: FieldSummary, value: unknown): void { + if (value === null) { + summary.hasNull = true; + return; + } + + if (Array.isArray(value)) { + handleArray(summary, value); + return; + } + + if (isPlainObject(value)) { + handleObject(summary, value as Record); + return; + } + + summary.primitiveTypes.add(getPrimitiveType(value)); +} + +function handleObject(summary: FieldSummary, value: Record): void { + const properties = summary.objectProperties ?? new Map(); + summary.objectProperties = properties; + + for (const [key, nested] of Object.entries(value)) { + const nestedSummary = properties.get(key) ?? createFieldSummary(); + recordValue(nestedSummary, nested); + properties.set(key, nestedSummary); + } +} + +function handleArray(summary: FieldSummary, values: Array): void { + const arraySummary = summary.arraySummary ?? createArraySummary(); + summary.arraySummary = arraySummary; + + arraySummary.sawValues ||= values.length > 0; + const vectorCandidate = values.length > 0 && values.every((element) => isNumericValue(element)); + + if (vectorCandidate) { + arraySummary.vectorLengths.add(values.length); + } else if (values.length > 0) { + arraySummary.nonVectorObserved = true; + } + + for (const element of values) { + recordValue(arraySummary.elementSummary, element); + } +} + +function getPrimitiveType(value: unknown): PrimitiveType { + if (value === undefined) { + return 'undefined'; + } + + if (typeof value === 'string') { + return 'string'; + } + + if (typeof value === 'number' || typeof value === 'bigint') { + return 'number'; + } + + if (typeof value === 'boolean') { + return 'boolean'; + } + + if (value instanceof Date) { + return 'date'; + } + + if (value instanceof RegExp) { + return 'regex'; + } + + if (value instanceof Uint8Array || value instanceof Binary) { + return 'binary'; + } + + if (value instanceof Timestamp) { + return 'timestamp'; + } + + if (value instanceof ObjectId) { + return 'objectId'; + } + + if (value instanceof Decimal128 || value instanceof Double || value instanceof Int32 || value instanceof Long) { + return 'number'; + } + + const bsonType = getBsonType(value); + if (bsonType) { + return mapBsonType(bsonType); + } + + return 'unknown'; +} + +function mapBsonType(type: string): PrimitiveType { + const normalized = type.toLowerCase(); + + switch (normalized) { + case 'objectid': + return 'objectId'; + case 'decimal128': + case 'double': + case 'int32': + case 'long': + return 'number'; + case 'timestamp': + return 'timestamp'; + case 'binary': + return 'binary'; + default: + return 'unknown'; + } +} + +function getBsonType(value: unknown): string | undefined { + if (!value || typeof value !== 'object') { + return undefined; + } + + const potential = value as { _bsontype?: unknown }; + if (typeof potential._bsontype === 'string') { + return potential._bsontype; + } + + return undefined; +} + +function createFieldSummary(): FieldSummary { + return { + primitiveTypes: new Set(), + hasNull: false, + }; +} + +function createArraySummary(): ArraySummary { + return { + elementSummary: createFieldSummary(), + vectorLengths: new Set(), + nonVectorObserved: false, + sawValues: false, + }; +} + +function convertProperties(properties: Map): Record { + const result: Record = {}; + + for (const key of Array.from(properties.keys()).sort((a, b) => a.localeCompare(b))) { + result[key] = convertSummary(properties.get(key) as FieldSummary); + } + + return result; +} + +function convertSummary(summary: FieldSummary): SchemaFieldDefinition { + const variants: SchemaFieldDefinition[] = []; + + if (summary.primitiveTypes.size > 0) { + variants.push(combinePrimitiveTypes(summary.primitiveTypes)); + } + + if (summary.objectProperties && summary.objectProperties.size > 0) { + variants.push({ + type: 'object', + properties: convertProperties(summary.objectProperties), + }); + } + + if (summary.arraySummary) { + variants.push(convertArraySummary(summary.arraySummary)); + } + + if (summary.hasNull) { + variants.push('null'); + } + + if (variants.length === 0) { + return 'unknown'; + } + + if (variants.length === 1) { + return variants[0]; + } + + return { type: 'union', variants }; +} + +function combinePrimitiveTypes(types: Set): string { + const filtered = Array.from(types) + .filter((type) => type !== 'undefined') + .sort((a, b) => a.localeCompare(b)); + + if (filtered.length === 0) { + return types.has('undefined') ? 'undefined' : 'unknown'; + } + + return filtered.join(' | '); +} + +function convertArraySummary(summary: ArraySummary): SchemaArrayDefinition | SchemaUnionDefinition { + const items = convertSummary(summary.elementSummary); + const definition: SchemaArrayDefinition = { + type: 'array', + items, + }; + + if (!summary.nonVectorObserved && summary.vectorLengths.size === 1 && summary.sawValues) { + definition.vectorLength = Array.from(summary.vectorLengths)[0]; + } + + if (summary.elementSummary.hasNull && typeof items === 'string' && items === 'unknown') { + return { type: 'union', variants: [definition, 'null'] }; + } + + return definition; +} + +function isPlainObject(value: unknown): boolean { + if (typeof value !== 'object' || value === null) { + return false; + } + + if (Array.isArray(value)) { + return false; + } + + if (value instanceof Date || value instanceof RegExp) { + return false; + } + + if (value instanceof Uint8Array || value instanceof Binary) { + return false; + } + + if (getBsonType(value)) { + return false; + } + + return true; +} + +function isNumericValue(value: unknown): boolean { + if (typeof value === 'number') { + return Number.isFinite(value); + } + + if (typeof value === 'bigint') { + return true; + } + + if (value instanceof Decimal128 || value instanceof Double || value instanceof Int32 || value instanceof Long) { + return true; + } + + return getBsonType(value)?.toLowerCase() === 'decimal128'; +} diff --git a/src/utils/survey.ts b/src/utils/survey.ts index 9b98abf3a..0b8836fa3 100644 --- a/src/utils/survey.ts +++ b/src/utils/survey.ts @@ -242,7 +242,7 @@ async function initSurvey(): Promise { try { // Deterministic machine ID selection using MD5 (faster than SHA-256) // MD5 is sufficient for non-security random distribution needs - const buffer = crypto.createHash('md5').update(env.machineId).digest(); + const buffer = crypto.createHash('md5').update(env.machineId).digest(); // CodeQL [SM04514] MD5 is used for deterministic machine ID selection and not for any cryptographic security purposes. // Read a 32-bit unsigned integer from the buffer directly // (Using the first 4 bytes gives a full 32-bit range) diff --git a/src/webviews/REACT_ARCHITECTURE_GUIDELINES.md b/src/webviews/REACT_ARCHITECTURE_GUIDELINES.md new file mode 100644 index 000000000..c7dceaf4c --- /dev/null +++ b/src/webviews/REACT_ARCHITECTURE_GUIDELINES.md @@ -0,0 +1,901 @@ +# React Architecture Guidelines for DocumentDB Webviews + +This document describes the patterns, practices, and architectural decisions used in the React-based webviews of the vscode-documentdb extension. + +## Overview + +The webviews folder contains React-based UI components for two main views: + +- **`documentView/`**: Simpler, cleaner architecture for viewing/editing individual documents +- **`collectionView/`**: More complex view for querying and displaying collections with multiple data presentation modes + +--- + +## Table of Contents + +1. [Component Structure](#component-structure) +2. [State Management](#state-management) +3. [Styling Approach](#styling-approach) +4. [React Hooks Usage](#react-hooks-usage) +5. [Monaco Editor Integration](#monaco-editor-integration) +6. [Third-Party Component Integration](#third-party-component-integration) +7. [Common Patterns](#common-patterns) +8. [Known Issues & Anti-Patterns](#known-issues--anti-patterns) + +--- + +## Component Structure + +### File Organization + +Each view follows a consistent structure: + +``` +viewName/ +├── viewName.tsx # Main component +├── viewName.scss # View-specific styles +├── viewNameContext.ts # Context and state types (if complex) +├── components/ # Sub-components +│ ├── Component.tsx +│ ├── component.scss # Component-specific styles (if needed) +│ └── toolbar/ # Nested component groups +└── viewNameController.ts # Backend communication logic +``` + +### Component Hierarchy Example + +**DocumentView** (simpler): + +```tsx +DocumentView +├── ToolbarDocuments (toolbar component) +└── MonacoEditor (editor component) +``` + +**CollectionView** (more complex): + +```tsx +CollectionView +├── ToolbarMainView +├── QueryEditor +│ └── MonacoAutoHeight +├── DataView (switched based on currentView) +│ ├── DataViewPanelTable +│ ├── DataViewPanelTree +│ └── DataViewPanelJSON +└── ToolbarTableNavigation +``` + +--- + +## State Management + +### Local State with `useState` + +Used for simple, component-local state: + +```tsx +const [isLoading, setIsLoading] = useState(false); +const [isDirty, setIsDirty] = useState(true); +const [editorContent, setEditorContent] = useState('{ }'); +``` + +### Context API for Shared State + +**CollectionView** uses React Context for complex, cross-component state: + +```tsx +// Define context type +export type CollectionViewContextType = { + isLoading: boolean; + isFirstTimeLoad: boolean; + currentView: Views; + currentViewState?: TableViewState; + activeQuery: { + queryText: string; // deprecated: use filter instead + filter: string; + project: string; + sort: string; + skip: number; + limit: number; + pageNumber: number; + pageSize: number; + }; + commands: { + disableAddDocument: boolean; + disableViewDocument: boolean; + disableEditDocument: boolean; + disableDeleteDocument: boolean; + }; + dataSelection: { + selectedDocumentObjectIds: string[]; + selectedDocumentIndexes: number[]; + }; + queryEditor?: { + getCurrentQuery: () => { + filter: string; + project: string; + sort: string; + skip: number; + limit: number; + }; + setJsonSchema(schema: object): Promise; + }; + isAiRowVisible: boolean; +}; + +// Create context with tuple pattern [state, setState] +export const CollectionViewContext = createContext< + [CollectionViewContextType, React.Dispatch>] +>([DefaultCollectionViewContext, () => {}]); + +// Usage in parent component +const [currentContext, setCurrentContext] = useState(DefaultCollectionViewContext); + +return ( + + {/* children */} + +); + +// Usage in child component +const [currentContext, setCurrentContext] = useContext(CollectionViewContext); +``` + +**Note**: DocumentView is simpler and doesn't require context - it uses props and local state. + +### State Updates + +Always use functional updates when new state depends on previous state: + +```tsx +setCurrentContext((prev) => ({ + ...prev, + isLoading: true, + activeQuery: { + ...prev.activeQuery, + pageNumber: 1, + }, +})); +``` + +--- + +## Styling Approach + +### SCSS Files + +Each component can have its own `.scss` file. Styles are imported directly in the component: + +```tsx +import './collectionView.scss'; +``` + +### Shared Styles + +Common styles are in `sharedStyles.scss`: + +```scss +// src/webviews/documentdb/sharedStyles.scss +@use '../index.scss'; + +$media-breakpoint-query-control-area: 1024px; +``` + +Use `@extend` to apply shared styles: + +```scss +.collectionView { + @extend .selectionDisabled; + // ... other styles +} +``` + +### Spacing and Layout Patterns + +**Consistent spacing unit: `10px`** + +#### Flexbox Layouts with Gaps + +```scss +.documentView { + display: flex; + flex-direction: column; + height: 100vh; + row-gap: 10px; // Consistent 10px spacing between flex children +} + +.collectionView { + display: flex; + flex-direction: column; + row-gap: 10px; + height: 100vh; +} +``` + +#### Padding Patterns + +```scss +.toolbarContainer { + padding-top: 10px; // Top padding for toolbars +} + +.monacoAutoHeightContainer { + padding-top: 6px; + padding-bottom: 6px; + padding-right: 4px; +} +``` + +#### Flexbox Sizing + +```scss +.monacoContainer { + flex-grow: 1; // Take available space + flex-shrink: 1; // Allow shrinking + flex-basis: 0%; // Start from 0 and grow +} + +.toolbarContainer { + flex-grow: 0; // Don't grow + flex-shrink: 1; // Allow shrinking + flex-basis: auto; // Use content size +} +``` + +#### Negative Margins (Use with Caution) + +```scss +.toolbarTableNavigation { + margin-top: -10px; // Pull element up to reduce spacing +} +``` + +**⚠️ WARNING**: Negative margins can cause layout issues and should be used sparingly. Consider if the layout can be achieved with proper flexbox/gap instead. + +#### Inline Styles (Avoid When Possible) + +Some inline styles are used in components: + +```tsx + +``` + +**🔴 ISSUE**: Inline styles should be moved to SCSS files for consistency and maintainability. + +--- + +## React Hooks Usage + +### `useEffect` - Component Lifecycle + +#### Run Once on Mount (Empty Dependency Array) + +```tsx +useEffect(() => { + if (configuration.mode !== 'add') { + const documentId: string = configuration.documentId; + + setIsLoading(true); + + void trpcClient.mongoClusters.documentView.getDocumentById + .query(documentId) + .then((response) => { + setContent(response); + }) + .catch((error) => { + void trpcClient.common.displayErrorMessage.mutate({ + message: l10n.t('Error while loading the document'), + modal: false, + cause: error instanceof Error ? error.message : String(error), + }); + }) + .finally(() => { + setIsLoading(false); + }); + } +}, []); // Empty array = run once on mount +``` + +#### Run on Specific State Changes + +```tsx +useEffect(() => { + // Update query editor context whenever filter value changes + setCurrentContext((prev) => ({ + ...prev, + queryEditor: prev.queryEditor + ? { + ...prev.queryEditor, + getCurrentQuery: () => ({ + filter: filterValue, + project: projectValue, + sort: sortValue, + skip: skipValue, + limit: limitValue, + }), + } + : prev.queryEditor, + })); +}, [filterValue, projectValue, sortValue, skipValue, limitValue, setCurrentContext]); // Re-run when these change +``` + +#### Cleanup Pattern + +```tsx +useEffect(() => { + const debouncedResizeHandler = debounce(handleResize, 200); + window.addEventListener('resize', debouncedResizeHandler); + + // Cleanup function + return () => { + if (editorRef.current) { + editorRef.current.dispose(); + } + window.removeEventListener('resize', debouncedResizeHandler); + }; +}, []); +``` + +### `useRef` - Storing Mutable References + +#### Storing DOM/Component References + +```tsx +const editorRef = useRef(null); + +// Set ref when component mounts +const handleMonacoEditorMount = (editor: monacoEditor.editor.IStandaloneCodeEditor) => { + editorRef.current = editor; +}; + +// Access later without causing re-renders +const getCurrentContent = () => editorRef.current?.getValue() || ''; +``` + +**Example from QueryEditor** - storing query state in context: + +```tsx +const getCurrentQueryFunction = () => ({ + filter: filterValue, + project: projectValue, + sort: sortValue, + skip: skipValue, + limit: limitValue, +}); + +setCurrentContext((prev) => ({ + ...prev, + queryEditor: { + getCurrentQuery: getCurrentQueryFunction, + setJsonSchema: async (schema) => { + /* ... */ + }, + }, +})); +``` + +#### Solving Stale Closure Issues + +**🚨 CRITICAL PATTERN**: When using third-party components (like SlickGrid) that don't automatically update event handlers on re-renders: + +```tsx +// Problem: Event handlers capture state at initialization time +// Solution: Store latest state in refs + +const [currentQueryResults, setCurrentQueryResults] = useState(); +const currentQueryResultsRef = useRef(currentQueryResults); + +// Keep ref in sync with state +useEffect(() => { + currentQueryResultsRef.current = currentQueryResults; +}, [currentQueryResults]); + +// In event handler, use ref to get latest data +const onCellDblClick = useCallback((event: CustomEvent) => { + // ✅ Good: Access latest data via ref + const activeDocument = currentQueryResultsRef.current?.tableData?.[row]; + + // ❌ Bad: Would capture stale state + // const activeDocument = currentQueryResults?.tableData?.[row]; +}, []); +``` + +**Why this is needed:** + +```tsx +// Third-party components like SlickGrid bind event handlers during initialization: +// 1. Component initializes with state = { data: [...] } +// 2. Event handler is created and captures state +// 3. State updates to { data: [...new items...] } +// 4. Event handler STILL sees old state (stale closure) +// 5. Using ref solves this because ref.current is always the latest value +``` + +### `useCallback` - Memoized Callbacks + +Use `useCallback` for event handlers passed to child components or third-party libraries: + +```tsx +const handleViewChanged = useCallback((optionValue: string) => { + setCurrentContext((prev) => ({ ...prev, currentView: selection })); + getDataForView(selection); +}, []); // Dependencies array +``` + +**Note**: When combined with the ref pattern, dependencies should only include stable references: + +```tsx +const onSelectedRowsChanged = useCallback( + (_eventData: unknown, _args: OnSelectedRowsChangedEventArgs): void => { + setCurrentContext((prev) => ({ + ...prev, + dataSelection: { + selectedDocumentIndexes: _args.rows, + // Use ref for latest data, not the prop + selectedDocumentObjectIds: _args.rows.map((row) => liveDataRef.current[row]?.['x-objectid'] ?? ''), + }, + })); + }, + [setCurrentContext], // Only setCurrentContext, NOT liveData +); +``` + +--- + +## Monaco Editor Integration + +### Basic Monaco Editor Setup + +The project wraps Monaco Editor in a custom component: + +```tsx +import { MonacoEditor } from '../../MonacoEditor'; + + setIsDirty(true)} +/>; +``` + +### Monaco Options + +```tsx +const monacoOptions = { + minimap: { enabled: true }, + scrollBeyondLastLine: false, + readOnly: false, + automaticLayout: false, // Handle manually for performance +}; +``` + +### Manual Layout Updates + +Monaco needs manual layout updates when container size changes: + +```tsx +const handleResize = () => { + if (editorRef.current) { + editorRef.current.layout(); + } +}; + +useEffect(() => { + const debouncedResizeHandler = debounce(handleResize, 200); + window.addEventListener('resize', debouncedResizeHandler); + handleResize(); // Initial layout + + return () => { + window.removeEventListener('resize', debouncedResizeHandler); + }; +}, []); +``` + +### Auto Height Monaco Editor + +The `MonacoAutoHeight` component extends Monaco with dynamic height based on content: + +```tsx + { + onExecuteRequest(); + }} + onMount={(editor, monaco) => { + handleEditorDidMount(editor, monaco); + }} + options={monacoOptions} +/> +``` + +**Features:** + +- Adjusts height based on line count (between minLines and maxLines) +- Registers Ctrl/Cmd+Enter shortcut via `onExecuteRequest` +- Uses ref pattern to avoid stale closures + +### JSON Schema Integration + +Set JSON schema for autocompletion and validation: + +```tsx +monaco.languages.json.jsonDefaults.setDiagnosticsOptions({ + validate: true, + schemas: [ + { + uri: 'mongodb-filter-query-schema.json', + fileMatch: ['*'], + schema: basicFindQuerySchema, + }, + ], +}); +``` + +**⚠️ Known Issue**: Monaco's JSON worker may not be initialized immediately after mount. Use a delay: + +```tsx +await new Promise((resolve) => setTimeout(resolve, 2000)); +monaco.languages.json.jsonDefaults.setDiagnosticsOptions({...}); +``` + +**🔴 TODO**: Implement proper worker initialization check instead of hardcoded delay. + +--- + +## Third-Party Component Integration + +### SlickGrid Integration + +SlickGrid is used for table and tree views. It requires special handling due to stale closure issues. + +#### Basic SlickGrid Setup + +```tsx +import { SlickgridReact, type GridOption } from 'slickgrid-react'; + + console.log('Grid created')} +/>; +``` + +#### Grid Options Pattern + +```tsx +const gridOptions: GridOption = { + autoResize: { + calculateAvailableSizeBy: 'container', + container: '#resultsDisplayAreaId', // Parent container selector + delay: 100, + bottomPadding: 2, + }, + enableAutoResize: true, + enableCellNavigation: true, + enableTextSelectionOnCells: true, + enableRowSelection: true, + multiSelect: true, +}; +``` + +#### Event Handlers with Refs (Critical!) + +```tsx +// Store latest data in refs +const liveDataRef = useRef(liveData); +const gridColumnsRef = useRef([]); + +// Keep refs in sync +useEffect(() => { + liveDataRef.current = liveData; +}, [liveData]); + +useEffect(() => { + gridColumnsRef.current = gridColumns; +}, [gridColumns]); + +// Event handler using refs +const onCellDblClick = useCallback( + (event: CustomEvent<{ args: OnDblClickEventArgs }>) => { + // ✅ Use ref to get latest data + const activeDocument = liveDataRef.current[event.detail.args.row]; + const activeColumn = gridColumnsRef.current[event.detail.args.cell].field; + + // Process event... + }, + [handleStepIn], +); // Only stable dependencies +``` + +**Why this matters:** + +- SlickGrid binds event handlers at initialization +- These handlers don't update when props/state change +- Without refs, handlers see stale/outdated data +- This caused multiple bugs that took hours to debug + +### Custom Cell Formatters + +```tsx +const cellFormatter: Formatter = (_row: number, _cell: number, value: CellValue) => { + if (value === undefined || value === null) { + return { + text: '', + toolTip: l10n.t('This field is not set'), + }; + } + return { + text: value.value, + addClasses: `typedTableCell type-${value.type}`, + toolTip: bsonStringToDisplayString(value.type), + }; +}; +``` + +--- + +## Common Patterns + +### Loading State Management + +```tsx +const [isLoading, setIsLoading] = useState(false); + +// Before async operation +setIsLoading(true); + +try { + await someAsyncOperation(); +} finally { + setIsLoading(false); // Always reset in finally +} + +// In render +{ + isLoading && ; +} +``` + +### Progress Bar Positioning + +```tsx +// In component +{isLoading && } + +// In SCSS +.progressBar { + position: absolute; + left: 0px; + top: 0px; +} +``` + +### Conditional Rendering Patterns + +**Object-based switch statement:** + +```tsx +{ + { + 'Table View': , + 'Tree View': , + 'JSON View': , + default:
error '{currentContext.currentView}'
, + }[currentContext.currentView] +} +``` + +**Conditional component rendering:** + +```tsx +{ + currentContext.currentView === Views.TABLE && ( +
+ +
+ ); +} +``` + +### Debouncing + +Use `es-toolkit` for debouncing: + +```tsx +import { debounce } from 'es-toolkit'; + +const debouncedResizeHandler = debounce(handleResize, 200); +``` + +### Error Handling + +```tsx +try { + const result = await trpcClient.someOperation.query(); +} catch (error) { + void trpcClient.common.displayErrorMessage.mutate({ + message: l10n.t('Error message'), + modal: false, // or true for important errors + cause: error instanceof Error ? error.message : String(error), + }); +} +``` + +### Telemetry/Event Reporting + +```tsx +trpcClient.common.reportEvent + .mutate({ + eventName: 'executeQuery', + properties: { + ui: 'button', + }, + measurements: { + queryLength: q.length, + }, + }) + .catch((error) => { + console.debug('Failed to report an event:', error); + }); +``` + +--- + +## Known Issues & Anti-Patterns + +### 🔴 Issues to Fix + +1. **Inline Styles**: Some components use inline styles instead of SCSS + + ```tsx + // ❌ Bad - should be in SCSS + + ``` + +2. **Negative Margins**: Overused to fix spacing issues + + ```scss + // ⚠️ Use sparingly, indicates potential layout issue + margin-top: -10px; + ``` + +3. **Monaco Worker Initialization**: Hardcoded 2-second delay + + ```tsx + // 🔴 TODO: Replace with proper worker ready check + await new Promise((resolve) => setTimeout(resolve, 2000)); + ``` + +4. **Mixed Architecture**: CollectionView has some inconsistencies in component structure + - Some components are well-separated + - Others have tight coupling + - DocumentView is cleaner as a reference + +### ⚠️ Common Pitfalls + +1. **Forgetting to use refs with third-party components** + + ```tsx + // ❌ Will capture stale state + const onClick = () => { + console.log(someState); + }; + + // ✅ Use ref pattern + const stateRef = useRef(someState); + useEffect(() => { + stateRef.current = someState; + }, [someState]); + const onClick = () => { + console.log(stateRef.current); + }; + ``` + +2. **Not cleaning up event listeners** + + ```tsx + // ✅ Always clean up + useEffect(() => { + window.addEventListener('resize', handler); + return () => window.removeEventListener('resize', handler); + }, []); + ``` + +3. **Forgetting Monaco manual layout** + + ```tsx + // ✅ Always call layout() after resize or mount + editorRef.current?.layout(); + ``` + +4. **Using `any` in TypeScript** + - Follow project guidelines: Never use `any` + - Use proper types or `unknown` with type guards + +5. **Not using localization** + + ```tsx + // ❌ Bad + + + // ✅ Good + + ``` + +### 🟡 Design Decisions to Review + +1. **Context Pattern**: CollectionView uses context extensively while DocumentView doesn't + - **Consider**: Is context needed for CollectionView? Could it be simplified? + - **Benefit**: Reduces prop drilling + - **Drawback**: Makes data flow harder to trace + +2. **Component Splitting**: Some components are very large (400+ lines) + - **Consider**: Breaking down into smaller, focused components + - **DocumentView**: Better example of cleaner structure + +3. **State Duplication**: Some data is duplicated between context and local state + ```tsx + // From CollectionView.tsx: + const [currentQueryResults, setCurrentQueryResults] = useState(); + // TODO comment mentions this might belong in global context + ``` + +--- + +## Summary of Best Practices + +### ✅ Do's + +- Use consistent 10px spacing units +- Use flexbox with `row-gap`/`column-gap` for spacing +- Store third-party component references in `useRef` +- Use ref pattern to solve stale closure issues +- Clean up event listeners and subscriptions +- Use functional state updates when depending on previous state +- Always localize user-facing strings with `l10n.t()` +- Define styles in SCSS files, not inline +- Use `es-toolkit` for utilities like `debounce` +- Handle errors gracefully with user-friendly messages +- Use Monaco's manual layout for performance + +### ❌ Don'ts + +- Don't use inline styles (move to SCSS) +- Don't overuse negative margins (fix layout instead) +- Don't forget to clean up in useEffect return +- Don't use `any` type (use proper types or `unknown`) +- Don't capture state in closures for third-party components (use refs) +- Don't hardcode delays (use proper initialization checks) +- Don't forget to call `editor.layout()` after resize +- Don't mix state management patterns inconsistently + +--- + +## References + +- TypeScript Guidelines: See `.github/copilot-instructions.md` +- VS Code Webview API: [VS Code Webview API](https://code.visualstudio.com/api/extension-guides/webview) +- Monaco Editor API: [Monaco Editor API](https://microsoft.github.io/monaco-editor/api/index.html) +- SlickGrid React: [SlickGrid React Docs](https://ghiscoding.gitbook.io/slickgrid-react/) +- React Hooks: [React Hooks Reference](https://react.dev/reference/react) + +--- + +_Document Version: 1.1_ +_Last Updated: October 20, 2025_ +_Based on: DocumentView and CollectionView analysis with QueryEditor enhancements_ diff --git a/src/webviews/api/configuration/appRouter.ts b/src/webviews/api/configuration/appRouter.ts index 9e35a9fcb..3026d6afe 100644 --- a/src/webviews/api/configuration/appRouter.ts +++ b/src/webviews/api/configuration/appRouter.ts @@ -10,6 +10,7 @@ import { callWithTelemetryAndErrorHandling } from '@microsoft/vscode-azext-utils import * as vscode from 'vscode'; import { z } from 'zod'; import { type API } from '../../../DocumentDBExperiences'; +import { openUrl } from '../../../utils/openUrl'; import { openSurvey, promptAfterActionEventually } from '../../../utils/survey'; import { UsageImpact } from '../../../utils/surveyTypes'; import { collectionsViewRouter as collectionViewRouter } from '../../documentdb/collectionView/collectionViewRouter'; @@ -126,6 +127,15 @@ const commonRouter = router({ .mutation(({ input }) => { void openSurvey(input.triggerAction); }), + openUrl: publicProcedure + .input( + z.object({ + url: z.string(), // URL string to open in default browser + }), + ) + .mutation(async ({ input }) => { + await openUrl(input.url); + }), }); // This is a demoRouter with examples of how to create a subscription diff --git a/src/webviews/components/InputWithHistory.tsx b/src/webviews/components/InputWithHistory.tsx new file mode 100644 index 000000000..4751a8e89 --- /dev/null +++ b/src/webviews/components/InputWithHistory.tsx @@ -0,0 +1,259 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Input, type InputProps } from '@fluentui/react-components'; +import { forwardRef, useRef, useState, type JSX } from 'react'; + +export interface InputWithHistoryProps extends InputProps { + /** + * History entries for the input. + * - If provided WITHOUT `onHistoryChange`: Used as initial history, component manages updates internally + * - If provided WITH `onHistoryChange`: Fully controlled mode, parent must handle updates + * - If omitted: Component starts with empty history and manages it internally + */ + history?: string[]; + + /** + * Maximum number of history entries to maintain. Defaults to 50. + * Oldest entries are removed when the limit is exceeded. + */ + maxHistorySize?: number; + + /** + * Optional callback invoked whenever the history array should be updated. + * - If provided: Component operates in fully controlled mode + * - If omitted: Component manages history internally (even if `history` prop is provided for initial state) + */ + onHistoryChange?: (history: string[]) => void; +} + +/** + * InputWithHistory Component + * + * A wrapper around Fluent UI's `Input` component that adds command-line style history navigation. + * + * Features: + * - **Arrow Up/Down Navigation**: Browse through previously entered values + * - **Draft Preservation**: Current input is saved when navigating away and restored when returning + * - **History Management**: Entries are added on Enter key press + * - **Deduplication**: Consecutive identical entries are automatically filtered out + * - **Configurable Limits**: Set maximum history size to prevent unbounded growth + * + * Behavior: + * - Arrow Up: Navigate to previous history entry (older) + * - Arrow Down: Navigate to next history entry (newer), eventually returning to empty/draft + * - Enter: Adds current value to history (without swallowing the event) + * - Editing: Any modification creates a new draft that becomes the next history entry + * + * Usage Modes: + * + * 1. **Uncontrolled** (no history management needed): + * ```tsx + * + * ``` + * + * 2. **Semi-controlled** (pre-seeded history, auto-managed): + * ```tsx + * + * ``` + * + * 3. **Fully controlled** (history survives unmount): + * ```tsx + * const [history, setHistory] = useState(['command 1']); + * + * ``` + */ +export const InputWithHistory = forwardRef( + ( + { + history: controlledHistory, + maxHistorySize = 50, + onHistoryChange, + value: controlledValue, + onChange, + onKeyDown, + ...inputProps + }, + ref, + ): JSX.Element => { + // Determine if history is fully controlled (both history AND onHistoryChange provided) + const isHistoryControlled = controlledHistory !== undefined && onHistoryChange !== undefined; + + // Internal history state + // Initialize with controlledHistory (if provided) or empty array + const [internalHistory, setInternalHistory] = useState(controlledHistory ?? []); + + // Use controlled history if fully controlled, otherwise use internal state + const history = isHistoryControlled ? controlledHistory : internalHistory; + + // Current position in history (-1 means not navigating, showing current/draft) + const [historyIndex, setHistoryIndex] = useState(-1); + + // Draft input (what user is currently typing, before adding to history) + const [draft, setDraft] = useState(''); + + // Track if we're using controlled or uncontrolled value + const isControlled = controlledValue !== undefined; + const [internalValue, setInternalValue] = useState(''); + + // Current effective value + const currentValue = isControlled ? String(controlledValue ?? '') : internalValue; + + // Ref to track if we're in the middle of history navigation + const isNavigatingRef = useRef(false); + + // Add entry to history (with deduplication and size management) + const addToHistory = (entry: string) => { + if (!entry.trim()) { + return; // Don't add empty entries + } + + const updateHistory = (prev: string[]) => { + // Don't add if it's identical to the last entry (deduplication) + if (prev.length > 0 && prev[prev.length - 1] === entry) { + return prev; + } + + const newHistory = [...prev, entry]; + + // Enforce max size (remove oldest entries) + if (newHistory.length > maxHistorySize) { + return newHistory.slice(newHistory.length - maxHistorySize); + } + + return newHistory; + }; + + if (isHistoryControlled) { + // Fully controlled mode: notify parent to update history + onHistoryChange(updateHistory(history)); + } else { + // Uncontrolled or semi-controlled mode: update internal state + setInternalHistory(updateHistory); + } + }; + + // Handle value changes + const handleChange: InputProps['onChange'] = (event, data) => { + const newValue = data.value; + + // If we're navigating history and user edits, save as draft and exit navigation + if (isNavigatingRef.current) { + setDraft(newValue); + setHistoryIndex(-1); + isNavigatingRef.current = false; + } else { + setDraft(newValue); + } + + // Update internal value if uncontrolled + if (!isControlled) { + setInternalValue(newValue); + } + + // Call parent onChange if provided + if (onChange) { + onChange(event, data); + } + }; + + // Handle keyboard navigation + const handleKeyDown: InputProps['onKeyDown'] = (event) => { + if (event.key === 'ArrowUp') { + event.preventDefault(); // Prevent cursor movement + navigateHistory('up'); + } else if (event.key === 'ArrowDown') { + event.preventDefault(); // Prevent cursor movement + navigateHistory('down'); + } else if (event.key === 'Enter') { + // Add to history (don't prevent default - let parent handle Enter) + addToHistory(currentValue); + setHistoryIndex(-1); // Reset navigation + setDraft(''); // Clear draft after adding to history + isNavigatingRef.current = false; + } + + // Call parent onKeyDown if provided + if (onKeyDown) { + onKeyDown(event); + } + }; + + // Navigate through history + const navigateHistory = (direction: 'up' | 'down') => { + if (history.length === 0) { + return; // No history to navigate + } + + isNavigatingRef.current = true; + + if (direction === 'up') { + // Going back in history (to older entries) + if (historyIndex === -1) { + // First time navigating up - save current draft and go to last entry + setDraft(currentValue); + setHistoryIndex(history.length - 1); + updateValueFromHistory(history.length - 1); + } else if (historyIndex > 0) { + // Navigate to previous entry + const newIndex = historyIndex - 1; + setHistoryIndex(newIndex); + updateValueFromHistory(newIndex); + } + // If already at oldest (index 0), do nothing (stop at beginning) + } else { + // Going forward in history (to newer entries) + if (historyIndex === -1) { + return; // Already at the latest (draft) + } + + if (historyIndex < history.length - 1) { + // Navigate to next entry + const newIndex = historyIndex + 1; + setHistoryIndex(newIndex); + updateValueFromHistory(newIndex); + } else { + // Reached the end - show draft + setHistoryIndex(-1); + updateValueFromHistory(-1); + isNavigatingRef.current = false; + } + } + }; + + // Update input value from history or draft + const updateValueFromHistory = (index: number) => { + const newValue = index === -1 ? draft : history[index]; + + if (!isControlled) { + setInternalValue(newValue); + } + + // If controlled, we need to notify parent to update the value + if (isControlled && onChange) { + // Create a synthetic event to update controlled value + const syntheticEvent = { + target: { value: newValue }, + currentTarget: { value: newValue }, + } as React.ChangeEvent; + + onChange(syntheticEvent, { value: newValue }); + } + }; + + return ( + + ); + }, +); + +InputWithHistory.displayName = 'InputWithHistory'; diff --git a/src/webviews/components/InputWithProgress.tsx b/src/webviews/components/InputWithProgress.tsx new file mode 100644 index 000000000..893c64876 --- /dev/null +++ b/src/webviews/components/InputWithProgress.tsx @@ -0,0 +1,76 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { ProgressBar } from '@fluentui/react-components'; +import { forwardRef, type JSX } from 'react'; +import { InputWithHistory, type InputWithHistoryProps } from './InputWithHistory'; +import './inputWithProgress.scss'; + +interface InputWithProgressProps extends InputWithHistoryProps { + /** + * When `true`, displays an indeterminate progress bar overlaid at the bottom of the input + * and hides Fluent UI's default underline border to prevent visual conflicts. + */ + indeterminateProgress?: boolean; +} + +/** + * InputWithProgress Component + * + * A wrapper around `InputWithHistory` that adds optional indeterminate progress indication. + * Combines history navigation features with visual progress feedback. + * + * Features: + * - All features from `InputWithHistory` (arrow up/down navigation, draft preservation, etc.) + * - When `indeterminateProgress` is `true`: + * - Renders a rounded progress bar overlaid at the bottom of the input + * - Automatically hides Fluent UI's default underline and borders to prevent visual overlap + * - Maintains layout stability (no shifts when toggling progress on/off) + * - Supports `ref` forwarding for direct access to the underlying `` element + * + * Use Cases: + * - AI query input with history and loading state + * - Command input with async operation feedback + * - Search bars with autocomplete and history + * + * @example + * ```tsx + * handleKeyPress(e)} + * /> + * ``` + */ +export const InputWithProgress = forwardRef( + ({ indeterminateProgress, ...inputProps }, ref): JSX.Element => { + return ( +
+ + {indeterminateProgress ? ( + + ) : null} +
+ ); + }, +); + +InputWithProgress.displayName = 'InputWithProgress'; diff --git a/src/webviews/documentdb/collectionView/components/MonacoAdaptive.tsx b/src/webviews/components/MonacoAutoHeight.tsx similarity index 61% rename from src/webviews/documentdb/collectionView/components/MonacoAdaptive.tsx rename to src/webviews/components/MonacoAutoHeight.tsx index 35eb8a447..d673843c7 100644 --- a/src/webviews/documentdb/collectionView/components/MonacoAdaptive.tsx +++ b/src/webviews/components/MonacoAutoHeight.tsx @@ -3,20 +3,21 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { useFocusFinders } from '@fluentui/react-components'; import { type EditorProps } from '@monaco-editor/react'; -import { MonacoEditor } from '../../../MonacoEditor'; +import { MonacoEditor } from './MonacoEditor'; // eslint-disable-next-line import/no-internal-modules import * as monacoEditor from 'monaco-editor/esm/vs/editor/editor.api'; import { debounce } from 'es-toolkit'; import { useEffect, useRef, useState } from 'react'; -import './monacoAdaptive.scss'; +import './monacoAutoHeight.scss'; /** - * Props for the MonacoEditor component. + * Props for the MonacoAutoHeight component. * - * @typedef {Object} MonacoEditorProps + * @typedef {Object} MonacoAutoHeightProps * * @property {Object} adaptiveHeight - Configuration for adaptive height of the editor. * @property {boolean} adaptiveHeight.enabled - Whether adaptive height is enabled. @@ -29,7 +30,7 @@ import './monacoAdaptive.scss'; * You can use it to access editor instance and get a reference to a function you need (e.g. to get the editor content) * @property {function} [onExecuteRequest] - Optional: Invoked when the user presses Ctrl/Cmd + Enter in the editor. */ -export type MonacoAdaptiveProps = EditorProps & { +export type MonacoAutoHeightProps = EditorProps & { adaptiveHeight?: { // Optional enabled: boolean; // Whether adaptive height is enabled @@ -38,10 +39,17 @@ export type MonacoAdaptiveProps = EditorProps & { lineHeight?: number; // Height of each line in pixels (optional) }; onExecuteRequest?: (editorContent: string) => void; // Optional: Invoked when the user presses Ctrl/Cmd + Enter in the editor + /** + * When true, Monaco keeps focus on the editor when Tab / Shift+Tab are pressed. + * When false (default), Tab navigation behaves like a standard input and moves focus to the next/previous focusable element. + */ + trapTabKey?: boolean; }; -export const MonacoAdaptive = (props: MonacoAdaptiveProps) => { +export const MonacoAutoHeight = (props: MonacoAutoHeightProps) => { const editorRef = useRef(null); + const tabKeyDisposerRef = useRef(null); + const focusFindersRef = useRef | null>(null); const [editorHeight, setEditorHeight] = useState(1 * 19); // Initial height const [lastLineCount, setLastLineCount] = useState(0); @@ -51,6 +59,7 @@ export const MonacoAdaptive = (props: MonacoAdaptiveProps) => { // won't automatically update when state changes const lastLineCountRef = useRef(lastLineCount); const propsRef = useRef(props); + const focusFinders = useFocusFinders(); // Keep refs updated with the latest values useEffect(() => { @@ -61,13 +70,17 @@ export const MonacoAdaptive = (props: MonacoAdaptiveProps) => { propsRef.current = props; }, [props]); + useEffect(() => { + focusFindersRef.current = focusFinders; + }, [focusFinders]); + // Exclude adaptiveHeight prop and onExecuteRequest prop from being passed to the Monaco editor // also, let's exclude onMount as we're adding our own handler and will invoke the provided one // once we're done with our setup // These props are intentionally destructured but not used directly - they're handled specially // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { adaptiveHeight, onExecuteRequest, onMount, ...editorProps } = props; + const { adaptiveHeight, onExecuteRequest, onMount, trapTabKey, ...editorProps } = props; const handleMonacoEditorMount = ( editor: monacoEditor.editor.IStandaloneCodeEditor, @@ -82,6 +95,8 @@ export const MonacoAdaptive = (props: MonacoAdaptiveProps) => { setupAdaptiveHeight(editor); } + configureTabKeyMode(editor, propsRef.current.trapTabKey ?? false); + // Register a command for Ctrl + Enter / Cmd + Enter if (propsRef.current.onExecuteRequest) { editor.addCommand(monacoEditor.KeyMod.CtrlCmd | monacoEditor.KeyCode.Enter, () => { @@ -106,6 +121,10 @@ export const MonacoAdaptive = (props: MonacoAdaptiveProps) => { // Clean up on component unmount return () => { + if (tabKeyDisposerRef.current) { + tabKeyDisposerRef.current.dispose(); + tabKeyDisposerRef.current = null; + } if (editorRef.current) { editorRef.current.dispose(); } @@ -113,6 +132,12 @@ export const MonacoAdaptive = (props: MonacoAdaptiveProps) => { }; }, []); + useEffect(() => { + if (editorRef.current) { + configureTabKeyMode(editorRef.current, trapTabKey ?? false); + } + }, [trapTabKey]); + const handleResize = () => { if (editorRef.current) { editorRef.current.layout(); @@ -163,8 +188,82 @@ export const MonacoAdaptive = (props: MonacoAdaptiveProps) => { } }; + /** + * Configures the Tab key behavior for the Monaco editor. + * + * When called, this function sets up or removes a keydown handler for the Tab key. + * If `shouldTrap` is true, Tab/Shift+Tab are trapped within the editor (focus remains in editor). + * If `shouldTrap` is false, Tab/Shift+Tab move focus to the next/previous focusable element outside the editor. + * + * @param {monacoEditor.editor.IStandaloneCodeEditor} editor - The Monaco editor instance. + * @param {boolean} shouldTrap - Whether to trap Tab key in the editor. + * - true: Tab/Shift+Tab are trapped in the editor. + * - false: Tab/Shift+Tab move focus to next/previous element. + */ + const configureTabKeyMode = (editor: monacoEditor.editor.IStandaloneCodeEditor, shouldTrap: boolean) => { + if (tabKeyDisposerRef.current) { + tabKeyDisposerRef.current.dispose(); + tabKeyDisposerRef.current = null; + } + + if (shouldTrap) { + return; + } + + tabKeyDisposerRef.current = editor.onKeyDown((event) => { + if (event.keyCode !== monacoEditor.KeyCode.Tab) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + + const direction = event.browserEvent.shiftKey ? 'previous' : 'next'; + moveFocus(editor, direction); + }); + }; + + /** + * Moves keyboard focus to the next or previous focusable element relative to the editor. + * + * @param {monacoEditor.editor.IStandaloneCodeEditor} editor - The Monaco editor instance. + * @param {'next' | 'previous'} direction - The direction to move focus: + * 'next' moves to the next focusable element, 'previous' moves to the previous one. + * Typically determined by whether Shift is held during Tab key press. + * + * If no focusable element is found in the given direction, the currently active element + * or the editor DOM node will be blurred as a fallback. + */ + const moveFocus = (editor: monacoEditor.editor.IStandaloneCodeEditor, direction: 'next' | 'previous') => { + const focusFinders = focusFindersRef.current; + const editorDomNode = editor.getDomNode(); + + if (!focusFinders || !editorDomNode) { + return; + } + + const activeElement = document.activeElement as HTMLElement | null; + const startElement = activeElement ?? (editorDomNode as HTMLElement); + + const targetElement = + direction === 'next' + ? focusFinders.findNextFocusable(startElement) + : focusFinders.findPrevFocusable(startElement); + + if (targetElement) { + targetElement.focus(); + return; + } + + if (activeElement) { + activeElement.blur(); + } else if (editorDomNode instanceof HTMLElement) { + editorDomNode.blur(); + } + }; + return ( -
+
); diff --git a/src/webviews/MonacoEditor.tsx b/src/webviews/components/MonacoEditor.tsx similarity index 97% rename from src/webviews/MonacoEditor.tsx rename to src/webviews/components/MonacoEditor.tsx index 0c82efc51..0a1dbd39d 100644 --- a/src/webviews/MonacoEditor.tsx +++ b/src/webviews/components/MonacoEditor.tsx @@ -9,7 +9,7 @@ import * as monacoEditor from 'monaco-editor/esm/vs/editor/editor.api'; import { useUncontrolledFocus } from '@fluentui/react-components'; import { useEffect } from 'react'; -import { useThemeState } from './theme/state/ThemeContext'; +import { useThemeState } from '../theme/state/ThemeContext'; loader.config({ monaco: monacoEditor }); diff --git a/src/webviews/components/inputWithProgress.scss b/src/webviews/components/inputWithProgress.scss new file mode 100644 index 000000000..0017d331a --- /dev/null +++ b/src/webviews/components/inputWithProgress.scss @@ -0,0 +1,26 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.inputWithProgress { + position: relative; + width: 100%; + + &.progress-active { + :global(.fui-Input) { + position: relative; + border-bottom-color: transparent !important; + border-color: transparent !important; + + &::after { + opacity: 0 !important; + border-bottom: none !important; + background: transparent !important; + height: 3px !important; + transform: none !important; + transition: none !important; + } + } + } +} diff --git a/src/webviews/components/monacoAutoHeight.scss b/src/webviews/components/monacoAutoHeight.scss new file mode 100644 index 000000000..a4334b769 --- /dev/null +++ b/src/webviews/components/monacoAutoHeight.scss @@ -0,0 +1,26 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +@use '../index.scss'; + +/** + * Monaco Editor Auto Height Container + * + * Applies Fluent UI Input styling to Monaco editor container + * for visual consistency with other input components. + */ +.monacoAutoHeightContainer { + // Apply complete Fluent UI input styling + @include index.fluent-input; + + // Monaco-specific tweaks: adjust right padding for scrollbar + padding-right: 4px; + + padding-top: 6px; + padding-bottom: 6px; + padding-left: 0px; + + border-width: 2px; +} diff --git a/src/webviews/documentdb/collectionView/CollectionView.tsx b/src/webviews/documentdb/collectionView/CollectionView.tsx index 77070fa08..1c323bf19 100644 --- a/src/webviews/documentdb/collectionView/CollectionView.tsx +++ b/src/webviews/documentdb/collectionView/CollectionView.tsx @@ -3,11 +3,12 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { ProgressBar, Tab, TabList } from '@fluentui/react-components'; +import { Badge, ProgressBar, Tab, TabList } from '@fluentui/react-components'; import * as l10n from '@vscode/l10n'; import { type JSX, useEffect, useRef, useState } from 'react'; import { type TableDataEntry } from '../../../documentdb/ClusterSession'; import { UsageImpact } from '../../../utils/surveyTypes'; +import { useConfiguration } from '../../api/webview-client/useConfiguration'; import { useTrpcClient } from '../../api/webview-client/useTrpcClient'; import { useSelectiveContextMenuPrevention } from '../../api/webview-client/utils/useSelectiveContextMenuPrevention'; import './collectionView.scss'; @@ -17,15 +18,18 @@ import { DefaultCollectionViewContext, Views, } from './collectionViewContext'; -import { DataViewPanelJSON } from './components/DataViewPanelJSON'; -import { DataViewPanelTableV2 } from './components/DataViewPanelTableV2'; -import { DataViewPanelTree } from './components/DataViewPanelTree'; -import { QueryEditor } from './components/QueryEditor'; +import { type CollectionViewWebviewConfigurationType } from './collectionViewController'; +import { QueryEditor } from './components/queryEditor/QueryEditor'; +import { QueryInsightsMain } from './components/queryInsightsTab/QueryInsightsTab'; +import { DataViewPanelJSON } from './components/resultsTab/DataViewPanelJSON'; +import { DataViewPanelTable } from './components/resultsTab/DataViewPanelTable'; +import { DataViewPanelTree } from './components/resultsTab/DataViewPanelTree'; import { ToolbarDocumentManipulation } from './components/toolbar/ToolbarDocumentManipulation'; import { ToolbarMainView } from './components/toolbar/ToolbarMainView'; import { ToolbarTableNavigation } from './components/toolbar/ToolbarTableNavigation'; import { ToolbarViewNavigation } from './components/toolbar/ToolbarViewNavigation'; import { ViewSwitcher } from './components/toolbar/ViewSwitcher'; +import { extractErrorCode } from './utils/errorCodeExtractor'; interface QueryResults { tableHeaders?: string[]; @@ -42,7 +46,7 @@ export const CollectionView = (): JSX.Element => { * Use the configuration object to access the data passed to the webview at its creation. * Feel free to update the content of the object. It won't be synced back to the extension though. */ - //const configuration = useConfiguration(); + const configuration = useConfiguration(); /** * Use the `useTrpcClient` hook to get the tRPC client @@ -66,7 +70,13 @@ export const CollectionView = (): JSX.Element => { */ // that's our current global context of the view - const [currentContext, setCurrentContext] = useState(DefaultCollectionViewContext); + const [currentContext, setCurrentContext] = useState(() => ({ + ...DefaultCollectionViewContext, + activeQuery: { + ...DefaultCollectionViewContext.activeQuery, + pageSize: configuration.defaultPageSize, + }, + })); useSelectiveContextMenuPrevention(); @@ -74,6 +84,9 @@ export const CollectionView = (): JSX.Element => { // TODO: it's a potential data duplication in the end, consider moving it into the global context of the view const [currentQueryResults, setCurrentQueryResults] = useState(); + // Track which tab is currently active + const [selectedTab, setSelectedTab] = useState<'tab_result' | 'tab_queryInsights'>('tab_result'); + // keep Refs updated with the current state const currentQueryResultsRef = useRef(currentQueryResults); const currentContextRef = useRef(currentContext); @@ -83,6 +96,85 @@ export const CollectionView = (): JSX.Element => { currentContextRef.current = currentContext; }, [currentQueryResults, currentContext]); + /** + * Reset query insights when query changes (not on pagination) + * Only reset when executionIntent is 'initial' or 'refresh' + * On 'pagination', preserve Query Insights data since the query hasn't changed + */ + useEffect(() => { + const intent = currentContext.activeQuery.executionIntent; + + // Only reset on actual query changes, not pagination + if (intent === 'initial' || intent === 'refresh') { + console.trace('[CollectionView] Query changed (intent: {0}), resetting Query Insights', intent); + setCurrentContext((prev) => ({ + ...prev, + queryInsights: DefaultCollectionViewContext.queryInsights, + })); + } + // On 'pagination' → preserve existing Query Insights state + }, [currentContext.activeQuery]); + + /** + * Non-blocking Stage 1 prefetch after query execution + * Populates ClusterSession cache so data is ready when user switches to Query Insights tab + * Uses promise tracking to prevent duplicate requests + */ + const prefetchQueryInsights = (): void => { + // Check if already loaded or in-flight promise + // Don't check status === 'loading' because we just reset to that state before calling this + if (currentContext.queryInsights.stage1Data || currentContext.queryInsights.stage1Promise) { + return; // Already handled + } + + // Query parameters are now retrieved from ClusterSession - no need to pass them + const promise = trpcClient.mongoClusters.collectionView.getQueryInsightsStage1.query(); + + // Track the promise immediately + setCurrentContext((prev) => ({ + ...prev, + queryInsights: { + ...prev.queryInsights, + stage1Promise: promise, + }, + })); + + // Handle completion + void promise + .then((stage1Data) => { + // Update state with data and mark stage as successful + // This prevents redundant fetch when user switches to Query Insights tab + setCurrentContext((prev) => ({ + ...prev, + queryInsights: { + ...prev.queryInsights, + currentStage: { phase: 1, status: 'success' }, + stage1Data: stage1Data, + stage1Promise: null, + }, + })); + console.debug('Stage 1 data prefetched:', stage1Data); + }) + .catch((error) => { + // Extract error code by traversing the cause chain using the helper function + const errorCode = extractErrorCode(error); + + // Mark stage as failed to prevent redundant fetch on tab switch + // Store both error message and code for UI pattern matching + setCurrentContext((prev) => ({ + ...prev, + queryInsights: { + ...prev.queryInsights, + currentStage: { phase: 1, status: 'error' }, + stage1ErrorMessage: error instanceof Error ? error.message : String(error), + stage1ErrorCode: errorCode, + stage1Promise: null, + }, + })); + console.warn('Stage 1 prefetch failed:', error); + }); + }; + /** * This is used to run the query. We control it by setting the query configuration * in the currentContext state. Whenever the query configuration changes, @@ -96,11 +188,16 @@ export const CollectionView = (): JSX.Element => { // 1. Run the query, this operation only acknowledges the request. // Next we need to load the ones we need. - trpcClient.mongoClusters.collectionView.runQuery + trpcClient.mongoClusters.collectionView.runFindQuery .query({ - findQuery: currentContext.currentQueryDefinition.queryText, - pageNumber: currentContext.currentQueryDefinition.pageNumber, - pageSize: currentContext.currentQueryDefinition.pageSize, + filter: currentContext.activeQuery.filter, + project: currentContext.activeQuery.project, + sort: currentContext.activeQuery.sort, + skip: currentContext.activeQuery.skip, + limit: currentContext.activeQuery.limit, + pageNumber: currentContext.activeQuery.pageNumber, + pageSize: currentContext.activeQuery.pageSize, + executionIntent: currentContext.activeQuery.executionIntent ?? 'pagination', }) .then((_response) => { // 2. This is the time to update the auto-completion data @@ -110,6 +207,10 @@ export const CollectionView = (): JSX.Element => { // 3. Load the data for the current view getDataForView(currentContext.currentView); + // 4. Non-blocking Stage 1 prefetch to populate cache + // This runs in background and doesn't block results display + prefetchQueryInsights(); + setCurrentContext((prev) => ({ ...prev, isLoading: false, isFirstTimeLoad: false })); }) .catch((error) => { @@ -122,7 +223,7 @@ export const CollectionView = (): JSX.Element => { .finally(() => { setCurrentContext((prev) => ({ ...prev, isLoading: false, isFirstTimeLoad: false })); }); - }, [currentContext.currentQueryDefinition]); + }, [currentContext.activeQuery]); useEffect(() => { if (currentContext.currentView === Views.TABLE && currentContext.currentViewState?.currentPath) { @@ -403,10 +504,29 @@ export const CollectionView = (): JSX.Element => {
{ + onExecuteRequest={() => { + // Get all query values from the editor at once + const query = currentContext.queryEditor?.getCurrentQuery() ?? { + filter: '{ }', + project: '{ }', + sort: '{ }', + skip: 0, + limit: 0, + }; + setCurrentContext((prev) => ({ ...prev, - currentQueryDefinition: { ...prev.currentQueryDefinition, queryText: q, pageNumber: 1 }, + activeQuery: { + ...prev.activeQuery, + queryText: query.filter, // deprecated: kept in sync with filter + filter: query.filter, + project: query.project, + sort: query.sort, + skip: query.skip, + limit: query.limit, + pageNumber: 1, + executionIntent: 'initial', + }, })); trpcClient.common.reportEvent @@ -416,7 +536,7 @@ export const CollectionView = (): JSX.Element => { ui: 'shortcut', }, measurements: { - queryLenth: q.length, + queryLenth: query.filter.length, }, }) .catch((error) => { @@ -425,45 +545,80 @@ export const CollectionView = (): JSX.Element => { }} /> - + { + const newTab = data.value as 'tab_result' | 'tab_queryInsights'; + + // Report tab switching telemetry + trpcClient.common.reportEvent + .mutate({ + eventName: 'tabChanged', + properties: { + previousTab: selectedTab, + newTab: newTab, + }, + }) + .catch((error) => { + console.debug('Failed to report tab change:', error); + }); + + setSelectedTab(newTab); + }} + style={{ marginTop: '-10px' }} + > Results + +
+ Query Insights + + PREVIEW + +
+
-
- - - -
- -
- { - { - 'Table View': ( - - ), - 'Tree View': , - 'JSON View': , - default:
error '{currentContext.currentView}'
, - }[currentContext.currentView] // switch-statement - } -
- - {currentContext.currentView === Views.TABLE && ( -
- -
+ {selectedTab === 'tab_result' && ( + <> +
+ + + +
+ +
+ { + { + 'Table View': ( + + ), + 'Tree View': , + 'JSON View': , + default:
error '{currentContext.currentView}'
, + }[currentContext.currentView] // switch-statement + } +
+ + {currentContext.currentView === Views.TABLE && ( +
+ +
+ )} + )} + + {selectedTab === 'tab_queryInsights' && } ); diff --git a/src/webviews/documentdb/collectionView/collectionView.scss b/src/webviews/documentdb/collectionView/collectionView.scss index 9128e93fd..db16164ee 100644 --- a/src/webviews/documentdb/collectionView/collectionView.scss +++ b/src/webviews/documentdb/collectionView/collectionView.scss @@ -5,7 +5,7 @@ @use '../../theme/slickgrid.scss'; -@use '../sharedStyles'; +@use '../../index'; //@import '@fluentui/react/dist/sass/_References'; @@ -22,6 +22,13 @@ height: 100vh; + // Hide overflow only on larger viewports where the 1px scrollbar is clearly a rounding error + // caused by cumulative fractional pixel rounding (e.g., 34.799px + 41.904px + ... = 645px vs 644px viewport). + // On smaller screens (< 400px), keep scrollbars visible for accessibility and legitimate content overflow. + @media (min-height: 400px) { + overflow-y: hidden; + } + .toolbarMainView { padding-top: 10px; @@ -50,8 +57,28 @@ @extend .selectionDisabled; flex-grow: 1; - - //background-color: aqua; // used to invstigate layout issues + flex-shrink: 1; + min-height: 0; // Required for flexbox children to shrink below content size + + z-index: 0; // Ensure this is below any floating elements like context menus, especially from Monaco editor + + //background-color: aqua; // used to investigate layout issues + + // Temporarily hide overflow during QueryEditor transitions to improve UX responsiveness. + // This class is applied when the enhanced query mode is toggled. While the window-level + // scrollbar flickering is now fixed by the media query on .collectionView, this logic + // remains useful for speeding up scrollbar re-rendering in SlickGrid (Table/Tree views). + // The !important is needed because child components (Monaco editor, SlickGrid) may have + // inline styles that would override this. Hiding overflow for 500ms makes the transition + // feel snappier by preventing intermediate scrollbar states during the ~100ms debounce + // period before resize handlers complete. + &.resizing { + overflow: hidden !important; + + * { + overflow: hidden !important; + } + } .loadingAnimationTable { .headerRow { diff --git a/src/webviews/documentdb/collectionView/collectionViewContext.ts b/src/webviews/documentdb/collectionView/collectionViewContext.ts index ae14bc112..435396ce6 100644 --- a/src/webviews/documentdb/collectionView/collectionViewContext.ts +++ b/src/webviews/documentdb/collectionView/collectionViewContext.ts @@ -4,6 +4,11 @@ *--------------------------------------------------------------------------------------------*/ import { createContext } from 'react'; +import { + type QueryInsightsStage1Response, + type QueryInsightsStage2Response, + type QueryInsightsStage3Response, +} from './types/queryInsights'; export enum Views { TABLE = 'Table View', @@ -11,16 +16,66 @@ export enum Views { JSON = 'JSON View', } +/** + * Query Insights State - Tracks the three-stage progressive loading of query insights + * - Stage 1: Query planner data (lightweight, from explain("queryPlanner")) + * - Stage 2: Execution statistics (from explain("executionStats")) + * - Stage 3: AI-powered recommendations (opt-in, requires external AI service call) + * + * Promise tracking prevents duplicate requests during rapid tab switching. + */ + +export type QueryInsightsStageStatus = 'loading' | 'success' | 'error' | 'cancelled'; + +export interface QueryInsightsCurrentStage { + phase: 1 | 2 | 3; + status: QueryInsightsStageStatus; +} + +export interface QueryInsightsState { + // Explicit stage tracking for clear state transitions + currentStage: QueryInsightsCurrentStage; + + stage1Data: QueryInsightsStage1Response | null; + stage1ErrorMessage: string | null; + stage1ErrorCode: string | null; // Error code for UI pattern matching (e.g., 'QUERY_INSIGHTS_PLATFORM_NOT_SUPPORTED_RU') + stage1Promise: Promise | null; + + stage2Data: QueryInsightsStage2Response | null; + stage2ErrorMessage: string | null; + stage2ErrorCode: string | null; // Error code for UI pattern matching + stage2Promise: Promise | null; + + stage3Data: QueryInsightsStage3Response | null; + stage3ErrorMessage: string | null; + stage3ErrorCode: string | null; // Error code for UI pattern matching + stage3Promise: Promise | null; + stage3RequestKey: string | null; // Unique key to track if the response is still valid + + // Track which errors have been displayed to the user (to prevent duplicate toasts) + displayedErrors: string[]; // Array of error keys that have been shown +} + +export type TableViewState = { + currentPath: string[]; +}; + export type CollectionViewContextType = { isLoading: boolean; // this is a concious decision to use 'isLoading' instead of tags. It's not only the data display component that is supposed to react to the lading state but also some input fields, buttons, etc. isFirstTimeLoad: boolean; // this will be set to true during the first data fetch, here we need more time and add more loading animations, but only on the first load currentView: Views; currentViewState?: TableViewState; // | TreeViewConfiguration | other views can get config over time - currentQueryDefinition: { - // holds the current query, we run a new database query when this changes - queryText: string; + activeQuery: { + // The last executed query (used for export, pagination, display) + queryText: string; // deprecated: use filter instead + filter: string; // MongoDB API find filter (same as queryText for backward compatibility) + project: string; // MongoDB API projection + sort: string; // MongoDB API sort specification + skip: number; // Number of documents to skip + limit: number; // Maximum number of documents to return pageNumber: number; pageSize: number; + executionIntent?: 'initial' | 'refresh' | 'pagination'; // Intent of the query execution }; commands: { disableAddDocument: boolean; @@ -35,21 +90,30 @@ export type CollectionViewContextType = { selectedDocumentIndexes: number[]; }; queryEditor?: { - getCurrentContent: () => string; + getCurrentQuery: () => { + filter: string; + project: string; + sort: string; + skip: number; + limit: number; + }; setJsonSchema(schema: object): Promise; //monacoEditor.languages.json.DiagnosticsOptions, but we don't want to import monacoEditor here }; -}; - -export type TableViewState = { - currentPath: string[]; + isAiRowVisible: boolean; // Controls visibility of the AI prompt row in QueryEditor + queryInsights: QueryInsightsState; // Query insights state for progressive loading }; export const DefaultCollectionViewContext: CollectionViewContextType = { isLoading: false, isFirstTimeLoad: true, currentView: Views.TABLE, - currentQueryDefinition: { - queryText: '{ }', + activeQuery: { + queryText: '{ }', // deprecated: use filter instead + filter: '{ }', + project: '{ }', + sort: '{ }', + skip: 0, + limit: 0, pageNumber: 1, pageSize: 10, }, @@ -63,6 +127,28 @@ export const DefaultCollectionViewContext: CollectionViewContextType = { selectedDocumentObjectIds: [], selectedDocumentIndexes: [], }, + isAiRowVisible: false, + queryInsights: { + currentStage: { phase: 1, status: 'loading' }, + + stage1Data: null, + stage1ErrorMessage: null, + stage1ErrorCode: null, + stage1Promise: null, + + stage2Data: null, + stage2ErrorMessage: null, + stage2ErrorCode: null, + stage2Promise: null, + + stage3Data: null, + stage3ErrorMessage: null, + stage3ErrorCode: null, + stage3Promise: null, + stage3RequestKey: null, + + displayedErrors: [], + }, }; export const CollectionViewContext = createContext< diff --git a/src/webviews/documentdb/collectionView/collectionViewController.ts b/src/webviews/documentdb/collectionView/collectionViewController.ts index 5c25d2c73..5b15d307e 100644 --- a/src/webviews/documentdb/collectionView/collectionViewController.ts +++ b/src/webviews/documentdb/collectionView/collectionViewController.ts @@ -5,6 +5,7 @@ import { API } from '../../../DocumentDBExperiences'; import { ext } from '../../../extensionVariables'; +import { SettingsService } from '../../../services/SettingsService'; import { WebviewController } from '../../api/extension-server/WebviewController'; import { type RouterContext } from './collectionViewRouter'; @@ -13,16 +14,26 @@ export type CollectionViewWebviewConfigurationType = { clusterId: string; databaseName: string; collectionName: string; + defaultPageSize: number; }; export class CollectionViewController extends WebviewController { - constructor(initialData: CollectionViewWebviewConfigurationType) { + constructor(initialData: Omit) { // ext.context here is the vscode.ExtensionContext required by the ReactWebviewPanelController's original implementation // we're not modifying it here in order to be ready for future updates of the webview API. const title: string = `${initialData.databaseName}/${initialData.collectionName}`; - super(ext.context, API.DocumentDB, title, 'mongoClustersCollectionView', initialData); + // Get the default page size from settings + const defaultPageSize = + SettingsService.getSetting(ext.settingsKeys.collectionViewDefaultPageSize) ?? 50; + + const fullInitialData: CollectionViewWebviewConfigurationType = { + ...initialData, + defaultPageSize, + }; + + super(ext.context, API.DocumentDB, title, 'mongoClustersCollectionView', fullInitialData); const trpcContext: RouterContext = { dbExperience: API.DocumentDB, diff --git a/src/webviews/documentdb/collectionView/collectionViewRouter.ts b/src/webviews/documentdb/collectionView/collectionViewRouter.ts index d0d13ee3a..0bb9bcd19 100644 --- a/src/webviews/documentdb/collectionView/collectionViewRouter.ts +++ b/src/webviews/documentdb/collectionView/collectionViewRouter.ts @@ -3,6 +3,10 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { callWithTelemetryAndErrorHandling, type IActionContext } from '@microsoft/vscode-azext-utils'; +import * as fs from 'fs'; +import { type Document } from 'mongodb'; +import * as path from 'path'; import * as vscode from 'vscode'; import { type JSONSchema } from 'vscode-json-languageservice'; import { z } from 'zod'; @@ -12,10 +16,28 @@ import { getKnownFields, type FieldEntry } from '../../../utils/json/mongo/autoc import { publicProcedure, router, trpcToTelemetry } from '../../api/extension-server/trpc'; import * as l10n from '@vscode/l10n'; +import { + generateQuery, + QueryGenerationType, + type QueryGenerationContext, +} from '../../../commands/llmEnhancedCommands/queryGenerationCommands'; import { showConfirmationAsInSettings } from '../../../utils/dialogs/showConfirmation'; -// eslint-disable-next-line import/no-internal-modules + import { Views } from '../../../documentdb/Views'; +import { + ExplainPlanAnalyzer, + type ExecutionStatsAnalysis, + type QueryPlannerAnalysis, +} from '../../../documentdb/queryInsights/ExplainPlanAnalyzer'; +import { StagePropertyExtractor } from '../../../documentdb/queryInsights/StagePropertyExtractor'; +import { + createFailedQueryResponse, + transformAIResponseForUI, + transformStage1Response, + transformStage2Response, +} from '../../../documentdb/queryInsights/transformations'; import { ext } from '../../../extensionVariables'; +import { QueryInsightsAIService } from '../../../services/ai/QueryInsightsAIService'; import { type CollectionItem } from '../../../tree/documentdb/CollectionItem'; // eslint-disable-next-line import/no-internal-modules import basicFindQuerySchema from '../../../utils/json/mongo/autocomplete/basicMongoFindFilterSchema.json'; @@ -23,6 +45,7 @@ import { generateMongoFindJsonSchema } from '../../../utils/json/mongo/autocompl import { promptAfterActionEventually } from '../../../utils/survey'; import { UsageImpact } from '../../../utils/surveyTypes'; import { type BaseRouterContext } from '../../api/configuration/appRouter'; +import { type QueryInsightsStage3Response } from './types/queryInsights'; export type RouterContext = BaseRouterContext & { sessionId: string; @@ -31,6 +54,45 @@ export type RouterContext = BaseRouterContext & { collectionName: string; }; +/** + * Debug helper: Read debug override file for Query Insights testing + * Looks for files in resources/debug/ directory + * Returns the raw MongoDB explain response if file exists and is valid, otherwise null + * + * To activate: Remove the "_comment" field from the JSON file + */ +function readQueryInsightsDebugFile(filename: string): Document | null { + try { + const debugFilePath = path.join(ext.context.extensionPath, 'resources', 'debug', filename); + + if (!fs.existsSync(debugFilePath)) { + return null; + } + + const content = fs.readFileSync(debugFilePath, 'utf8').trim(); + + if (!content) { + return null; + } + + const parsed = JSON.parse(content) as Document & { _debug_active?: boolean }; + + // Check if debug mode is explicitly activated + if (!parsed._debug_active) { + return null; + } + + ext.outputChannel.appendLine(`🐛 Query Insights Debug: Using override data from ${filename}`); + + return parsed; + } catch (error) { + ext.outputChannel.appendLine( + `⚠️ Query Insights Debug: Failed to read ${filename}: ${(error as Error).message}`, + ); + return null; + } +} + // Helper function to find the collection node based on context async function findCollectionNodeInTree( clusterId: string, @@ -81,30 +143,53 @@ export const collectionsViewRouter = router({ return l10n.t('Info from the webview: ') + JSON.stringify(myCtx); }), - runQuery: publicProcedure + runFindQuery: publicProcedure .use(trpcToTelemetry) // parameters .input( z.object({ - findQuery: z.string(), + filter: z.string(), + project: z.string().optional(), + sort: z.string().optional(), + skip: z.number().optional(), + limit: z.number().optional(), pageNumber: z.number(), pageSize: z.number(), + executionIntent: z.enum(['initial', 'refresh', 'pagination']).optional(), }), ) // procedure type .query(async ({ input, ctx }) => { const myCtx = ctx as RouterContext; + // Track execution intent for telemetry + const executionIntent = input.executionIntent ?? 'pagination'; + // run query const session: ClusterSession = ClusterSession.getSession(myCtx.sessionId); - const size = await session.runQueryWithCache( + const size = await session.runFindQueryWithCache( myCtx.databaseName, myCtx.collectionName, - input.findQuery, + { + filter: input.filter, + project: input.project, + sort: input.sort, + skip: input.skip, + limit: input.limit, + }, input.pageNumber, input.pageSize, + executionIntent, ); + // Report execution intent for analytics + void callWithTelemetryAndErrorHandling('documentDB.query.executionIntent', (telemetryCtx) => { + telemetryCtx.errorHandling.suppressDisplay = true; + telemetryCtx.telemetry.properties.intent = executionIntent; + telemetryCtx.telemetry.properties.pageNumber = input.pageNumber.toString(); + telemetryCtx.telemetry.measurements.documentCount = size; + }); + void promptAfterActionEventually(UsageImpact.High); return { documentCount: size }; @@ -125,7 +210,6 @@ export const collectionsViewRouter = router({ if (autoCompletionData.length > 0) { querySchema = generateMongoFindJsonSchema(autoCompletionData); } else { - // eslint-disable-next-line querySchema = basicFindQuerySchema; } @@ -249,7 +333,15 @@ export const collectionsViewRouter = router({ exportDocuments: publicProcedure .use(trpcToTelemetry) // parameters - .input(z.object({ query: z.string() })) + .input( + z.object({ + filter: z.string(), + project: z.string().optional(), + sort: z.string().optional(), + skip: z.number().optional(), + limit: z.number().optional(), + }), + ) //procedure type .query(async ({ input, ctx }) => { const myCtx = ctx as RouterContext; @@ -266,7 +358,13 @@ export const collectionsViewRouter = router({ 'vscode-documentdb.command.internal.exportDocuments', collectionTreeNode, { - queryText: input.query, + queryParams: { + filter: input.filter, + project: input.project, + sort: input.sort, + skip: input.skip, + limit: input.limit, + }, source: 'webview;collectionView', }, ); @@ -293,4 +391,453 @@ export const collectionsViewRouter = router({ throw new Error('Could not find the specified collection in the tree.'); } }), + + generateQuery: publicProcedure + .use(trpcToTelemetry) + // parameters + .input( + z.object({ + currentQuery: z.object({ + filter: z.string(), + project: z.string().optional(), + sort: z.string().optional(), + skip: z.number().optional(), + limit: z.number().optional(), + }), + prompt: z.string(), + }), + ) + // handle generation request + .query(async ({ input, ctx }) => { + const generationCtx = ctx as RouterContext; + + const result = await callWithTelemetryAndErrorHandling( + 'vscode-documentdb.collectionView.generateQuery', + async (context: IActionContext) => { + // Prepare query generation context + const queryContext: QueryGenerationContext = { + clusterId: generationCtx.clusterId, + databaseName: generationCtx.databaseName, + collectionName: generationCtx.collectionName, + // For now, only handle Find queries + targetQueryType: 'Find', + naturalLanguageQuery: input.prompt, + generationType: QueryGenerationType.SingleCollection, + }; + + // Generate query with LLM + const generationResult = await generateQuery(context, queryContext); + if (generationResult.generatedQuery === undefined) { + const errorExplanation = generationResult.explanation + ? generationResult.explanation.startsWith('Error:') + ? generationResult.explanation.slice(6).trim() + : generationResult.explanation + : 'No detailed error message provided.'; + context.telemetry.properties.generationError = errorExplanation; + throw new Error(l10n.t('Query generation failed with the error: {0}', errorExplanation)); + } + + // Parse the generated command + // For now we only support find query + let parsedCommand: { + filter?: string; + project?: string; + sort?: string; + skip?: number; + limit?: number; + }; + + try { + parsedCommand = JSON.parse(generationResult.generatedQuery) as { + filter?: string; + project?: string; + sort?: string; + skip?: number; + limit?: number; + }; + } catch (error) { + // Add error details to telemetry + context.telemetry.properties.parseError = error instanceof Error ? error.name : 'UnknownError'; + context.telemetry.properties.parseErrorMessage = + error instanceof Error ? error.message : String(error); + + throw new Error( + l10n.t('Failed to parse generated query. Query generation provided an invalid response.'), + ); + } + + return { + filter: parsedCommand.filter ?? input.currentQuery.filter, + project: parsedCommand.project ?? input.currentQuery.project ?? '{ }', + sort: parsedCommand.sort ?? input.currentQuery.sort ?? '{ }', + skip: parsedCommand.skip ?? input.currentQuery.skip ?? 0, + limit: parsedCommand.limit ?? input.currentQuery.limit ?? 0, + }; + }, + ); + + if (!result) { + throw new Error(l10n.t('Query generation failed')); + } + + return result; + }), + + /** + * Query Insights Stage 1 - Initial Performance View + * Returns fast metrics using explain("queryPlanner") + * + * This endpoint: + * 1. Retrieves execution time from ClusterSession (tracked during query execution) + * 2. Retrieves cached query planner info from ClusterSession + * 3. Uses ExplainPlanAnalyzer to parse the explain output + * 4. Transforms the analysis into UI-friendly format + * + * Note: This uses queryPlanner verbosity (no query re-execution) + * Documents returned is NOT available in Stage 1 - only in Stage 2 with executionStats + */ + getQueryInsightsStage1: publicProcedure.use(trpcToTelemetry).query(async ({ ctx }) => { + const myCtx = ctx as RouterContext; + const { sessionId, databaseName, collectionName } = myCtx; + + ext.outputChannel.trace( + l10n.t('[Query Insights Stage 1] Started for {db}.{collection}', { + db: databaseName, + collection: collectionName, + }), + ); + + let analyzed: QueryPlannerAnalysis; + let executionTime: number; + + // Platform compatibility check: + // Query Insights requires the MongoDB API explain() command with specific verbosity modes + // (queryPlanner and executionStats). Currently, only DocumentDB supports these features. + // + // Supported platforms: + // - Azure Cosmos DB for MongoDB vCore (domainInfo_api !== 'RU') + // - Native MongoDB clusters + const session: ClusterSession = ClusterSession.getSession(sessionId); + const clusterMetadata = await session.getClient().getClusterMetadata(); + + if (clusterMetadata?.domainInfo_api === 'RU') { + // TODO: Platform identification improvements needed + // 1. Create a centralized platform detection service (ClusterSession.getPlatformType()) + // 2. Define platform capabilities enum (SupportsExplain, SupportsAggregation, etc.) + // 3. Check capabilities instead of platform names for better maintainability + // 4. Consider adding feature detection (try explain() and handle gracefully) + // 5. Update UI to show platform-specific feature availability + ext.outputChannel.trace( + l10n.t( + '[Query Insights Stage 1] Query Insights is not supported on Azure Cosmos DB for MongoDB (RU) clusters.', + ), + ); + + // Create error with code for UI-specific handling + const error = new Error( + l10n.t('Query Insights is not supported on Azure Cosmos DB for MongoDB (RU) clusters.'), + ); + // Add error code as a custom property for UI pattern matching + (error as Error & { code?: string }).code = 'QUERY_INSIGHTS_PLATFORM_NOT_SUPPORTED_RU'; + throw error; + } + + // Check for debug override file first + const debugData = readQueryInsightsDebugFile('query-insights-stage1.json'); + if (debugData) { + ext.outputChannel.trace(l10n.t('[Query Insights Stage 1] Using debug data file')); + // Use debug data - analyze it the same way as real data + analyzed = ExplainPlanAnalyzer.analyzeQueryPlanner(debugData); + // Use a default execution time for debug mode + executionTime = 2.5; + } else { + // Get ClusterSession + const session: ClusterSession = ClusterSession.getSession(sessionId); + + // Get execution time from session (tracked during last query execution) + executionTime = session.getLastExecutionTimeMs(); + + // Get query parameters from session with parsed BSON objects + const queryParams = session.getCurrentFindQueryParamsWithObjects(); + + // Get query planner info (cached or fetch) without skip/limit for full query insights + const queryPlannerStart = Date.now(); + const queryPlannerResult = await session.getQueryPlannerInfo( + databaseName, + collectionName, + queryParams.filterObj, + { + sort: queryParams.sortObj, + projection: queryParams.projectionObj, + // Intentionally omit skip/limit for full query insights + }, + ); + const queryPlannerDuration = Date.now() - queryPlannerStart; + ext.outputChannel.trace( + l10n.t('[Query Insights Stage 1] explain(queryPlanner) completed in {ms}ms', { + ms: queryPlannerDuration.toString(), + }), + ); + + // Analyze with ExplainPlanAnalyzer + analyzed = ExplainPlanAnalyzer.analyzeQueryPlanner(queryPlannerResult); + } + + // Transform to UI format + const transformed = transformStage1Response(analyzed, executionTime); + ext.outputChannel.trace( + l10n.t('[Query Insights Stage 1] Completed: indexes={idx}, collScan={scan}', { + idx: analyzed.usedIndexes.join(', ') || 'none', + scan: analyzed.isCollectionScan.toString(), + }), + ); + + return transformed; + }), + + /** + * Query Insights Stage 2 - Detailed Execution Analysis + * Returns authoritative metrics using explain("executionStats") + * + * This endpoint: + * 1. Retrieves the current query from ClusterSession (no parameters needed) + * 2. Retrieves cached execution stats from ClusterSession + * 3. Uses ExplainPlanAnalyzer to parse and rate performance + * 4. Transforms the analysis into UI-friendly format with performance rating + * + * Note: This executes the query with executionStats verbosity + */ + getQueryInsightsStage2: publicProcedure.use(trpcToTelemetry).query(async ({ ctx }) => { + const myCtx = ctx as RouterContext; + const { sessionId, databaseName, collectionName } = myCtx; + + ext.outputChannel.trace( + l10n.t('[Query Insights Stage 2] Started for {db}.{collection}', { + db: databaseName, + collection: collectionName, + }), + ); + + // Track execution time to ensure minimum duration for better UX + const startTime = performance.now(); + + let analyzed: ExecutionStatsAnalysis; + let explainResult: Document | undefined; + + // Check for debug override file first + const debugData = readQueryInsightsDebugFile('query-insights-stage2.json'); + if (debugData) { + ext.outputChannel.trace(l10n.t('[Query Insights Stage 2] Using debug data file')); + // Use debug data - analyze it the same way as real data + analyzed = ExplainPlanAnalyzer.analyzeExecutionStats(debugData); + explainResult = debugData; + } else { + // Get ClusterSession + const session: ClusterSession = ClusterSession.getSession(sessionId); + + // Get query parameters from session with parsed BSON objects + const queryParams = session.getCurrentFindQueryParamsWithObjects(); + + // Get execution stats (cached or fetch) without skip/limit for full query insights + const executionStatsStart = Date.now(); + const executionStatsResult = await session.getExecutionStats( + databaseName, + collectionName, + queryParams.filterObj, + { + sort: queryParams.sortObj, + projection: queryParams.projectionObj, + // Intentionally omit skip/limit for full query insights + }, + ); + const executionStatsDuration = Date.now() - executionStatsStart; + ext.outputChannel.trace( + l10n.t('[Query Insights Stage 2] explain(executionStats) completed in {ms}ms', { + ms: executionStatsDuration.toString(), + }), + ); + + // Analyze with ExplainPlanAnalyzer + analyzed = ExplainPlanAnalyzer.analyzeExecutionStats(executionStatsResult); + explainResult = executionStatsResult; + } + + // Extract extended stage info (as per design document) + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + const executionStages = explainResult?.executionStats?.executionStages as Document | undefined; + if (executionStages) { + analyzed.extendedStageInfo = StagePropertyExtractor.extractAllExtendedStageInfo(executionStages); + } + + // Check for execution error and return error response if found + if (analyzed.executionError) { + ext.outputChannel.warn( + l10n.t('[Query Insights Stage 2] Query execution failed: {error}', { + error: analyzed.executionError.errorMessage, + }), + ); + const errorResponse = createFailedQueryResponse(analyzed, explainResult); + + // Ensure minimum execution time for better UX + const elapsedTime = performance.now() - startTime; + const minimumDuration = 1500; // 1.5 seconds + if (elapsedTime < minimumDuration) { + await new Promise((resolve) => setTimeout(resolve, minimumDuration - elapsedTime)); + } + + ext.outputChannel.trace(l10n.t('Query Insights Stage 2 completed with execution error')); + return errorResponse; + } + + // Transform to UI format (normal successful execution path) + ext.outputChannel.trace(l10n.t('Transforming Stage 2 response to UI format')); + const transformed = transformStage2Response(analyzed); + + // Ensure minimum execution time for better UX (avoid jarring instant transitions) + const elapsedTime = performance.now() - startTime; + const minimumDuration = 1500; // 1.5 seconds + if (elapsedTime < minimumDuration) { + await new Promise((resolve) => setTimeout(resolve, minimumDuration - elapsedTime)); + } + + ext.outputChannel.trace( + l10n.t( + '[Query Insights Stage 2] Completed: execTime={time}ms, returned={ret}, examined={ex}, ratio={ratio}', + { + time: analyzed.executionTimeMillis.toString(), + ret: analyzed.nReturned.toString(), + ex: analyzed.totalDocsExamined.toString(), + ratio: analyzed.efficiencyRatio.toFixed(2), + }, + ), + ); + return transformed; + }), + + /** + * Get Query Insights - Stage 3 (AI-powered recommendations) + * Opt-in AI analysis of query performance with actionable suggestions + * + * This endpoint: + * 1. Retrieves the current query from ClusterSession (no parameters needed) + * 2. Calls AI service with query, database, and collection info + * 3. Transforms AI response into UI-friendly format with action buttons + */ + getQueryInsightsStage3: publicProcedure + .use(trpcToTelemetry) + .input(z.object({ requestKey: z.string() })) + .query(async ({ input, ctx }): Promise => { + const myCtx = ctx as RouterContext; + const { sessionId, clusterId, databaseName, collectionName } = myCtx; + const { requestKey } = input; + + ext.outputChannel.trace( + l10n.t('[Query Insights Stage 3] Started for {db}.{collection} (requestKey: {key})', { + db: databaseName, + collection: collectionName, + key: requestKey, + }), + ); + + // Get ClusterSession + const session: ClusterSession = ClusterSession.getSession(sessionId); + + // Get query parameters from session (current query) + const queryParams = session.getCurrentFindQueryParams(); + + // Create AI service instance + const aiService = new QueryInsightsAIService(); + + // Call AI service + const aiServiceStart = Date.now(); + const aiRecommendations = await aiService.getOptimizationRecommendations( + sessionId, + queryParams, + databaseName, + collectionName, + ); + const aiServiceDuration = Date.now() - aiServiceStart; + ext.outputChannel.trace( + l10n.t('[Query Insights Stage 3] AI service completed in {ms}ms (requestKey: {key})', { + ms: aiServiceDuration.toString(), + key: requestKey, + }), + ); + + // Transform AI response to UI format with button payloads + const transformed = transformAIResponseForUI(aiRecommendations, { + clusterId, + databaseName, + collectionName, + }); + ext.outputChannel.trace( + l10n.t('[Query Insights Stage 3] Completed: {count} improvement cards generated (requestKey: {key})', { + count: transformed.improvementCards.length.toString(), + key: requestKey, + }), + ); + + return transformed; + }), + + /** + * Execute a recommendation action (create index, drop index, learn more, etc.) + * + * Takes actionId and payload from the button click and routes to appropriate handler + * in QueryInsightsAIService + */ + executeQueryInsightsAction: publicProcedure + .use(trpcToTelemetry) + .input( + z.object({ + actionId: z.string(), + payload: z.unknown(), + }), + ) + .mutation(async ({ ctx, input }) => { + const myCtx = ctx as RouterContext; + const { sessionId, clusterId } = myCtx; + const { actionId, payload } = input; + + // Create AI service instance + const aiService = new QueryInsightsAIService(); + + // Execute the recommendation action + const result = await aiService.executeQueryInsightsAction(clusterId, sessionId, actionId, payload); + + return result; + }), + + /** + * View Raw Explain Output + * Opens the raw explain plan output in a new VS Code document + */ + viewRawExplainOutput: publicProcedure.use(trpcToTelemetry).mutation(async ({ ctx }) => { + const myCtx = ctx as RouterContext; + const { sessionId, databaseName, collectionName } = myCtx; + + // Get ClusterSession + const session: ClusterSession = ClusterSession.getSession(sessionId); + + // Get the cached execution stats (raw explain output) + const rawExplainOutput = session.getRawExplainOutput(databaseName, collectionName); + + if (!rawExplainOutput) { + throw new Error('No explain output available. Please run a query first.'); + } + + // Pretty-print the JSON + const prettyJson = JSON.stringify(rawExplainOutput, null, 4); + + // Open in a new untitled document with .json extension + const vscode = await import('vscode'); + const doc = await vscode.workspace.openTextDocument({ + content: prettyJson, + language: 'json', + }); + + await vscode.window.showTextDocument(doc); + + return { success: true }; + }), }); diff --git a/src/webviews/documentdb/collectionView/components/DataViewPanelTable.tsx b/src/webviews/documentdb/collectionView/components/DataViewPanelTable.tsx deleted file mode 100644 index f639692cf..000000000 --- a/src/webviews/documentdb/collectionView/components/DataViewPanelTable.tsx +++ /dev/null @@ -1,81 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import * as React from 'react'; -import { useContext } from 'react'; -import { SlickgridReact, type GridOption } from 'slickgrid-react'; -import { CollectionViewContext } from '../collectionViewContext'; -import { LoadingAnimationTable } from './LoadingAnimationTable'; - -interface Props { - liveHeaders: string[]; - liveData: object[]; -} - -export function DataViewPanelTable({ liveHeaders, liveData }: Props): React.JSX.Element { - const [currentContext] = useContext(CollectionViewContext); - - type GridColumn = { id: string; name: string; field: string; minWidth: number }; - - const gridColumns: GridColumn[] = liveHeaders.map((header) => { - return { - id: header + '_id', - name: header, - field: header, - minWidth: 100, - }; - }); - - const gridOptions: GridOption = { - autoResize: { - calculateAvailableSizeBy: 'container', - container: '.resultsDisplayArea', // this is a selector of the parent container, in this case it's the collectionView.tsx and the class is "resultsDisplayArea" - delay: 100, - }, - enableAutoResize: true, - enableAutoSizeColumns: true, // true by default, we disabled it under the assumption that there are a lot of columns in users' data in general - - enableCellNavigation: true, - enableCheckboxSelector: false, // todo: [post MVP] this is failing, it looks like it happens when we're defining columns after the grid has been created.. we're deleting the 'checkbox' column. we can work around it, but it needs a bit more attention to get it done right. - enableRowSelection: true, - multiSelect: true, - // checkboxSelector: { - // // optionally change the column index position of the icon (defaults to 0) - // // columnIndexPosition: 1, - - // // you can toggle these 2 properties to show the "select all" checkbox in different location - // hideInFilterHeaderRow: false, - // hideInColumnTitleRow: true, - // applySelectOnAllPages: true, // when clicking "Select All", should we apply it to all pages (defaults to true) - // }, - // rowSelectionOptions: { todo: [post MVP] connected to the issue above. - // // True (Single Selection), False (Multiple Selections) - // selectActiveRow: false, - // }, - // disalbing features that would require more polishing to make them production-ready - enableColumnPicker: false, - enableColumnReorder: false, - enableContextMenu: false, - enableGridMenu: false, - enableHeaderButton: false, - enableHeaderMenu: false, - }; - - if (currentContext.isLoading) { - return ; - } else { - return ( - console.debug('Grid created')} - /> - ); - } -} diff --git a/src/webviews/documentdb/collectionView/components/QueryEditor.tsx b/src/webviews/documentdb/collectionView/components/QueryEditor.tsx deleted file mode 100644 index 439e30cd9..000000000 --- a/src/webviews/documentdb/collectionView/components/QueryEditor.tsx +++ /dev/null @@ -1,159 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { useContext, useEffect, useRef, type JSX } from 'react'; // Add useEffect import -// eslint-disable-next-line import/no-internal-modules -import type * as monacoEditor from 'monaco-editor/esm/vs/editor/editor.api'; -// eslint-disable-next-line import/no-internal-modules -import basicFindQuerySchema from '../../../../utils/json/mongo/autocomplete/basicMongoFindFilterSchema.json'; -// eslint-disable-next-line import/no-internal-modules -import { type editor } from 'monaco-editor/esm/vs/editor/editor.api'; -import { CollectionViewContext } from '../collectionViewContext'; -import { MonacoAdaptive } from './MonacoAdaptive'; - -interface QueryEditorProps { - onExecuteRequest: (query: string) => void; -} - -export const QueryEditor = ({ onExecuteRequest }: QueryEditorProps): JSX.Element => { - const [, setCurrentContext] = useContext(CollectionViewContext); - - const schemaAbortControllerRef = useRef(null); - - const handleEditorDidMount = (editor: monacoEditor.editor.IStandaloneCodeEditor, monaco: typeof monacoEditor) => { - editor.setValue('{ }'); - - const getCurrentContentFunction = () => editor.getValue(); - // adding the function to the context for use outside of the editor - setCurrentContext((prev) => ({ - ...prev, - queryEditor: { - getCurrentContent: getCurrentContentFunction, - /** - * Dynamically sets the JSON schema for the Monaco editor's validation and autocompletion. - * - * NOTE: This function can encounter network errors if called immediately after the - * editor mounts, as the underlying JSON web worker may not have finished loading. - * To mitigate this, a delay is introduced before attempting to set the schema. - * - * A more robust long-term solution should be implemented to programmatically - * verify that the JSON worker is initialized before this function proceeds. - * - * An AbortController is used to prevent race conditions when this function is - * called in quick succession (e.g., rapid "refresh" clicks). It ensures that - * any pending schema update is cancelled before a new one begins, guaranteeing - * a clean, predictable state and allowing the Monaco worker to initialize correctly. - */ - setJsonSchema: async (schema) => { - // Use the ref to cancel the previous operation - if (schemaAbortControllerRef.current) { - schemaAbortControllerRef.current.abort(); - } - - // Create and store the new AbortController in the ref - const abortController = new AbortController(); - schemaAbortControllerRef.current = abortController; - const signal = abortController.signal; - - try { - // Wait for 2 seconds to give the worker time to initialize - await new Promise((resolve) => setTimeout(resolve, 2000)); - - // If the operation was cancelled during the delay, abort early - if (signal.aborted) { - return; - } - - // Check if JSON language features are available and set the schema - if (monaco.languages.json?.jsonDefaults) { - monaco.languages.json.jsonDefaults.setDiagnosticsOptions({ - validate: false, - schemas: [ - { - uri: 'mongodb-filter-query-schema.json', - fileMatch: ['*'], - schema: schema, - }, - ], - }); - } - } catch (error) { - // The error is likely an uncaught exception in the worker, - // but we catch here just in case. - console.warn('Error setting JSON schema:', error); - } - }, - }, - })); - - // initialize the monaco editor with the schema that's basic - // as we don't know the schema of the collection available - // this is a fallback for the case when the autocompletion feature fails. - monaco.languages.json.jsonDefaults.setDiagnosticsOptions({ - validate: true, - schemas: [ - { - uri: 'mongodb-filter-query-schema.json', // Unique identifier - fileMatch: ['*'], // Apply to all JSON files or specify as needed - - schema: basicFindQuerySchema, - // schema: generateMongoFindJsonSchema(fieldEntries) - }, - ], - }); - }; - - const monacoOptions: editor.IStandaloneEditorConstructionOptions = { - contextmenu: false, - fontSize: 14, - lineHeight: 19, - hideCursorInOverviewRuler: true, - overviewRulerBorder: false, - overviewRulerLanes: 0, - glyphMargin: false, - folding: false, - renderLineHighlight: 'none', - minimap: { - enabled: false, - }, - lineNumbers: 'off', - scrollbar: { - vertical: 'auto', - horizontal: 'auto', - }, - readOnly: false, - scrollBeyondLastLine: false, - automaticLayout: false, - }; - - // Cleanup any pending operations when component unmounts - useEffect(() => { - return () => { - if (schemaAbortControllerRef.current) { - schemaAbortControllerRef.current.abort(); - schemaAbortControllerRef.current = null; - } - }; - }, []); - - return ( - { - onExecuteRequest(input); - }} - onMount={handleEditorDidMount} - options={monacoOptions} - /> - ); -}; diff --git a/src/webviews/documentdb/collectionView/components/queryEditor/QueryEditor.tsx b/src/webviews/documentdb/collectionView/components/queryEditor/QueryEditor.tsx new file mode 100644 index 000000000..586ca3153 --- /dev/null +++ b/src/webviews/documentdb/collectionView/components/queryEditor/QueryEditor.tsx @@ -0,0 +1,583 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Button, Input, Label, ToggleButton, Tooltip } from '@fluentui/react-components'; +import { Collapse } from '@fluentui/react-motion-components-preview'; +import * as l10n from '@vscode/l10n'; +import { useContext, useEffect, useRef, useState, type JSX } from 'react'; +import { InputWithProgress } from '../../../../components/InputWithProgress'; +// eslint-disable-next-line import/no-internal-modules +import type * as monacoEditor from 'monaco-editor/esm/vs/editor/editor.api'; +// eslint-disable-next-line import/no-internal-modules +import basicFindQuerySchema from '../../../../../utils/json/mongo/autocomplete/basicMongoFindFilterSchema.json'; +import { ENABLE_AI_QUERY_GENERATION } from '../../constants'; + +import { ArrowResetRegular, SendRegular, SettingsFilled, SettingsRegular } from '@fluentui/react-icons'; +// eslint-disable-next-line import/no-internal-modules +import { type editor } from 'monaco-editor/esm/vs/editor/editor.api'; +import { useTrpcClient } from '../../../../api/webview-client/useTrpcClient'; +import { MonacoAutoHeight } from '../../../../components/MonacoAutoHeight'; +import { CollectionViewContext } from '../../collectionViewContext'; +import { useHideScrollbarsDuringResize } from '../../hooks/useHideScrollbarsDuringResize'; +import './queryEditor.scss'; + +interface QueryEditorProps { + onExecuteRequest: () => void; +} + +export const QueryEditor = ({ onExecuteRequest }: QueryEditorProps): JSX.Element => { + const { trpcClient } = useTrpcClient(); + const [currentContext, setCurrentContext] = useContext(CollectionViewContext); + const [isEnhancedQueryMode, setIsEnhancedQueryMode] = useState(false); + const [isAiActive, setIsAiActive] = useState(false); + + // Local state for query fields (survives show/hide of enhanced query section) + const [filterValue, setFilterValue] = useState('{ }'); + const [projectValue, setProjectValue] = useState('{ }'); + const [sortValue, setSortValue] = useState('{ }'); + const [skipValue, setSkipValue] = useState(0); + const [limitValue, setLimitValue] = useState(0); + const [aiPromptValue, setAiPromptValue] = useState(''); + + // AI prompt history (survives hide/show of AI input) + const [aiPromptHistory, setAiPromptHistory] = useState([]); + + const schemaAbortControllerRef = useRef(null); + const aiGenerationAbortControllerRef = useRef(null); + const aiInputRef = useRef(null); + + // Refs for Monaco editors + const filterEditorRef = useRef(null); + const projectEditorRef = useRef(null); + const sortEditorRef = useRef(null); + + const hideScrollbarsTemporarily = useHideScrollbarsDuringResize(); + + const handleEditorDidMount = (editor: monacoEditor.editor.IStandaloneCodeEditor, monaco: typeof monacoEditor) => { + editor.setValue('{ }'); + + // Store the filter editor reference + filterEditorRef.current = editor; + + const getCurrentQueryFunction = () => ({ + filter: filterValue, + project: projectValue, + sort: sortValue, + skip: skipValue, + limit: limitValue, + }); + + // adding the functions to the context for use outside of the editor + setCurrentContext((prev) => ({ + ...prev, + queryEditor: { + getCurrentQuery: getCurrentQueryFunction, + /** + * Dynamically sets the JSON schema for the Monaco editor's validation and autocompletion. + * + * NOTE: This function can encounter network errors if called immediately after the + * editor mounts, as the underlying JSON web worker may not have finished loading. + * To mitigate this, a delay is introduced before attempting to set the schema. + * + * A more robust long-term solution should be implemented to programmatically + * verify that the JSON worker is initialized before this function proceeds. + * + * An AbortController is used to prevent race conditions when this function is + * called in quick succession (e.g., rapid "refresh" clicks). It ensures that + * any pending schema update is cancelled before a new one begins, guaranteeing + * a clean, predictable state and allowing the Monaco worker to initialize correctly. + */ + setJsonSchema: async (schema) => { + // Use the ref to cancel the previous operation + if (schemaAbortControllerRef.current) { + schemaAbortControllerRef.current.abort(); + } + + // Create and store the new AbortController in the ref + const abortController = new AbortController(); + schemaAbortControllerRef.current = abortController; + const signal = abortController.signal; + + try { + // Wait for 2 seconds to give the worker time to initialize + await new Promise((resolve) => setTimeout(resolve, 2000)); + + // If the operation was cancelled during the delay, abort early + if (signal.aborted) { + return; + } + + // Check if JSON language features are available and set the schema + if (monaco.languages.json?.jsonDefaults) { + monaco.languages.json.jsonDefaults.setDiagnosticsOptions({ + validate: false, + schemas: [ + { + uri: 'mongodb-filter-query-schema.json', + fileMatch: ['*'], + schema: schema, + }, + ], + }); + } + } catch (error) { + // The error is likely an uncaught exception in the worker, + // but we catch here just in case. + console.warn('Error setting JSON schema:', error); + } + }, + }, + })); + + // initialize the monaco editor with the schema that's basic + // as we don't know the schema of the collection available + // this is a fallback for the case when the autocompletion feature fails. + monaco.languages.json.jsonDefaults.setDiagnosticsOptions({ + validate: true, + schemas: [ + { + uri: 'mongodb-filter-query-schema.json', // Unique identifier + fileMatch: ['*'], // Apply to all JSON files or specify as needed + + schema: basicFindQuerySchema, + // schema: generateMongoFindJsonSchema(fieldEntries) + }, + ], + }); + }; + + const monacoOptions: editor.IStandaloneEditorConstructionOptions = { + contextmenu: false, + fontSize: 14, + lineHeight: 19, + hideCursorInOverviewRuler: true, + overviewRulerBorder: false, + overviewRulerLanes: 0, + glyphMargin: false, + folding: false, + renderLineHighlight: 'none', + minimap: { + enabled: false, + }, + lineNumbers: 'off', + scrollbar: { + vertical: 'auto', + horizontal: 'auto', + }, + readOnly: false, + scrollBeyondLastLine: false, + automaticLayout: false, + }; + + // Cleanup any pending operations when component unmounts + useEffect(() => { + return () => { + if (schemaAbortControllerRef.current) { + schemaAbortControllerRef.current.abort(); + schemaAbortControllerRef.current = null; + } + if (aiGenerationAbortControllerRef.current) { + aiGenerationAbortControllerRef.current.abort(); + aiGenerationAbortControllerRef.current = null; + } + }; + }, []); + + // Update getCurrentQuery function whenever state changes + useEffect(() => { + setCurrentContext((prev) => ({ + ...prev, + queryEditor: prev.queryEditor + ? { + ...prev.queryEditor, + getCurrentQuery: () => ({ + filter: filterValue, + project: projectValue, + sort: sortValue, + skip: skipValue, + limit: limitValue, + }), + } + : prev.queryEditor, + })); + }, [filterValue, projectValue, sortValue, skipValue, limitValue, setCurrentContext]); + + // Focus AI input when AI row becomes visible + useEffect(() => { + if (currentContext.isAiRowVisible && aiInputRef.current) { + // Use setTimeout to ensure the Collapse animation has started + setTimeout(() => { + aiInputRef.current?.focus(); + }, 200); + } + }, [currentContext.isAiRowVisible]); + + // Sync state changes to Monaco editors (for AI-generated updates) + useEffect(() => { + if (filterEditorRef.current && filterEditorRef.current.getValue() !== filterValue) { + filterEditorRef.current.setValue(filterValue); + } + }, [filterValue]); + + useEffect(() => { + if (projectEditorRef.current && projectEditorRef.current.getValue() !== projectValue) { + projectEditorRef.current.setValue(projectValue); + } + }, [projectValue]); + + useEffect(() => { + if (sortEditorRef.current && sortEditorRef.current.getValue() !== sortValue) { + sortEditorRef.current.setValue(sortValue); + } + }, [sortValue]); + + // Add keydown event listeners to detect Ctrl+Enter for skip and limit inputs + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') { + onExecuteRequest(); + } + }; + + const skipInput = document.querySelector('.queryEditorInput.skip'); + const limitInput = document.querySelector('.queryEditorInput.limit'); + + skipInput?.addEventListener('keydown', handleKeyDown); + limitInput?.addEventListener('keydown', handleKeyDown); + + return () => { + skipInput?.removeEventListener('keydown', handleKeyDown); + limitInput?.removeEventListener('keydown', handleKeyDown); + }; + }, [onExecuteRequest]); + + // Handler for AI query generation + const handleGenerateQuery = async () => { + if (!aiPromptValue.trim()) { + return; // Don't generate if prompt is empty + } + + // Cancel any previous AI generation request by marking it as aborted + if (aiGenerationAbortControllerRef.current) { + aiGenerationAbortControllerRef.current.abort(); + } + + // Create new AbortController for this request (used for client-side cancellation only) + const abortController = new AbortController(); + aiGenerationAbortControllerRef.current = abortController; + + setIsAiActive(true); + + try { + const result = await trpcClient.mongoClusters.collectionView.generateQuery.query({ + currentQuery: { + filter: filterValue, + project: projectValue, + sort: sortValue, + skip: skipValue, + limit: limitValue, + }, + prompt: aiPromptValue, + }); + + // Check if this request was aborted while waiting for response + if (abortController.signal.aborted) { + return; // Ignore the response if we aborted + } + + // Update state with generated query + setFilterValue(result.filter); + setProjectValue(result.project); + setSortValue(result.sort); + setSkipValue(result.skip); + setLimitValue(result.limit); + + // Check if we need to expand enhanced query mode + const hasNonDefaultValues = + result.project !== '{ }' || result.sort !== '{ }' || result.skip !== 0 || result.limit !== 0; + + if (hasNonDefaultValues && !isEnhancedQueryMode) { + setIsEnhancedQueryMode(true); + } + + // Clear the AI prompt after successful generation + setAiPromptValue(''); + } catch (error) { + // Check if this request was aborted + if (abortController.signal.aborted) { + return; // Ignore errors from aborted requests + } + + void trpcClient.common.displayErrorMessage.mutate({ + message: l10n.t('Error generating query'), + modal: false, + cause: error instanceof Error ? error.message : String(error), + }); + } finally { + // Only clear active state if this request wasn't aborted + if (!abortController.signal.aborted) { + setIsAiActive(false); + aiGenerationAbortControllerRef.current = null; + } + } + }; + + // Helper button component for the AI input's contentAfter slot + const SendButton: React.FC = () => { + return ( + + + + + ); +}; + +FeedbackCard.displayName = 'FeedbackCard'; diff --git a/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/FeedbackDialog.tsx b/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/FeedbackDialog.tsx new file mode 100644 index 000000000..6fd278059 --- /dev/null +++ b/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/FeedbackDialog.tsx @@ -0,0 +1,183 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { + Button, + Checkbox, + Dialog, + DialogActions, + DialogBody, + DialogContent, + DialogSurface, + DialogTitle, + Link, + Text, +} from '@fluentui/react-components'; +import { ChatMailRegular } from '@fluentui/react-icons'; +import * as l10n from '@vscode/l10n'; +import { useEffect, useState, type JSX } from 'react'; + +export interface FeedbackDialogProps { + /** Whether the dialog is open */ + open: boolean; + + /** Callback when dialog is closed */ + onClose: () => void; + + /** The sentiment: 'positive' or 'negative' */ + sentiment: 'positive' | 'negative'; + + /** Callback when feedback is submitted */ + onSubmit: (feedback: { sentiment: 'positive' | 'negative'; selectedReasons: string[] }) => Promise; +} + +export const FeedbackDialog = ({ open, onClose, sentiment, onSubmit }: FeedbackDialogProps): JSX.Element => { + const [selectedReasons, setSelectedReasons] = useState>(new Set()); + const [isSubmitting, setIsSubmitting] = useState(false); + + // Reset selected reasons when sentiment changes + useEffect(() => { + setSelectedReasons(new Set()); + }, [sentiment]); + + const positiveReasons = [ + l10n.t('Data shown was correct'), + l10n.t('Helped me understand the query execution'), + l10n.t('Recommendations were actionable'), + l10n.t('Improved my query performance'), + ]; + + const negativeReasons = [ + l10n.t('Data shown was incorrect'), + l10n.t('Information was confusing'), + l10n.t('Recommendations were not helpful'), + l10n.t('Missing important information'), + ]; + + const reasons = sentiment === 'positive' ? positiveReasons : negativeReasons; + + const handleReasonToggle = (reason: string) => { + const newReasons = new Set(selectedReasons); + if (newReasons.has(reason)) { + newReasons.delete(reason); + } else { + newReasons.add(reason); + } + setSelectedReasons(newReasons); + }; + + const handleSubmit = async () => { + setIsSubmitting(true); + try { + await onSubmit({ + sentiment, + selectedReasons: Array.from(selectedReasons), + }); + // Reset state + setSelectedReasons(new Set()); + onClose(); + } catch (error) { + console.error('Failed to submit feedback:', error); + } finally { + setIsSubmitting(false); + } + }; + + const handleClose = () => { + if (!isSubmitting) { + setSelectedReasons(new Set()); + onClose(); + } + }; + + return ( + !data.open && handleClose()}> + + + +
+ + {sentiment === 'positive' + ? l10n.t('Thank you for your feedback!') + : l10n.t('Thank you for helping us improve!')} + + +
+
+ +
+ + {sentiment === 'positive' + ? l10n.t( + 'Your positive feedback helps us understand what works well in Query Insights. Tell us more:', + ) + : l10n.t( + 'Your feedback helps us improve Query Insights. Tell us what could be better:', + )} + + + {/* Checkbox reasons */} +
+ {reasons.map((reason) => ( + handleReasonToggle(reason)} + /> + ))} +
+ + {/* Invitation for more feedback */} +
+ + {l10n.t( + 'These signals help us improve, but more context in a discussion, issue report, or a direct message adds even more value. ', + )} +
+ + {l10n.t('Start a discussion')} + + {l10n.t(' or ')}{' '} + + {l10n.t('report an issue')} + + {l10n.t(' on GitHub.')} +
+
+
+
+ + + + +
+
+
+ ); +}; + +FeedbackDialog.displayName = 'FeedbackDialog'; diff --git a/src/webviews/documentdb/collectionView/components/monacoAdaptive.scss b/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/QuickActions.scss similarity index 57% rename from src/webviews/documentdb/collectionView/components/monacoAdaptive.scss rename to src/webviews/documentdb/collectionView/components/queryInsightsTab/components/QuickActions.scss index d84fe6ce1..dc25a842a 100644 --- a/src/webviews/documentdb/collectionView/components/monacoAdaptive.scss +++ b/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/QuickActions.scss @@ -3,12 +3,18 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -.monacoAdaptiveContainer { - background-color: inherit; - padding-top: 6px; - padding-bottom: 6px; - padding-right: 4px; +.quickActionsCard { + padding: 16px; +} + +.quickActionsTitle { + display: block; + margin-bottom: 12px; +} - border: 2px solid var(--vscode-checkbox-border); // TODO: find the style name of a fluent input field - border-radius: 3px; +.quickActionsButtons { + display: flex; + flex-direction: column; + gap: 8px; + align-items: flex-start; } diff --git a/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/QuickActions.tsx b/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/QuickActions.tsx new file mode 100644 index 000000000..135390994 --- /dev/null +++ b/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/QuickActions.tsx @@ -0,0 +1,39 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Button, Card, Text } from '@fluentui/react-components'; +import { DocumentArrowLeftRegular, EyeRegular } from '@fluentui/react-icons'; +import * as l10n from '@vscode/l10n'; +import * as React from 'react'; +import './QuickActions.scss'; + +interface QuickActionsProps { + stageState: 1 | 2 | 3; +} + +export const QuickActions: React.FC = ({ stageState }) => { + if (stageState < 2) { + return null; + } + + return ( + + + {l10n.t('Quick Actions')} + +
+ + + +
+
+ ); +}; diff --git a/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/animatedCardList/AnimatedCardList.scss b/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/animatedCardList/AnimatedCardList.scss new file mode 100644 index 000000000..bfdde8931 --- /dev/null +++ b/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/animatedCardList/AnimatedCardList.scss @@ -0,0 +1,13 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// Utility class for card spacing in animated/collapsible contexts +// Note: This class is used for spacing elements in QueryInsightsTab, +// but NOT for cards within AnimatedCardList. Cards in AnimatedCardList +// should apply marginBottom directly to the Card component to ensure +// proper rendering of borders/shadows during collapse animations. +.cardSpacing { + margin-bottom: 16px; +} diff --git a/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/animatedCardList/AnimatedCardList.tsx b/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/animatedCardList/AnimatedCardList.tsx new file mode 100644 index 000000000..c5a9b6059 --- /dev/null +++ b/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/animatedCardList/AnimatedCardList.tsx @@ -0,0 +1,103 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CollapseRelaxed } from '@fluentui/react-motion-components-preview'; +import { type ReactNode, useEffect, useRef, useState } from 'react'; +import './AnimatedCardList.scss'; + +export interface AnimatedCardItem { + key: string; + component: ReactNode; +} + +interface AnimatedCardListProps { + items: AnimatedCardItem[]; + exitDuration?: number; // Duration of exit animation (ms), default 300 +} + +interface ItemState { + key: string; + component: ReactNode; + isExiting: boolean; +} + +/** + * A container for animated cards. New items appear immediately with collapse animation. + * Removed items animate out before being unmounted. + */ +export const AnimatedCardList = ({ items, exitDuration = 300 }: AnimatedCardListProps) => { + const [displayItems, setDisplayItems] = useState([]); + const exitTimersRef = useRef>(new Map()); + + useEffect(() => { + const newKeys = new Set(items.map((item) => item.key)); + const currentKeys = new Set(displayItems.map((item) => item.key)); + + // Find which items to add and which to remove + const toAdd = items.filter((item) => !currentKeys.has(item.key)); + const toRemove = displayItems.filter((item) => !newKeys.has(item.key) && !item.isExiting); + + if (toAdd.length === 0 && toRemove.length === 0) { + return; + } + + // Build new display list maintaining source order + const updated: ItemState[] = []; + const displayMap = new Map(displayItems.map((item) => [item.key, item])); + + // First, add all items from source in their original order + for (const sourceItem of items) { + const existing = displayMap.get(sourceItem.key); + if (existing) { + // Item already exists, keep it with updated component + updated.push({ ...existing, component: sourceItem.component }); + displayMap.delete(sourceItem.key); // Mark as processed + } else { + // New item + updated.push({ key: sourceItem.key, component: sourceItem.component, isExiting: false }); + } + } + + // Then, handle items that were removed (not in source but still in display) + for (const [key, item] of displayMap) { + if (!item.isExiting) { + // Mark as exiting + const exitingItem = { ...item, isExiting: true }; + updated.push(exitingItem); + + // Schedule removal after animation + const timer = setTimeout(() => { + setDisplayItems((current) => current.filter((i) => i.key !== key)); + exitTimersRef.current.delete(key); + }, exitDuration); + + exitTimersRef.current.set(key, timer); + } else { + // Already exiting, keep it + updated.push(item); + } + } + + setDisplayItems(updated); + }, [items, exitDuration]); + + // Cleanup timers on unmount + useEffect(() => { + return () => { + exitTimersRef.current.forEach((timer) => clearTimeout(timer)); + exitTimersRef.current.clear(); + }; + }, []); + + return ( +
+ {displayItems.map((item) => ( + +
{item.component}
+
+ ))} +
+ ); +}; diff --git a/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/animatedCardList/index.ts b/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/animatedCardList/index.ts new file mode 100644 index 000000000..633068fec --- /dev/null +++ b/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/animatedCardList/index.ts @@ -0,0 +1,6 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export * from './AnimatedCardList'; diff --git a/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/index.ts b/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/index.ts new file mode 100644 index 000000000..155804849 --- /dev/null +++ b/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/index.ts @@ -0,0 +1,11 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export * from './animatedCardList'; +export * from './FeedbackCard'; +export * from './FeedbackDialog'; +export * from './optimizationCards'; +export * from './queryPlanSummary'; +export * from './QuickActions'; diff --git a/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/metricsRow/CountMetric.tsx b/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/metricsRow/CountMetric.tsx new file mode 100644 index 000000000..01b86a274 --- /dev/null +++ b/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/metricsRow/CountMetric.tsx @@ -0,0 +1,94 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as React from 'react'; +import { formatCount } from './formatUtils'; +import { MetricBase, type MetricBaseProps } from './MetricBase'; + +/** + * Specialized metric component for displaying count/integer values. + * + * Features: + * - Automatic digit grouping (thousands separator, locale-aware): 10000 → "10,000" + * - Optional compact mode: 1500000 → "1.5M" + * - Configurable threshold for compact notation + * + * Value handling: + * - undefined: Shows loading skeleton (data is being fetched) + * - null: Shows N/A or custom nullValuePlaceholder (data unavailable/error) + * - number: Formats and displays the count + * + * @example + * + * + * @example + * + * + * @example + * // Show N/A when data is unavailable (e.g., error state) + * + */ +export interface CountMetricProps extends Omit { + /** The count value + * - undefined: Data is loading + * - null: Data is unavailable + * - number: Count value to format and display + */ + value: number | null | undefined; + + /** Enable thousand separators (default: true) */ + useGrouping?: boolean; + + /** Use compact notation for large numbers (1.5M instead of 1,500,000) */ + compact?: boolean; + + /** Threshold for switching to compact notation (default: 1,000,000) */ + compactThreshold?: number; +} + +export const CountMetric: React.FC = ({ + label, + value, + useGrouping = true, + compact = false, + compactThreshold = 1000000, + loadingPlaceholder = 'skeleton', + nullValuePlaceholder = 'N/A', + tooltipExplanation, +}) => { + // Preserve null vs undefined distinction + // - null → passes null to MetricBase (shows nullValuePlaceholder) + // - undefined → passes undefined to MetricBase (shows skeleton) + // - number → formats and passes string to MetricBase + const formattedValue = + value === null + ? null + : value !== undefined + ? formatCount(value, { useGrouping, compact, threshold: compactThreshold }) + : undefined; + + return ( + + ); +}; diff --git a/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/metricsRow/GenericMetric.tsx b/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/metricsRow/GenericMetric.tsx new file mode 100644 index 000000000..b05affbff --- /dev/null +++ b/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/metricsRow/GenericMetric.tsx @@ -0,0 +1,135 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as React from 'react'; +import { MetricBase, type MetricBaseProps } from './MetricBase'; + +/** + * Generic metric component for displaying string or number values without special formatting. + * + * Use this when you need to display: + * - Simple string values + * - Pre-formatted numbers + * - Custom static content + * + * For specialized formatting, use: + * - TimeMetric - for time values (auto-converts ms to s, minutes, etc.) + * - CountMetric - for integers (with grouping and compact mode) + * - RatioMetric - for percentages/ratios (with visual bar chart) + * + * @example + * + * + * @example + * + */ +export interface GenericMetricProps extends Omit { + /** The value to display (string or number) */ + value: string | number | null | undefined; +} + +export const GenericMetric: React.FC = ({ + label, + value, + loadingPlaceholder = 'skeleton', + tooltipExplanation, +}) => { + return ( + + ); +}; + +/** + * CREATING NEW SPECIALIZED METRIC COMPONENTS + * =========================================== + * + * To create a new metric type, follow this pattern: + * + * 1. Create a new file (e.g., YourMetric.tsx) + * 2. Import MetricBase and its props + * 3. Define your specific props (extending Omit) + * 4. Add your formatting logic + * 5. Return with formatted value + * + * Example - SizeMetric for bytes: + * + * ```typescript + * import * as React from 'react'; + * import { MetricBase, type MetricBaseProps } from './MetricBase'; + * + * function formatBytes(bytes: number): string { + * if (bytes < 1024) return `${bytes} B`; + * if (bytes < 1048576) return `${(bytes / 1024).toFixed(2)} KB`; + * return `${(bytes / 1048576).toFixed(2)} MB`; + * } + * + * export interface SizeMetricProps extends Omit { + * valueBytes: number | null | undefined; + * } + * + * export const SizeMetric: React.FC = ({ + * label, + * valueBytes, + * placeholder = 'skeleton', + * tooltip + * }) => { + * const formattedValue = valueBytes !== null && valueBytes !== undefined + * ? formatBytes(valueBytes) + * : undefined; + * + * return ( + * + * ); + * }; + * ``` + * + * Then export it in index.ts: + * ```typescript + * export { SizeMetric, type SizeMetricProps } from './SizeMetric'; + * ``` + * + * CUSTOM VALUE RENDERING + * ====================== + * + * You can also pass a React node to MetricBase for custom rendering: + * + * ```typescript + * const customValue = ( + *
+ * + * Custom Content + *
+ * ); + * + * return ( + * + * ); + * ``` + * + * See RatioMetric.tsx for an example with a progress bar. + */ diff --git a/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/metricsRow/MetricBase.tsx b/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/metricsRow/MetricBase.tsx new file mode 100644 index 000000000..35a6b3e67 --- /dev/null +++ b/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/metricsRow/MetricBase.tsx @@ -0,0 +1,112 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Card, SkeletonItem, Tooltip } from '@fluentui/react-components'; +import { DataUsageRegular } from '@fluentui/react-icons'; +import * as React from 'react'; +import './MetricsRow.scss'; + +/** + * Base metric component that provides the card layout and placeholder logic. + * This component is NOT exported - use specialized metric components instead. + * + * All metric components extend this to inherit: + * - Consistent card styling + * - Tooltip support + * - Placeholder handling (loading skeleton vs null value placeholder) + * - Label/value layout + * + * Placeholder behavior: + * - When value is undefined: Shows loading skeleton (configurable via loadingPlaceholder) + * - When value is null: Shows null value placeholder (configurable via nullValuePlaceholder, default: 'N/A') + */ +export interface MetricBaseProps { + /** The label displayed at the top of the metric card */ + label: string; + + /** The formatted value or custom React node to display + * - undefined: Data is loading (shows skeleton) + * - null: Data is unavailable/not applicable (shows nullValuePlaceholder) + * - string/number/ReactNode: Display the value + */ + value?: string | number | React.ReactNode; + + /** What to display while data is loading (when value is undefined) */ + loadingPlaceholder?: 'skeleton' | 'empty'; + + /** What to display when value is explicitly null (data unavailable) */ + nullValuePlaceholder?: string; + + /** Optional tooltip explanation shown on hover */ + tooltipExplanation?: string; +} + +/** + * Internal base component for metrics. + * DO NOT use directly - use TimeMetric, CountMetric, GenericMetric, or create a new specialized metric. + */ +export const MetricBase: React.FC = ({ + label, + value, + loadingPlaceholder = 'skeleton', + nullValuePlaceholder = 'N/A', + tooltipExplanation, +}) => { + const renderValue = () => { + // Explicit null means data is unavailable (e.g., error state, not supported) + if (value === null) { + return {nullValuePlaceholder}; + } + + // Undefined means data is still loading + if (value === undefined) { + if (loadingPlaceholder === 'skeleton') { + return ; + } + return null; // empty + } + + return value; + }; + + const content = ( + +
{label}
+
{renderValue()}
+
+ ); + + if (tooltipExplanation) { + // Format tooltip with similar styling to performance rating badges + const valueText = + value !== null && value !== undefined && (typeof value === 'string' || typeof value === 'number') + ? String(value) + : ''; + + return ( + +
{label}
+
{tooltipExplanation}
+ {valueText && ( +
+ {valueText} +
+ )} + + ), + }} + positioning="below" + relationship="description" + > + {content} +
+ ); + } + + return content; +}; diff --git a/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/metricsRow/MetricsRow.scss b/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/metricsRow/MetricsRow.scss new file mode 100644 index 000000000..b9cc87ebf --- /dev/null +++ b/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/metricsRow/MetricsRow.scss @@ -0,0 +1,73 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +@use '../../queryInsights.scss' as *; + +.metricsRow { + display: grid; + gap: 16px; + min-width: 0; + grid-auto-rows: auto; + + // Default: 1 column + grid-template-columns: 1fr; + + // Medium screen: 2 columns + @media (min-width: 400px) { + grid-template-columns: repeat(2, 1fr); + } + + // Wide screen: 4 columns + @media (min-width: 800px) { + grid-template-columns: repeat(4, 1fr); + } +} + +.metricCard { + padding: 16px; + display: flex; + flex-direction: column; + align-items: flex-start; + text-align: left; + gap: 8px; + + // Reference base styles from queryInsights.scss + .dataHeader { + @extend .baseDataHeader; + } + + .dataValue { + @extend .baseDataValue; + } +} + +.metricTooltip { + padding: 8px; + + .tooltipHeader { + font-weight: 600; + margin-bottom: 12px; + font-size: 16px; + } + + .tooltipContent { + white-space: pre-line; + } + + .tooltipValue { + margin-top: 12px; + margin-left: -3px; + font-weight: 600; + display: flex; + align-items: center; + gap: 6px; + } +} + +// Styling for null value placeholders (N/A, Not Available, etc.) +.nullValue { + opacity: 0.5; + color: var(--vscode-disabledForeground); +} diff --git a/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/metricsRow/MetricsRow.tsx b/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/metricsRow/MetricsRow.tsx new file mode 100644 index 000000000..098e0bfc8 --- /dev/null +++ b/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/metricsRow/MetricsRow.tsx @@ -0,0 +1,28 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as React from 'react'; +import './MetricsRow.scss'; + +/** + * Container component for displaying a row of metric cards. + * + * This is a simple wrapper that applies the grid layout. + * Use it with specialized metric components (TimeMetric, CountMetric, etc.) + * + * @example + * + * + * + * + * + */ +export interface MetricsRowProps { + children: React.ReactNode; +} + +export const MetricsRow: React.FC = ({ children }) => { + return
{children}
; +}; diff --git a/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/metricsRow/RatioMetric.tsx b/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/metricsRow/RatioMetric.tsx new file mode 100644 index 000000000..f5df25615 --- /dev/null +++ b/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/metricsRow/RatioMetric.tsx @@ -0,0 +1,125 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { tokens } from '@fluentui/react-components'; +import * as React from 'react'; +import { formatRatio } from './formatUtils'; +import { MetricBase, type MetricBaseProps } from './MetricBase'; + +/** + * Specialized metric component for displaying ratio/percentage values. + * + * Features: + * - Multiple display formats: percent, decimal, ratio + * - Optional visual bar chart + * - Configurable decimal places + * + * This component demonstrates how to override the value area with custom React nodes. + * + * @example + * // Simple percentage + * + * + * @example + * // With visual bar chart + * + */ +export interface RatioMetricProps extends Omit { + /** The ratio value (0-1 for percentages) */ + ratio: number | null | undefined; + + /** Display format (default: 'percent') */ + format?: 'percent' | 'decimal' | 'ratio'; + + /** Number of decimal places (default: 2) */ + decimals?: number; + + /** Show visual bar chart (default: false) */ + showBar?: boolean; + + /** Bar color (default: brand color) */ + barColor?: string; +} + +export const RatioMetric: React.FC = ({ + label, + ratio, + format = 'percent', + decimals = 2, + showBar = true, + barColor = tokens.colorBrandBackground, + loadingPlaceholder = 'skeleton', + tooltipExplanation, +}) => { + if (ratio === null || ratio === undefined) { + return ( + + ); + } + + const formattedValue = formatRatio(ratio, format, decimals); + + // If showBar is enabled, create custom value area with bar chart + if (showBar) { + const percentage = Math.min(100, Math.max(0, ratio * 100)); + + const customValueArea = ( +
+
{formattedValue}
+
+
+
+
+ ); + + return ( + + ); + } + + // Simple text display without bar + return ( + + ); +}; diff --git a/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/metricsRow/TimeMetric.tsx b/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/metricsRow/TimeMetric.tsx new file mode 100644 index 000000000..f05322cab --- /dev/null +++ b/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/metricsRow/TimeMetric.tsx @@ -0,0 +1,73 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as React from 'react'; +import { formatTime } from './formatUtils'; +import { MetricBase, type MetricBaseProps } from './MetricBase'; + +/** + * Specialized metric component for displaying time values. + * + * Automatically formats time with Datadog/New Relic style: + * - < 1000ms: "2.33 ms" + * - 1s - 100s: "15.20 s" + * - > 100s: "2m 15s" + * + * Value handling: + * - undefined: Shows loading skeleton (data is being fetched) + * - null: Shows N/A or custom nullValuePlaceholder (data unavailable/error) + * - number: Formats and displays the time + * + * @example + * + * + * @example + * // Show N/A when data is unavailable (e.g., error state) + * + */ +export interface TimeMetricProps extends Omit { + /** Time value in milliseconds + * - undefined: Data is loading + * - null: Data is unavailable + * - number: Time value to format and display + */ + valueMs: number | null | undefined; + + /** Number of decimal places for ms/s display (default: 2) */ + decimals?: number; +} + +export const TimeMetric: React.FC = ({ + label, + valueMs, + decimals = 2, + loadingPlaceholder = 'skeleton', + nullValuePlaceholder = 'N/A', + tooltipExplanation, +}) => { + // Preserve null vs undefined distinction + // - null → passes null to MetricBase (shows nullValuePlaceholder) + // - undefined → passes undefined to MetricBase (shows skeleton) + // - number → formats and passes string to MetricBase + const formattedValue = valueMs === null ? null : valueMs !== undefined ? formatTime(valueMs, decimals) : undefined; + + return ( + + ); +}; diff --git a/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/metricsRow/formatUtils.ts b/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/metricsRow/formatUtils.ts new file mode 100644 index 000000000..a5f2a20d3 --- /dev/null +++ b/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/metricsRow/formatUtils.ts @@ -0,0 +1,89 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Format time value in milliseconds to human-readable string + * DataDog/New Relic style with 2 decimal places (rounded) + * + * @param ms - Time in milliseconds + * @param decimals - Number of decimal places (default: 2) + * @returns Formatted time string (e.g., "2.33 ms", "1.23 s", "2m 15s") + */ +export function formatTime(ms: number, decimals: number = 2): string { + if (ms < 1000) { + // Less than 1 second: show in milliseconds + return `${ms.toFixed(decimals)} ms`; + } else if (ms < 100000) { + // 1s to 100s: show in seconds + const seconds = ms / 1000; + return `${seconds.toFixed(decimals)} s`; + } else { + // 100s+: show as "Xm Ys" + const totalSeconds = Math.floor(ms / 1000); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return `${minutes}m ${seconds}s`; + } +} + +/** + * Format count value with optional grouping and compact mode + * + * @param value - The count value + * @param options - Formatting options + * @returns Formatted count string (e.g., "10,000", "1.2M") + */ +export function formatCount( + value: number, + options: { + useGrouping?: boolean; + compact?: boolean; + threshold?: number; // When to switch to compact (default: 1,000,000) + } = {}, +): string { + const { useGrouping = true, compact = false, threshold = 1000000 } = options; + + // Use compact notation for large numbers if enabled + if (compact && value >= threshold) { + if (value >= 1000000000) { + return `${(value / 1000000000).toFixed(1)}B`; + } else if (value >= 1000000) { + return `${(value / 1000000).toFixed(1)}M`; + } else if (value >= 1000) { + return `${(value / 1000).toFixed(1)}K`; + } + } + + // Standard formatting with grouping + return value.toLocaleString(undefined, { + useGrouping, + maximumFractionDigits: 0, + }); +} + +/** + * Format ratio/percentage value + * + * @param ratio - The ratio value (0-1 for percentages, or any number for ratios) + * @param format - Output format ('percent' | 'decimal' | 'ratio') + * @param decimals - Number of decimal places (default: 2) + * @returns Formatted ratio string + */ +export function formatRatio( + ratio: number, + format: 'percent' | 'decimal' | 'ratio' = 'percent', + decimals: number = 2, +): string { + switch (format) { + case 'percent': + return `${(ratio * 100).toFixed(decimals)}%`; + case 'decimal': + return ratio.toFixed(decimals); + case 'ratio': + return `${ratio.toFixed(decimals)}:1`; + default: + return ratio.toString(); + } +} diff --git a/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/metricsRow/index.ts b/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/metricsRow/index.ts new file mode 100644 index 000000000..ca2126182 --- /dev/null +++ b/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/metricsRow/index.ts @@ -0,0 +1,20 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// Container component +export { MetricsRow, type MetricsRowProps } from './MetricsRow'; + +// Specialized metric components +export { CountMetric, type CountMetricProps } from './CountMetric'; +export { GenericMetric, type GenericMetricProps } from './GenericMetric'; +export { RatioMetric, type RatioMetricProps } from './RatioMetric'; +export { TimeMetric, type TimeMetricProps } from './TimeMetric'; + +// Formatting utilities (for advanced use cases) +export { formatCount, formatRatio, formatTime } from './formatUtils'; + +// NOTE: MetricBase is intentionally NOT exported +// Users should use the specialized metric components above +// or create their own following the pattern in GenericMetric.tsx diff --git a/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/optimizationCards/AiCard.scss b/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/optimizationCards/AiCard.scss new file mode 100644 index 000000000..e443ef6a7 --- /dev/null +++ b/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/optimizationCards/AiCard.scss @@ -0,0 +1,12 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.ai-card { + &-title-container { + display: flex; + align-items: center; + gap: 8px; + } +} diff --git a/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/optimizationCards/AiCard.tsx b/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/optimizationCards/AiCard.tsx new file mode 100644 index 000000000..743e62a4c --- /dev/null +++ b/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/optimizationCards/AiCard.tsx @@ -0,0 +1,95 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Card, CardHeader, Text, tokens } from '@fluentui/react-components'; +// TODO: Copy content feature will be added in the next release +// import { Button, CopyRegular } from '@fluentui/react-icons'; +import { SparkleRegular } from '@fluentui/react-icons'; +import * as l10n from '@vscode/l10n'; +import { forwardRef, type ReactNode } from 'react'; +import './AiCard.scss'; +import './optimizationCard.scss'; + +export interface AiCardProps { + /** + * The main title of the card + */ + title: string; + + /** + * Optional badges or other elements to display alongside the title + */ + titleChildren?: ReactNode; + + /** + * The main content of the card + */ + children: ReactNode; + + /** + * Optional callback when the copy button is clicked + */ + onCopy?: () => void; +} + +/** + * AI-themed card component for displaying optimization recommendations. + * This component supports ref forwarding for use with animation libraries. + * + * **Usage with animations**: Use directly with animation libraries like @fluentui/react-motion-components-preview: + * + * ```tsx + * + * + * + * ``` + * + * **Important**: The component applies `marginBottom: '16px'` by default for proper spacing in animated lists. + * The margin is on the Card itself to ensure borders and shadows render immediately during collapse animations. + */ +export const AiCard = forwardRef( + // TODO: Copy content feature will be added in the next release - _onCopy parameter will be used then + ({ title, titleChildren, children, onCopy: _onCopy }, ref) => { + return ( + + + {l10n.t('AI responses may be inaccurate')} + +
+ +
+ + + {title} + + {titleChildren} +
+ } + // TODO: Copy content feature will be added in the next release + // action={ + // onCopy ? ( + //
+
+ + ); + }, +); + +AiCard.displayName = 'AiCard'; diff --git a/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/optimizationCards/ImprovementCard.tsx b/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/optimizationCards/ImprovementCard.tsx new file mode 100644 index 000000000..317cc6937 --- /dev/null +++ b/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/optimizationCards/ImprovementCard.tsx @@ -0,0 +1,316 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { + Badge, + Button, + Card, + CardHeader, + Label, + MessageBar, + MessageBarBody, + MessageBarTitle, + Spinner, + Text, + tokens, +} from '@fluentui/react-components'; +import { ArrowTrendingSparkleRegular } from '@fluentui/react-icons'; +// TODO: Copy content feature will be added in the next release +// import { CopyRegular } from '@fluentui/react-icons'; +import * as l10n from '@vscode/l10n'; +import { forwardRef, useState } from 'react'; +import { type ImprovementCard as ImprovementCardConfig } from '../../../../types/queryInsights'; +import './AiCard.scss'; +import './optimizationCard.scss'; + +export interface ImprovementCardProps { + /** + * Configuration for the improvement card + */ + config: ImprovementCardConfig; + + /** + * Optional callback when the copy button is clicked + */ + onCopy?: () => void; + + /** + * Callback when the primary button is clicked + * Returns a Promise with success status and optional message + */ + onPrimaryAction?: (actionId: string, payload: unknown) => Promise<{ success: boolean; message?: string }>; + + /** + * Callback when the secondary button is clicked + * Returns a Promise with success status and optional message + */ + onSecondaryAction?: (actionId: string, payload: unknown) => Promise<{ success: boolean; message?: string }>; +} + +/** + * Priority badge color mapping + */ +const priorityColors: Record<'high' | 'medium' | 'low', 'danger' | 'warning' | 'informative'> = { + high: 'danger', + medium: 'warning', + low: 'informative', +}; + +/** + * Improvement card component for displaying AI-generated optimization recommendations. + * This component supports ref forwarding for use with animation libraries. + * + * Each card manages its own execution state (loading, error, success) locally, + * making it self-contained and independently operable. + * + * **Usage with animations**: Use directly with animation libraries like @fluentui/react-motion-components-preview: + * + * ```tsx + * + * + * + * ``` + * + * **Important**: The component applies `marginBottom: '16px'` by default for proper spacing in animated lists. + * The margin is on the Card itself to ensure borders and shadows render immediately during collapse animations. + */ +export const ImprovementCard = forwardRef( + // TODO: Copy content feature will be added in the next release - _onCopy parameter will be used then + ({ config, onCopy: _onCopy, onPrimaryAction, onSecondaryAction }, ref) => { + // Separate state for each button - independent execution tracking + const [primaryState, setPrimaryState] = useState<{ + isLoading: boolean; + errorMessage?: string; + successMessage?: string; + }>({ isLoading: false }); + + const [secondaryState, setSecondaryState] = useState<{ + isLoading: boolean; + errorMessage?: string; + successMessage?: string; + }>({ isLoading: false }); + + const handlePrimaryClick = async () => { + if (!config.primaryButton || !onPrimaryAction) return; + + // Clear previous state, set loading for primary button only + setPrimaryState({ isLoading: true }); + + try { + const result = await onPrimaryAction(config.primaryButton.actionId, config.primaryButton.payload); + + if (result.success) { + setPrimaryState({ + isLoading: false, + successMessage: result.message || l10n.t('Action completed successfully'), + }); + } else { + setPrimaryState({ + isLoading: false, + errorMessage: result.message || l10n.t('Action failed'), + }); + } + } catch (error) { + setPrimaryState({ + isLoading: false, + errorMessage: error instanceof Error ? error.message : l10n.t('An unexpected error occurred'), + }); + } + }; + + const handleSecondaryClick = async () => { + if (!config.secondaryButton || !onSecondaryAction) return; + + // Clear previous state, set loading for secondary button only + setSecondaryState({ isLoading: true }); + + try { + const result = await onSecondaryAction(config.secondaryButton.actionId, config.secondaryButton.payload); + + if (result.success) { + setSecondaryState({ + isLoading: false, + successMessage: result.message || l10n.t('Action completed successfully'), + }); + } else { + setSecondaryState({ + isLoading: false, + errorMessage: result.message || l10n.t('Action failed'), + }); + } + } catch (error) { + setSecondaryState({ + isLoading: false, + errorMessage: error instanceof Error ? error.message : l10n.t('An unexpected error occurred'), + }); + } + }; + + const priorityBadgeText = { + high: l10n.t('HIGH PRIORITY'), + medium: l10n.t('MEDIUM PRIORITY'), + low: l10n.t('LOW PRIORITY'), + }[config.priority]; + + return ( + +
+ +
+ + + {config.title} + + + {priorityBadgeText} + +
+ } + action={ + + {l10n.t('AI responses may be inaccurate')} + + } + /> +
+ {/* Description */} + + {config.description} + + + {/* Recommended Index Section */} +
+ +
+
+                                        {config.primaryButton?.actionId === 'createIndex'
+                                            ? config.recommendedIndex
+                                            : config.indexName}
+                                    
+ + {config.recommendedIndexDetails} + +
+
+ + {/* Index Options Section (if available) */} + {config.indexOptions && ( +
+ +
+
{config.indexOptions}
+
+
+ )} + + {/* Details/Risks */} + + {config.details} + + + {/* Primary Button Error Message Bar */} + {primaryState.errorMessage && ( + + + {l10n.t('Error')} + {primaryState.errorMessage} + + + )} + + {/* Primary Button Success Message Bar */} + {primaryState.successMessage && ( + + {primaryState.successMessage} + + )} + + {/* Secondary Button Error Message Bar */} + {secondaryState.errorMessage && ( + + + {l10n.t('Error')} + {secondaryState.errorMessage} + + + )} + + {/* Secondary Button Success Message Bar */} + {secondaryState.successMessage && ( + + {secondaryState.successMessage} + + )} + + {/* Action Buttons */} +
+ {config.primaryButton && ( + + )} + {config.secondaryButton && ( + + )} +
+
+
+ +
+ ); + }, +); + +ImprovementCard.displayName = 'ImprovementCard'; diff --git a/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/optimizationCards/MarkdownCard.tsx b/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/optimizationCards/MarkdownCard.tsx new file mode 100644 index 000000000..10105d48d --- /dev/null +++ b/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/optimizationCards/MarkdownCard.tsx @@ -0,0 +1,183 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Card, CardHeader, makeStyles, Text, tokens } from '@fluentui/react-components'; +// TODO: Copy content feature will be added in the next release +// import { CopyRegular } from '@fluentui/react-icons'; +import { SparkleRegular } from '@fluentui/react-icons'; +import * as l10n from '@vscode/l10n'; +import { forwardRef, type JSX } from 'react'; +import ReactMarkdown from 'react-markdown'; +import './optimizationCard.scss'; + +const useStyles = makeStyles({ + content: { + marginTop: '12px', + '& h1, & h2, & h3, & h4, & h5, & h6': { + marginTop: tokens.spacingVerticalM, + marginBottom: tokens.spacingVerticalS, + fontWeight: tokens.fontWeightSemibold, + }, + '& h1': { + fontSize: tokens.fontSizeBase500, + }, + '& h2': { + fontSize: tokens.fontSizeBase400, + }, + '& h3': { + fontSize: tokens.fontSizeBase300, + }, + '& h4, & h5, & h6': { + fontSize: tokens.fontSizeBase300, + }, + '& p': { + marginTop: tokens.spacingVerticalS, + marginBottom: tokens.spacingVerticalS, + fontSize: tokens.fontSizeBase300, + lineHeight: tokens.lineHeightBase300, + }, + '& code': { + backgroundColor: tokens.colorNeutralBackground3, + padding: '2px 4px', + borderRadius: tokens.borderRadiusSmall, + fontFamily: tokens.fontFamilyMonospace, + fontSize: tokens.fontSizeBase200, + }, + '& pre': { + backgroundColor: tokens.colorNeutralBackground3, + padding: tokens.spacingVerticalM, + borderRadius: tokens.borderRadiusMedium, + overflow: 'auto', + marginTop: tokens.spacingVerticalS, + marginBottom: tokens.spacingVerticalS, + }, + '& pre code': { + backgroundColor: 'transparent', + padding: 0, + }, + '& ul, & ol': { + marginTop: tokens.spacingVerticalS, + marginBottom: tokens.spacingVerticalS, + paddingLeft: tokens.spacingHorizontalXXL, + }, + '& li': { + marginTop: tokens.spacingVerticalXS, + marginBottom: tokens.spacingVerticalXS, + fontSize: tokens.fontSizeBase300, + }, + '& strong': { + fontWeight: tokens.fontWeightSemibold, + }, + '& em': { + fontStyle: 'italic', + }, + '& blockquote': { + marginTop: tokens.spacingVerticalS, + marginBottom: tokens.spacingVerticalS, + paddingLeft: tokens.spacingHorizontalL, + borderLeft: `4px solid ${tokens.colorBrandBackground}`, + color: tokens.colorNeutralForeground2, + fontStyle: 'italic', + }, + '& blockquote p': { + marginTop: tokens.spacingVerticalXS, + marginBottom: tokens.spacingVerticalXS, + }, + '& hr': { + marginTop: tokens.spacingVerticalL, + marginBottom: tokens.spacingVerticalL, + border: 'none', + borderTop: `1px solid ${tokens.colorNeutralStroke2}`, + }, + '& a': { + color: tokens.colorBrandForeground1, + textDecoration: 'none', + }, + '& a:hover': { + textDecoration: 'underline', + }, + }, +}); + +interface MarkdownCardProps { + /** + * Card title + */ + title: string; + + /** + * Markdown content to render + */ + content: string; + + /** + * Optional custom icon (defaults to BookInformation24Regular) + */ + icon?: JSX.Element; + + /** + * Optional callback when the copy button is clicked + */ + onCopy?: () => void; + + /** + * Whether to show the AI disclaimer. Default: true + * Set to false for non-AI generated content (e.g., error messages) + */ + showAiDisclaimer?: boolean; +} + +/** + * Markdown card component for displaying educational content with rich formatting. + * This component supports ref forwarding for use with animation libraries. + * + * **Usage with animations**: Use directly with animation libraries like @fluentui/react-motion-components-preview: + * + * ```tsx + * * + * + * + * ``` + * + * **Important**: The component applies `marginBottom: '16px'` by default for proper spacing in animated lists. + * The margin is on the Card itself to ensure borders and shadows render immediately during collapse animations. + */ +export const MarkdownCard = forwardRef( + // TODO: Copy content feature will be added in the next release - _onCopy parameter will be used then + ({ title, content, icon, onCopy: _onCopy, showAiDisclaimer = true }, ref) => { + const styles = useStyles(); + + return ( + +
+
+ {icon ?? } +
+
+ + {title} + + } + action={ + showAiDisclaimer ? ( + + {l10n.t('AI responses may be inaccurate')} + + ) : undefined + } + /> +
+ {content} +
+
+
+
+ ); + }, +); + +MarkdownCard.displayName = 'MarkdownCard'; diff --git a/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/optimizationCards/MarkdownCardEx.tsx b/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/optimizationCards/MarkdownCardEx.tsx new file mode 100644 index 000000000..de7a31018 --- /dev/null +++ b/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/optimizationCards/MarkdownCardEx.tsx @@ -0,0 +1,191 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Card, CardHeader, makeStyles, Text, tokens } from '@fluentui/react-components'; +import { SparkleRegular } from '@fluentui/react-icons'; +import * as l10n from '@vscode/l10n'; +import { forwardRef, type JSX } from 'react'; +import ReactMarkdown from 'react-markdown'; +import './optimizationCard.scss'; + +const useStyles = makeStyles({ + content: { + marginTop: '12px', + '& h1, & h2, & h3, & h4, & h5, & h6': { + marginTop: tokens.spacingVerticalM, + marginBottom: tokens.spacingVerticalS, + fontWeight: tokens.fontWeightSemibold, + }, + '& h1': { + fontSize: tokens.fontSizeBase500, + }, + '& h2': { + fontSize: tokens.fontSizeBase400, + }, + '& h3': { + fontSize: tokens.fontSizeBase300, + }, + '& h4, & h5, & h6': { + fontSize: tokens.fontSizeBase300, + }, + '& p': { + marginTop: tokens.spacingVerticalS, + marginBottom: tokens.spacingVerticalS, + fontSize: tokens.fontSizeBase300, + lineHeight: tokens.lineHeightBase300, + }, + '& code': { + backgroundColor: tokens.colorNeutralBackground3, + padding: '2px 4px', + borderRadius: tokens.borderRadiusSmall, + fontFamily: tokens.fontFamilyMonospace, + fontSize: tokens.fontSizeBase200, + }, + '& pre': { + backgroundColor: tokens.colorNeutralBackground3, + padding: tokens.spacingVerticalM, + borderRadius: tokens.borderRadiusMedium, + fontFamily: tokens.fontFamilyMonospace, + fontSize: tokens.fontSizeBase200, + overflow: 'auto', + border: `1px solid ${tokens.colorNeutralStroke2}`, + }, + '& blockquote': { + borderLeft: `3px solid ${tokens.colorBrandBackground}`, + paddingLeft: tokens.spacingHorizontalM, + marginLeft: '0', + fontStyle: 'italic', + }, + '& ul, & ol': { + paddingLeft: tokens.spacingHorizontalL, + }, + '& li': { + marginBottom: tokens.spacingVerticalXS, + }, + '& hr': { + border: 'none', + height: '1px', + backgroundColor: tokens.colorNeutralStroke2, + margin: `${tokens.spacingVerticalM} 0`, + }, + '& a': { + color: tokens.colorBrandForeground1, + textDecoration: 'underline', + }, + '& table': { + borderCollapse: 'collapse', + width: '100%', + marginTop: tokens.spacingVerticalS, + marginBottom: tokens.spacingVerticalS, + }, + '& th, & td': { + border: `1px solid ${tokens.colorNeutralStroke2}`, + padding: tokens.spacingVerticalXS, + textAlign: 'left', + }, + '& th': { + backgroundColor: tokens.colorNeutralBackground2, + fontWeight: tokens.fontWeightSemibold, + }, + }, +}); + +interface MarkdownCardExProps { + /** + * Card title + */ + title: string; + + /** + * Markdown content to render + */ + content: string; + + /** + * Optional custom icon (defaults to SparkleRegular) + */ + icon?: JSX.Element; + + /** + * Optional callback when the copy button is clicked + */ + onCopy?: () => void; + + /** + * Whether to show the AI disclaimer. Default: true + * Set to false for non-AI generated content (e.g., error messages) + */ + showAiDisclaimer?: boolean; + + /** + * Optional children to render between the title and content + */ + children?: React.ReactNode; +} + +/** + * Extended Markdown card component that supports children between title and content. + * This component extends the original MarkdownCard with the ability to insert custom + * content (such as MessageBars) between the title/header and the main markdown content. + * + * **Usage with children**: + * ```tsx + * + * + * Custom message + * + * + * ``` + * + * **Usage with animations**: Use directly with animation libraries like @fluentui/react-motion-components-preview: + * ```tsx + * + * + * + * ``` + * + * **Important**: The component applies `marginBottom: '16px'` by default for proper spacing in animated lists. + * The margin is on the Card itself to ensure borders and shadows render immediately during collapse animations. + */ +export const MarkdownCardEx = forwardRef( + // TODO: Copy content feature will be added in the next release - _onCopy parameter will be used then + ({ title, content, icon, onCopy: _onCopy, showAiDisclaimer = true, children }, ref) => { + const styles = useStyles(); + + return ( + +
+
+ {icon ?? } +
+
+ + {title} + + } + action={ + showAiDisclaimer ? ( + + {l10n.t('AI responses may be inaccurate')} + + ) : undefined + } + style={{ marginBottom: '16px' }} + /> + {/* Custom children content between header and main content */} + {children} +
+ {content} +
+
+
+
+ ); + }, +); + +MarkdownCardEx.displayName = 'MarkdownCardEx'; diff --git a/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/optimizationCards/TipsCard.scss b/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/optimizationCards/TipsCard.scss new file mode 100644 index 000000000..c2daecf2c --- /dev/null +++ b/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/optimizationCards/TipsCard.scss @@ -0,0 +1,11 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.tips-card { + &-actions-container { + display: flex; + gap: 4px; + } +} diff --git a/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/optimizationCards/TipsCard.tsx b/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/optimizationCards/TipsCard.tsx new file mode 100644 index 000000000..48c07e2b0 --- /dev/null +++ b/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/optimizationCards/TipsCard.tsx @@ -0,0 +1,163 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Button, Card, CardHeader, Text, tokens } from '@fluentui/react-components'; +import { ChevronLeftRegular, ChevronRightRegular, DismissRegular, LightbulbRegular } from '@fluentui/react-icons'; +import { forwardRef, useState } from 'react'; +import { useTrpcClient } from '../../../../../../api/webview-client/useTrpcClient'; +import './optimizationCard.scss'; +import './TipsCard.scss'; + +export interface Tip { + title: string; + description: string; +} + +export interface TipsCardProps { + /** + * The main title of the card + */ + title: string; + + /** + * Array of tips to display + */ + tips: Tip[]; + + /** + * Optional callback when the card is dismissed + */ + onDismiss?: () => void; + + /** + * Optional callback when the copy button is clicked + */ + onCopy?: () => void; +} + +/** + * Card component for displaying performance tips with carousel navigation. + * This component supports ref forwarding for use with animation libraries. + * + * **Usage with animations**: Use directly with animation libraries like @fluentui/react-motion-components-preview: + * + * ```tsx + * + * + * + * ``` + * + * **Important**: The component applies `marginBottom: '16px'` by default for proper spacing in animated lists. + * The margin is on the Card itself to ensure borders and shadows render immediately during collapse animations. + */ +export const TipsCard = forwardRef(({ title, tips, onDismiss }, ref) => { + const { trpcClient } = useTrpcClient(); + const [currentIndex, setCurrentIndex] = useState(0); + + const handleNext = () => { + setCurrentIndex((prev) => (prev + 1) % tips.length); + + // Report tip navigation telemetry + trpcClient.common.reportEvent + .mutate({ + eventName: 'queryInsights.tipNavigated', + properties: { + direction: 'next', + fromIndex: currentIndex.toString(), + toIndex: ((currentIndex + 1) % tips.length).toString(), + }, + }) + .catch((error) => { + console.debug('Failed to report tip navigation:', error); + }); + }; + + const handlePrevious = () => { + setCurrentIndex((prev) => (prev - 1 + tips.length) % tips.length); + + // Report tip navigation telemetry + trpcClient.common.reportEvent + .mutate({ + eventName: 'queryInsights.tipNavigated', + properties: { + direction: 'previous', + fromIndex: currentIndex.toString(), + toIndex: ((currentIndex - 1 + tips.length) % tips.length).toString(), + }, + }) + .catch((error) => { + console.debug('Failed to report tip navigation:', error); + }); + }; + + const handleDismiss = () => { + // Report tips dismissal telemetry + trpcClient.common.reportEvent + .mutate({ + eventName: 'queryInsights.tipsDismissed', + }) + .catch((error) => { + console.debug('Failed to report tips dismissal:', error); + }); + + onDismiss?.(); + }; + + return ( + +
+ +
+ + {title} + + } + action={ +
+
+ } + /> + + {tips[currentIndex].title} + + {tips[currentIndex].description} +
+
+
+ ); +}); + +TipsCard.displayName = 'TipsCard'; diff --git a/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/optimizationCards/custom/GetPerformanceInsightsCard.scss b/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/optimizationCards/custom/GetPerformanceInsightsCard.scss new file mode 100644 index 000000000..e7b9b7032 --- /dev/null +++ b/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/optimizationCards/custom/GetPerformanceInsightsCard.scss @@ -0,0 +1,21 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.get-performance-insights-card { + padding: 20px; + position: relative; + + &-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; + } + + &-loading { + display: flex; + align-items: center; + gap: 12px; + } +} diff --git a/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/optimizationCards/custom/GetPerformanceInsightsCard.tsx b/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/optimizationCards/custom/GetPerformanceInsightsCard.tsx new file mode 100644 index 000000000..994834e5a --- /dev/null +++ b/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/optimizationCards/custom/GetPerformanceInsightsCard.tsx @@ -0,0 +1,174 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { + Button, + Card, + MessageBar, + MessageBarBody, + MessageBarTitle, + Spinner, + Text, + tokens, +} from '@fluentui/react-components'; +import { SparkleRegular } from '@fluentui/react-icons'; +import * as l10n from '@vscode/l10n'; +import { forwardRef } from 'react'; +import '../optimizationCard.scss'; +import './GetPerformanceInsightsCard.scss'; + +export interface GetPerformanceInsightsCardProps { + /** + * The body text describing the query performance + */ + bodyText: string; + + /** + * Optional recommendation text. If not provided, the recommendation line won't be shown + */ + recommendation?: string; + + /** + * Whether the AI is currently loading/analyzing + */ + isLoading: boolean; + + /** + * Whether the card actions are enabled. When false, action buttons are disabled. + */ + enabled?: boolean; + + /** + * Optional error message. If provided, shows error state with retry button + */ + errorMessage?: string; + + /** + * Handler for the "Get AI Performance Insights" button + */ + onGetInsights: () => void; + + /** + * Handler for the "Learn more about AI Performance Insights" button + */ + onLearnMore: () => void; + + /** + * Handler for the "Cancel" button (shown during loading) + */ + onCancel: () => void; + + /** + * Optional className to apply to the Card component (e.g., for spacing) + */ + className?: string; +} + +/** + * Branded card component for prompting users to get AI-powered performance insights. + * This component supports ref forwarding for use with animation libraries. + * + * **Usage with animations**: Use directly with animation libraries like @fluentui/react-motion-components-preview: + * + * ```tsx + * + * + * + * ``` + * + * **Note**: This component does not apply default margins. Use the `className` prop to apply + * spacing classes (e.g., `cardSpacing`) when using in layouts that require spacing. + */ +export const GetPerformanceInsightsCard = forwardRef( + ( + { + bodyText, + recommendation, + isLoading, + enabled = true, + errorMessage, + onGetInsights, + onLearnMore, + onCancel, + className, + }, + ref, + ) => { + return ( + + + {l10n.t('AI responses may be inaccurate')} + +
+ +
+ + {l10n.t('AI Performance Insights')} + + + {bodyText} + + {recommendation && ( + + {recommendation} + + )} + {errorMessage && ( + + + Error: + {errorMessage} + + + )} + {isLoading ? ( +
+ + {l10n.t('AI is analyzing...')} + +
+ ) : ( +
+ + +
+ )} +
+
+
+ ); + }, +); + +GetPerformanceInsightsCard.displayName = 'GetPerformanceInsightsCard'; diff --git a/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/optimizationCards/custom/index.ts b/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/optimizationCards/custom/index.ts new file mode 100644 index 000000000..2e8fdf039 --- /dev/null +++ b/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/optimizationCards/custom/index.ts @@ -0,0 +1,6 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export * from './GetPerformanceInsightsCard'; diff --git a/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/optimizationCards/index.ts b/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/optimizationCards/index.ts new file mode 100644 index 000000000..313ea5697 --- /dev/null +++ b/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/optimizationCards/index.ts @@ -0,0 +1,11 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export * from './AiCard'; +export * from './custom'; +export * from './ImprovementCard'; +export * from './MarkdownCard'; +export * from './MarkdownCardEx'; +export * from './TipsCard'; diff --git a/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/optimizationCards/optimizationCard.scss b/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/optimizationCards/optimizationCard.scss new file mode 100644 index 000000000..325aec7e6 --- /dev/null +++ b/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/optimizationCards/optimizationCard.scss @@ -0,0 +1,41 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// Shared properties for all optimization cards +.optimization-card { + // Icon sizing (used by all cards) + &-icon { + font-size: 40px; + color: var(--colorBrandForeground1); + } + + // Container layout (used by all cards) + &-container { + display: flex; + gap: 16px; + } +} + +// Index code block display +.index-code-block { + background-color: var(--vscode-textCodeBlock-background); + border: 1px solid var(--vscode-panel-border); + border-radius: 4px; + padding: 8px 12px; + font-family: var(--vscode-editor-font-family); + font-size: var(--vscode-editor-font-size); + margin: 0; + overflow: auto; + white-space: pre-wrap; + word-break: break-word; +} + +// Index details text (appears below code block) +.index-details-text { + display: block; + margin-top: 4px; + color: var(--vscode-descriptionForeground); + font-size: 12px; +} diff --git a/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/queryPlanSummary/QueryPlanSummary.scss b/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/queryPlanSummary/QueryPlanSummary.scss new file mode 100644 index 000000000..b1879693f --- /dev/null +++ b/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/queryPlanSummary/QueryPlanSummary.scss @@ -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. + *--------------------------------------------------------------------------------------------*/ + +.planSection { + display: flex; + flex-direction: column; + gap: 12px; +} + +.stage-separator { + display: flex; + justify-content: center; + align-items: center; + color: var(--vscode-descriptionForeground); +} + +.queryPlanContent { + display: flex; + gap: 20px; + margin-top: 16px; + align-items: flex-start; +} + +.queryPlanTabs { + flex-shrink: 0; + + & button { + font-size: 12px; + font-weight: 600; + } +} + +.queryPlanDetails { + flex: 1; + min-width: 0; +} + +.queryPlanPlaceholder { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 40px 20px; + text-align: center; + color: var(--vscode-descriptionForeground); +} + +.detailsGrid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 12px; + margin-top: 12px; +} + +.detailItem { + display: flex; + flex-direction: column; + gap: 4px; +} + +.codeBlock { + background-color: var(--vscode-editor-background); + padding: 8px 12px; + border-radius: 4px; + font-family: monospace; + font-size: 11px; + border: 1px solid var(--vscode-editorWidget-border); + overflow-x: auto; + margin-top: 8px; +} + +.stageHeader { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; +} + +.stageHeaderLeft { + display: flex; + align-items: center; + gap: 8px; +} + +.indexBoundsLabel { + margin-top: 12px; + display: block; +} diff --git a/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/queryPlanSummary/QueryPlanSummary.tsx b/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/queryPlanSummary/QueryPlanSummary.tsx new file mode 100644 index 000000000..cf83f2f87 --- /dev/null +++ b/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/queryPlanSummary/QueryPlanSummary.tsx @@ -0,0 +1,441 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { + Accordion, + AccordionHeader, + AccordionItem, + AccordionPanel, + Badge, + Button, + Card, + Skeleton, + SkeletonItem, + Text, + tokens, +} from '@fluentui/react-components'; +import { ArrowUpFilled, EyeRegular, WarningRegular } from '@fluentui/react-icons'; +import * as l10n from '@vscode/l10n'; +import * as React from 'react'; +import { useTrpcClient } from '../../../../../../api/webview-client/useTrpcClient'; +import { + type QueryInsightsStage1Response, + type QueryInsightsStage2Response, +} from '../../../../../../documentdb/collectionView/types/queryInsights'; +import '../../queryInsights.scss'; +import './QueryPlanSummary.scss'; +import { StageDetailCard, type StageType } from './StageDetailCard'; + +interface QueryPlanSummaryProps { + stage1Data: QueryInsightsStage1Response | null; + stage2Data: QueryInsightsStage2Response | null; + stage1Loading: boolean; + stage2Loading: boolean; + hasError: boolean; +} + +export const QueryPlanSummary: React.FC = ({ + stage1Data, + stage2Data, + stage1Loading, + stage2Loading, + hasError, +}) => { + const { trpcClient } = useTrpcClient(); + + const handleStageDetailsToggle = (isExpanded: boolean) => { + trpcClient.common.reportEvent + .mutate({ + eventName: 'queryInsights.stageDetailsToggled', + properties: { + action: isExpanded ? 'expanded' : 'collapsed', + isSharded: stage1Data?.isSharded ? 'true' : 'false', + }, + }) + .catch((error) => { + console.debug('Failed to report stage details toggle:', error); + }); + }; + + const handleViewRawExplain = async () => { + try { + await trpcClient.mongoClusters.collectionView.viewRawExplainOutput.mutate(); + } catch (error) { + void trpcClient.common.displayErrorMessage.mutate({ + message: l10n.t('Failed to open raw execution stats'), + modal: false, + cause: error instanceof Error ? error.message : String(error), + }); + } + }; + + return ( + +
+ + {l10n.t('Query Plan Summary')} + + {stage2Data && ( + + )} +
+ + {/* Show skeleton if Stage 1 is loading or no data yet */} + {(stage1Loading || (!stage1Data && !stage2Data)) && !hasError && ( + + + + + + )} + + {/* Show N/A when in error state */} + {hasError && ( + + {l10n.t('N/A')} + + )} + + {/* Show real data when Stage 1 is available */} + {stage1Data && !stage1Loading && !hasError && ( + <> + {/* Sharded query view */} + {stage1Data.isSharded && stage1Data.shards && stage1Data.shards.length > 0 ? ( +
+ {/* Lightweight merge info */} +
+ {(!stage2Data || stage2Loading) && ( +
+ + {l10n.t('SHARD_MERGE · {0} shards', stage1Data.shards.length)} + + + + +
+ )} + {stage2Data && !stage2Loading && ( + + {l10n.t( + 'SHARD_MERGE · {0} shards · {1} docs · {2}ms', + stage1Data.shards.length, + stage2Data.documentsReturned, + stage2Data.executionTimeMs.toFixed(0), + )} + + )} +
+ + {stage1Data.shards.map((shard) => { + // Find matching shard data from stage2 if available + const shard2Data = stage2Data?.shards?.find((s) => s.shardName === shard.shardName); + + return ( +
+ {/* Shard Summary (always visible) */} +
+ + {l10n.t('Shard: {0}', shard.shardName)} + + {/* Stage flow with badges */} +
+ {[...shard.stages].reverse().map((stage, index) => { + // Check if this stage has failed (from extended stage info) + const stageIndex = [...shard.stages].length - 1 - index; // Original index before reverse + const extendedInfo = stage2Data?.extendedStageInfo?.[stageIndex]; + const hasFailed = extendedInfo?.properties?.['Failed'] === true; + + return ( + + {index > 0 && } + : undefined} + > + {stage.stage} + + + ); + })} +
+ + {/* Metrics - show skeleton until Stage 2 data is available */} + {(!shard2Data || stage2Loading) && ( + + + + )} + {shard2Data && !stage2Loading && ( + + {shard2Data.nReturned || 0} returned ·{' '} + {(shard2Data.keysExamined || 0).toLocaleString()} keys ·{' '} + {(shard2Data.docsExamined || 0).toLocaleString()} docs ·{' '} + {shard2Data.executionTimeMs || 0}ms + + )} +
+ + {/* Expandable Stage Details - only show when Stage 2 data is available */} + {shard2Data && !stage2Loading && ( + { + handleStageDetailsToggle(data.openItems.length > 0); + }} + > + + + {l10n.t('Show Stage Details')} + + +
+ {shard2Data.stages.map((stage, stageIndex) => { + const metrics: Array<{ + label: string; + value: string | number; + }> = []; + + // Note: Extended stage info for sharded queries + // would require accessing stage2Data.extendedStageInfo + // For now, sharded queries don't show extended properties + + // Check if stage has failed (from extended info if available) + const extendedInfo = + stage2Data?.extendedStageInfo?.[stageIndex]; + const hasFailed = + extendedInfo?.properties?.['Failed'] === true; + + return ( + + {stageIndex > 0 && ( +
+ +
+ )} + 0 ? metrics : undefined + } + hasFailed={hasFailed} + /> +
+ ); + })} +
+
+
+
+ )} +
+ ); + })} +
+ ) : ( + /* Non-sharded query view */ +
+ {/* Summary (always visible from Stage 1) */} +
+ + {l10n.t('Your Cluster')} + + {/* Stage flow with badges */} +
+ {[...stage1Data.stages].reverse().map((stage, index) => { + // Check if this stage has failed (from extended stage info) + const stageIndex = [...stage1Data.stages].length - 1 - index; // Original index before reverse + const extendedInfo = stage2Data?.extendedStageInfo?.[stageIndex]; + const hasFailed = extendedInfo?.properties?.['Failed'] === true; + + return ( + + {index > 0 && } + : undefined} + > + {stage.stage} + + + ); + })} +
{' '} + {/* Metrics - show skeleton until Stage 2 data is available */} + {(!stage2Data || stage2Loading) && ( + + + + )} + {stage2Data && !stage2Loading && ( + + {stage2Data.documentsReturned} returned ·{' '} + {stage2Data.totalKeysExamined.toLocaleString()} keys ·{' '} + {stage2Data.totalDocsExamined.toLocaleString()} docs ·{' '} + {stage2Data.executionTimeMs.toFixed(2)}ms + + )} +
+ + {/* Expandable Stage Details - only show when Stage 2 data is available */} + {stage2Data && !stage2Loading && ( + { + handleStageDetailsToggle(data.openItems.length > 0); + }} + > + + {l10n.t('Show Stage Details')} + +
+ {stage2Data.stages.map((stage, stageIndex) => { + const metrics: Array<{ label: string; value: string | number }> = + []; + + // Note: keysExamined, docsExamined and so on are added via extendedStageInfo + + // Add stage-specific properties from extendedStageInfo + // Arrays are built in the same traversal order, so index-based matching is correct + const extendedInfo = stage2Data.extendedStageInfo?.[stageIndex]; + let hasFailed = false; + + if (extendedInfo?.properties) { + Object.entries(extendedInfo.properties).forEach( + ([key, value]) => { + if (value !== undefined) { + // Check if this stage has failed + if (key === 'Failed' && value === true) { + hasFailed = true; + } + + // Convert value to string for display with proper formatting + let displayValue: string; + if (typeof value === 'boolean') { + displayValue = value ? 'Yes' : 'No'; + } else if (typeof value === 'number') { + // Use toLocaleString for numbers to get thousand separators + displayValue = value.toLocaleString(); + } else if (typeof value === 'object') { + displayValue = JSON.stringify(value); + } else { + // String values - use as is + displayValue = String(value); + } + + metrics.push({ + label: key, + value: displayValue, + }); + } + }, + ); + } + + return ( + + {stageIndex > 0 && ( +
+ +
+ )} + 0 ? metrics : undefined} + hasFailed={hasFailed} + /> +
+ ); + })} +
+
+
+
+ )} +
+ )} + + )} +
+ ); +}; diff --git a/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/queryPlanSummary/StageDetailCard.scss b/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/queryPlanSummary/StageDetailCard.scss new file mode 100644 index 000000000..5883501f3 --- /dev/null +++ b/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/queryPlanSummary/StageDetailCard.scss @@ -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. + *--------------------------------------------------------------------------------------------*/ + +@use '../../queryInsights.scss' as *; + +.stage-detail-card { + display: flex; + flex-direction: column; + + .stage-detail-card-header { + display: flex; + align-items: center; + gap: 6px; + } + + .stage-detail-card-description { + color: var(--vscode-descriptionForeground); + } + + .cellLabel { + @extend .baseDataHeader; + } + + .cellValue { + font-size: 12px; + font-weight: 400; + } + + // ================== + // Primary metrics: Bordered grid cells + // ================== + .primary-metrics-bordered-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + + .primary-metric-grid-cell { + display: flex; + flex-direction: column; + + .cellLabel { + display: inline; + } + } + } + + // ================== + // Optional metrics: Inline badges + // ================== + .metrics-inline-badges { + display: flex; + flex-wrap: wrap; + gap: 6px; + + .badge-label { + color: var(--vscode-descriptionForeground); + } + + .badge-value { + color: var(--vscode-foreground); + } + } +} diff --git a/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/queryPlanSummary/StageDetailCard.tsx b/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/queryPlanSummary/StageDetailCard.tsx new file mode 100644 index 000000000..13fb4bc3d --- /dev/null +++ b/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/queryPlanSummary/StageDetailCard.tsx @@ -0,0 +1,144 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Badge, Card, Text, Tooltip } from '@fluentui/react-components'; +import { WarningRegular } from '@fluentui/react-icons'; +import { forwardRef } from 'react'; +import './StageDetailCard.scss'; + +export type StageType = 'IXSCAN' | 'FETCH' | 'PROJECTION' | 'SORT' | 'COLLSCAN'; + +export interface StageMetric { + label: string; + value: string | number; +} + +export interface StageDetailCardProps { + /** + * The type of stage (shown as badge) + */ + stageType: StageType; + + /** + * Description text shown next to the badge (e.g., "Index Name: user_id_1", "In-memory sort") + */ + description?: string; + + /** + * Number of documents returned + */ + returned?: number; + + /** + * Execution time in milliseconds + */ + executionTimeMs?: number; + + /** + * Additional key-value pairs for stage-specific metrics + */ + metrics?: StageMetric[]; + + /** + * Whether this stage has failed + */ + hasFailed?: boolean; + + /** + * Optional className for styling + */ + className?: string; +} + +/** + * Stage detail card component for displaying query execution plan stage information. + * Uses bordered grid cells for primary metrics (Returned + Execution Time). + * Supports ref forwarding for use with animation libraries. + */ +export const StageDetailCard = forwardRef( + ({ stageType, description, returned, executionTimeMs, metrics, hasFailed, className }, ref) => { + // Use danger color for failed stages, otherwise use brand color + const badgeColor = hasFailed ? 'danger' : 'brand'; + + return ( + + {/* Header: Badge + Description */} +
+ : undefined} + > + {stageType} + + {description && ( + + {description} + + )} +
+ + {/* Primary metrics: Bordered grid cells */} + {(returned !== undefined || executionTimeMs !== undefined) && ( +
+ {returned !== undefined && ( +
+
Returned
+ {returned.toLocaleString()} +
+ )} + {executionTimeMs !== undefined && ( +
+
Execution Time
+ {executionTimeMs.toFixed(2)}ms +
+ )} +
+ )} + + {/* Optional metrics: Gray badges */} + {metrics && metrics.length > 0 && ( +
+ {metrics.map((metric, index) => { + const valueStr = + typeof metric.value === 'number' ? metric.value.toLocaleString() : String(metric.value); + + // Truncate long values (over 50 characters) + const maxLength = 50; + const isTruncated = valueStr.length > maxLength; + const displayValue = isTruncated ? valueStr.substring(0, maxLength) + '...' : valueStr; + + const badgeContent = ( + + {metric.label}:  + {displayValue} + + ); + + // Wrap in tooltip if truncated + return isTruncated ? ( + + {badgeContent} + + ) : ( + badgeContent + ); + })} +
+ )} +
+ ); + }, +); + +StageDetailCard.displayName = 'StageDetailCard'; diff --git a/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/queryPlanSummary/index.ts b/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/queryPlanSummary/index.ts new file mode 100644 index 000000000..fcbb8ea0b --- /dev/null +++ b/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/queryPlanSummary/index.ts @@ -0,0 +1,8 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export { QueryPlanSummary } from './QueryPlanSummary'; +export { StageDetailCard } from './StageDetailCard'; +export type { StageDetailCardProps, StageMetric, StageType } from './StageDetailCard'; diff --git a/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/summaryCard/CellBase.tsx b/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/summaryCard/CellBase.tsx new file mode 100644 index 000000000..16633d1c1 --- /dev/null +++ b/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/summaryCard/CellBase.tsx @@ -0,0 +1,81 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { SkeletonItem } from '@fluentui/react-components'; +import * as React from 'react'; +import './SummaryCard.scss'; + +/** + * Base cell component that provides the layout and placeholder logic. + * This component is NOT exported - use specialized cell components instead. + * + * All cell components extend this to inherit: + * - Consistent layout + * - Placeholder handling (loading skeleton vs null value placeholder) + * - Label/value layout + * - Column spanning support + * + * Placeholder behavior: + * - When value is undefined: Shows loading skeleton (configurable via loadingPlaceholder) + * - When value is null: Shows null value placeholder (configurable via nullValuePlaceholder, default: 'N/A') + */ +export interface CellBaseProps { + /** The label displayed at the top of the cell */ + label: string; + + /** The formatted value or custom React node to display + * - undefined: Data is loading (shows skeleton) + * - null: Data is unavailable/not applicable (shows nullValuePlaceholder) + * - string/number/ReactNode: Display the value + */ + value?: string | number | React.ReactNode; + + /** What to display while data is loading (when value is undefined) */ + loadingPlaceholder?: 'skeleton' | 'empty'; + + /** What to display when value is explicitly null (data unavailable) */ + nullValuePlaceholder?: string; + + /** Column spanning: 'single' (1 column) or 'full' (2 columns) */ + span?: 'single' | 'full'; +} + +/** + * Internal base component for summary cells. + * DO NOT use directly - use GenericCell or create a custom cell component. + */ +export const CellBase: React.FC = ({ + label, + value, + loadingPlaceholder = 'skeleton', + nullValuePlaceholder = 'N/A', + span = 'single', +}) => { + const renderValue = () => { + // Explicit null means data is unavailable (e.g., error state, not supported) + if (value === null) { + return {nullValuePlaceholder}; + } + + // Undefined means data is still loading + if (value === undefined) { + if (loadingPlaceholder === 'skeleton') { + return ; + } + return null; // empty + } + + return value; + }; + + const cellClassName = span === 'full' ? 'summaryCell cellSpanFull' : 'summaryCell'; + + return ( +
+
{label}
+ {renderValue()} +
+ ); +}; diff --git a/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/summaryCard/GenericCell.scss b/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/summaryCard/GenericCell.scss new file mode 100644 index 000000000..cee2d0abf --- /dev/null +++ b/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/summaryCard/GenericCell.scss @@ -0,0 +1,11 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +@use '../../queryInsights.scss' as *; + +.cellValue { + @extend .baseDataValue; + font-size: 14px; +} diff --git a/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/summaryCard/GenericCell.tsx b/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/summaryCard/GenericCell.tsx new file mode 100644 index 000000000..64c7fb4bf --- /dev/null +++ b/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/summaryCard/GenericCell.tsx @@ -0,0 +1,107 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as React from 'react'; +import { CellBase } from './CellBase'; +import './GenericCell.scss'; + +/** + * Generic cell for simple string/number values in a SummaryCard. + * + * Value handling: + * - undefined: Shows loading skeleton (data is being fetched) + * - null: Shows N/A or custom nullValuePlaceholder (data unavailable/error) + * - string/number: Displays the formatted value + * + * Example usage: + * ```tsx + * + * + * + * + * // Custom null placeholder for error states + * + * ``` + * + * To create custom cells with special formatting or components: + * 1. Import CellBase from './CellBase' + * 2. Format your value or create a custom React node + * 3. Pass it to CellBase as the value prop + * 4. Set span='full' if you want the cell to span 2 columns + * + * Example of a custom cell: + * ```tsx + * export const MyCustomCell: React.FC = ({ label, data }) => { + * const customContent = ( + *
+ * + * {formatData(data)} + *
+ * ); + * + * return ( + * + * ); + * }; + * ``` + * + * See custom/PerformanceRatingCell.tsx for a complete example. + */ + +export interface GenericCellProps { + /** The label displayed at the top of the cell */ + label: string; + + /** The value to display (will be converted to string) + * - undefined: Data is loading + * - null: Data is unavailable + * - string/number: Value to display + */ + value?: string | number | null | undefined; + + /** What to display while data is loading (when value is undefined) */ + loadingPlaceholder?: 'skeleton' | 'empty'; + + /** What to display when value is explicitly null (data unavailable) */ + nullValuePlaceholder?: string; +} + +export const GenericCell: React.FC = ({ + label, + value, + loadingPlaceholder = 'skeleton', + nullValuePlaceholder = 'N/A', +}) => { + // Preserve null vs undefined distinction + // - null → passes null to CellBase (shows nullValuePlaceholder) + // - undefined → passes undefined to CellBase (shows skeleton) + // - string/number → wraps in span and passes to CellBase + const displayValue = + value === null ? null : value !== undefined ? {String(value)} : undefined; + + return ( + + ); +}; diff --git a/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/summaryCard/SummaryCard.scss b/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/summaryCard/SummaryCard.scss new file mode 100644 index 000000000..194014bdd --- /dev/null +++ b/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/summaryCard/SummaryCard.scss @@ -0,0 +1,45 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +@use '../../queryInsights.scss' as *; + +.summaryCard { + flex-shrink: 0; +} + +.summaryGrid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 16px; +} + +.summaryCell { + display: flex; + flex-direction: column; + gap: 4px; + align-items: flex-start; + grid-column: span 1; + + .cellLabel { + @extend .baseDataHeader; + } + + &.cellSpanFull { + grid-column: span 2; + margin-top: 4px; + align-items: stretch; // Allow children to fill full width + + .cellLabel { + margin-bottom: 8px; + display: block; + } + } +} + +// Styling for null value placeholders (N/A, Not Available, etc.) +.nullValue { + opacity: 0.5; + color: var(--vscode-disabledForeground); +} diff --git a/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/summaryCard/SummaryCard.tsx b/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/summaryCard/SummaryCard.tsx new file mode 100644 index 000000000..b7182fb53 --- /dev/null +++ b/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/summaryCard/SummaryCard.tsx @@ -0,0 +1,40 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Card, Text } from '@fluentui/react-components'; +import * as React from 'react'; +import './SummaryCard.scss'; + +/** + * Container component for displaying a summary card with a title and cells. + * + * Example usage: + * ```tsx + * + * + * + * + * + * ``` + */ + +export interface SummaryCardProps { + /** The title displayed at the top of the card */ + title: string; + + /** Cell components to display in the card */ + children: React.ReactNode; +} + +export const SummaryCard: React.FC = ({ title, children }) => { + return ( + + + {title} + +
{children}
+
+ ); +}; diff --git a/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/summaryCard/custom/PerformanceRatingCell.scss b/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/summaryCard/custom/PerformanceRatingCell.scss new file mode 100644 index 000000000..e90bde03b --- /dev/null +++ b/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/summaryCard/custom/PerformanceRatingCell.scss @@ -0,0 +1,21 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// Performance rating specific styles +.efficiencyIndicator { + display: flex; + align-items: center; + gap: 8px; + padding: 12px; + border-radius: var(--borderRadiusMedium); + background-color: var(--vscode-editorWidget-background); +} + +.efficiencyDot { + width: 12px; + height: 12px; + border-radius: 50%; + flex-shrink: 0; +} diff --git a/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/summaryCard/custom/PerformanceRatingCell.tsx b/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/summaryCard/custom/PerformanceRatingCell.tsx new file mode 100644 index 000000000..47850ae31 --- /dev/null +++ b/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/summaryCard/custom/PerformanceRatingCell.tsx @@ -0,0 +1,168 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Badge, Text, tokens, Tooltip } from '@fluentui/react-components'; +import { InfoRegular } from '@fluentui/react-icons'; +import { CollapseRelaxed } from '@fluentui/react-motion-components-preview'; +import * as l10n from '@vscode/l10n'; +import * as React from 'react'; +import { type PerformanceDiagnostic } from '../../../../../../../documentdb/collectionView/types/queryInsights'; +import { CellBase } from '../CellBase'; +import './PerformanceRatingCell.scss'; + +export type PerformanceRating = 'poor' | 'fair' | 'good' | 'excellent'; + +export interface PerformanceRatingCellProps { + /** The label displayed at the top of the cell */ + label: string; + + /** The performance rating level + * - undefined: Data is loading (shows skeleton) + * - null: Data is unavailable (shows nullValuePlaceholder) + * - PerformanceRating: Shows rating with color and diagnostics + */ + rating: PerformanceRating | null | undefined; + + /** Array of diagnostic messages explaining the rating */ + diagnostics?: PerformanceDiagnostic[]; + + /** Whether the rating content is visible (for animation) */ + visible?: boolean; + + /** What to display when rating is explicitly null (data unavailable) */ + nullValuePlaceholder?: string; +} + +/** + * Custom cell component for displaying performance ratings with colored indicators. + * Spans the full width (2 columns) of the summary grid. + * + * Value handling: + * - undefined: Shows loading skeleton (data is being fetched) + * - null: Shows N/A or custom nullValuePlaceholder (data unavailable/error) + * - PerformanceRating: Displays rating badge with diagnostics + * + * Example usage: + * ```tsx + * = 2} + * /> + * + * // In error state + * + * ``` + */ +export const PerformanceRatingCell: React.FC = ({ + label, + rating, + diagnostics, + visible = true, + nullValuePlaceholder = 'N/A', +}) => { + const getRatingColor = (rating: PerformanceRating): string => { + switch (rating) { + case 'poor': + return tokens.colorPaletteRedBackground3; + case 'fair': + return tokens.colorPaletteYellowBackground3; + case 'good': + return tokens.colorPaletteGreenBackground3; + case 'excellent': + return tokens.colorPaletteLightGreenBackground3; + } + }; + + const getRatingText = (rating: PerformanceRating): string => { + switch (rating) { + case 'poor': + return l10n.t('Poor'); + case 'fair': + return l10n.t('Fair'); + case 'good': + return l10n.t('Good'); + case 'excellent': + return l10n.t('Excellent'); + } + }; + + // Determine the content to display based on rating value + let customContent: React.ReactNode; + + if (rating === null) { + // Explicit null: data unavailable (will use CellBase's nullValuePlaceholder) + customContent = null; + } else if (rating === undefined) { + // Undefined: data loading (will show skeleton) + customContent = undefined; + } else { + // Has rating: display with animation + customContent = ( + +
+ {/* First row, first column: dot */} +
+ {/* First row, second column: rating text */} + + {getRatingText(rating)} + + {/* Second row, first column: empty */} + {diagnostics && diagnostics.length > 0 &&
} + {/* Second row, second column: diagnostic badges with tooltips */} + {diagnostics && diagnostics.length > 0 && ( +
+ {diagnostics.map((diagnostic, index) => ( + +
+ {diagnostic.message} +
+
{diagnostic.details}
+
+ ), + }} + positioning="above-start" + relationship="description" + > + } + > + {diagnostic.message} + + + ))} +
+ )} +
+ + ); + } + + return ; +}; diff --git a/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/summaryCard/index.ts b/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/summaryCard/index.ts new file mode 100644 index 000000000..54e40577a --- /dev/null +++ b/src/webviews/documentdb/collectionView/components/queryInsightsTab/components/summaryCard/index.ts @@ -0,0 +1,19 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// Export main components +export { SummaryCard } from './SummaryCard'; +export type { SummaryCardProps } from './SummaryCard'; + +// Export cell components +export { GenericCell } from './GenericCell'; +export type { GenericCellProps } from './GenericCell'; + +// Export custom cells +export { PerformanceRatingCell } from './custom/PerformanceRatingCell'; +export type { PerformanceRating, PerformanceRatingCellProps } from './custom/PerformanceRatingCell'; + +// Note: CellBase is intentionally not exported +// Users should use GenericCell or create custom cells that wrap CellBase diff --git a/src/webviews/documentdb/collectionView/components/queryInsightsTab/index.ts b/src/webviews/documentdb/collectionView/components/queryInsightsTab/index.ts new file mode 100644 index 000000000..4e52867b0 --- /dev/null +++ b/src/webviews/documentdb/collectionView/components/queryInsightsTab/index.ts @@ -0,0 +1,6 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export { QueryInsightsMain } from './QueryInsightsTab'; diff --git a/src/webviews/documentdb/collectionView/components/queryInsightsTab/queryInsights.scss b/src/webviews/documentdb/collectionView/components/queryInsightsTab/queryInsights.scss new file mode 100644 index 000000000..cb536ec86 --- /dev/null +++ b/src/webviews/documentdb/collectionView/components/queryInsightsTab/queryInsights.scss @@ -0,0 +1,36 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// Shared styles for Query Insights components + +// Data display styles +.baseDataHeader { + font-size: 12px; + font-weight: 600; + color: var(--vscode-descriptionForeground); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + width: 100%; +} + +.baseDataValue { + font-size: 28px; + font-weight: 600; + line-height: 32px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + width: 100%; +} + +// Card padding override +.cardPadding { + padding: 16px; +} + +.cardPaddingLarge { + padding: 20px; +} diff --git a/src/webviews/documentdb/collectionView/components/DataViewPanelJSON.tsx b/src/webviews/documentdb/collectionView/components/resultsTab/DataViewPanelJSON.tsx similarity index 68% rename from src/webviews/documentdb/collectionView/components/DataViewPanelJSON.tsx rename to src/webviews/documentdb/collectionView/components/resultsTab/DataViewPanelJSON.tsx index d0120200b..8d3bdbc3d 100644 --- a/src/webviews/documentdb/collectionView/components/DataViewPanelJSON.tsx +++ b/src/webviews/documentdb/collectionView/components/resultsTab/DataViewPanelJSON.tsx @@ -5,7 +5,7 @@ import { debounce } from 'es-toolkit'; import * as React from 'react'; -import { MonacoEditor } from '../../../MonacoEditor'; +import { MonacoEditor } from '../../../../components/MonacoEditor'; // eslint-disable-next-line import/no-internal-modules import type * as monacoEditor from 'monaco-editor/esm/vs/editor/editor.api'; @@ -27,9 +27,17 @@ export const DataViewPanelJSON = ({ value }: Props): React.JSX.Element => { const editorRef = React.useRef(null); React.useEffect(() => { - // Add the debounced resize event listener - const debouncedResizeHandler = debounce(handleResize, 200); - window.addEventListener('resize', debouncedResizeHandler); + // Add ResizeObserver to watch parent container size changes + // This detects all resize events: window resize, QueryEditor Collapse animation, etc. + // Debouncing prevents "ResizeObserver loop completed with undelivered notifications" warning + const container = document.querySelector('.resultsDisplayArea'); + let resizeObserver: ResizeObserver | null = null; + + if (container) { + const debouncedResizeHandler = debounce(handleResize, 100); + resizeObserver = new ResizeObserver(debouncedResizeHandler); + resizeObserver.observe(container); + } // Initial layout adjustment handleResize(); @@ -39,7 +47,9 @@ export const DataViewPanelJSON = ({ value }: Props): React.JSX.Element => { if (editorRef.current) { editorRef.current.dispose(); } - window.removeEventListener('resize', debouncedResizeHandler); + if (resizeObserver) { + resizeObserver.disconnect(); + } }; }, []); diff --git a/src/webviews/documentdb/collectionView/components/DataViewPanelTableV2.tsx b/src/webviews/documentdb/collectionView/components/resultsTab/DataViewPanelTable.tsx similarity index 85% rename from src/webviews/documentdb/collectionView/components/DataViewPanelTableV2.tsx rename to src/webviews/documentdb/collectionView/components/resultsTab/DataViewPanelTable.tsx index 6b558665f..3d05d4a3b 100644 --- a/src/webviews/documentdb/collectionView/components/DataViewPanelTableV2.tsx +++ b/src/webviews/documentdb/collectionView/components/resultsTab/DataViewPanelTable.tsx @@ -14,11 +14,11 @@ import { type OnDblClickEventArgs, type OnSelectedRowsChangedEventArgs, } from 'slickgrid-react'; -import { type TableDataEntry } from '../../../../documentdb/ClusterSession'; -import { type CellValue } from '../../../../utils/slickgrid/CellValue'; -import { bsonStringToDisplayString } from '../../../utils/slickgrid/typeToDisplayString'; -import { CollectionViewContext } from '../collectionViewContext'; -import './dataViewPanelTableV2.scss'; +import { type TableDataEntry } from '../../../../../documentdb/ClusterSession'; +import { type CellValue } from '../../../../../utils/slickgrid/CellValue'; +import { bsonStringToDisplayString } from '../../../../utils/slickgrid/typeToDisplayString'; +import { CollectionViewContext } from '../../collectionViewContext'; +import './dataViewPanelTable.scss'; import { LoadingAnimationTable } from './LoadingAnimationTable'; interface Props { @@ -41,7 +41,7 @@ const cellFormatter: Formatter = (_row: number, _cell: number, value: Ce }; }; -export function DataViewPanelTableV2({ liveHeaders, liveData, handleStepIn }: Props): React.JSX.Element { +export function DataViewPanelTable({ liveHeaders, liveData, handleStepIn }: Props): React.JSX.Element { const [currentContext, setCurrentContext] = useContext(CollectionViewContext); const gridRef = useRef(null); @@ -125,13 +125,13 @@ export function DataViewPanelTableV2({ liveHeaders, liveData, handleStepIn }: Pr ); const gridOptions: GridOption = { + // keep this so that slickgrid uses the container when we resize it manually autoResize: { calculateAvailableSizeBy: 'container', container: '#resultsDisplayAreaId', // this is a selector of the parent container, in this case it's the collectionView.tsx and the class is "resultsDisplayArea" - delay: 100, bottomPadding: 2, }, - enableAutoResize: true, + enableAutoResize: false, // Disable SlickGrid's automatic resize, we'll handle it manually with ResizeObserver enableAutoSizeColumns: true, // true by default, we disabled it under the assumption that there are a lot of columns in users' data in general enableCellNavigation: true, @@ -181,6 +181,27 @@ export function DataViewPanelTableV2({ liveHeaders, liveData, handleStepIn }: Pr gridRef.current?.gridService.renderGrid(); }, [liveData, gridColumns]); // Re-run when headers or data change + // Setup ResizeObserver to watch the results container and manually trigger grid resize + React.useEffect(() => { + const container = document.querySelector('.resultsDisplayArea'); + let resizeObserver: ResizeObserver | null = null; + + if (container) { + const debouncedResizeHandler = debounce(() => { + void gridRef.current?.resizerService?.resizeGrid(10); + }, 200); + + resizeObserver = new ResizeObserver(debouncedResizeHandler); + resizeObserver.observe(container); + } + + return () => { + if (resizeObserver) { + resizeObserver.disconnect(); + } + }; + }, []); + if (currentContext.isFirstTimeLoad) { return ; } else { diff --git a/src/webviews/documentdb/collectionView/components/DataViewPanelTree.tsx b/src/webviews/documentdb/collectionView/components/resultsTab/DataViewPanelTree.tsx similarity index 79% rename from src/webviews/documentdb/collectionView/components/DataViewPanelTree.tsx rename to src/webviews/documentdb/collectionView/components/resultsTab/DataViewPanelTree.tsx index e228d2ebb..fed0cb4d7 100644 --- a/src/webviews/documentdb/collectionView/components/DataViewPanelTree.tsx +++ b/src/webviews/documentdb/collectionView/components/resultsTab/DataViewPanelTree.tsx @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { debounce } from 'es-toolkit'; import * as React from 'react'; import { FieldType, Formatters, SlickgridReact, type GridOption } from 'slickgrid-react'; @@ -11,6 +12,8 @@ interface Props { } export const DataViewPanelTree = ({ liveData }: Props): React.JSX.Element => { + const gridRef = React.useRef(null); + const columnsDef = [ { id: 'id_field', @@ -28,13 +31,13 @@ export const DataViewPanelTree = ({ liveData }: Props): React.JSX.Element => { ]; const gridOptions: GridOption = { + // keep this so that slickgrid uses the container when we resize it manually autoResize: { calculateAvailableSizeBy: 'container', container: '.resultsDisplayArea', // this is a selector of the parent container, in this case it's the collectionView.tsx and the class is "resultsDisplayArea" - delay: 100, bottomPadding: 0, }, - enableAutoResize: true, + enableAutoResize: false, // Disable SlickGrid's automatic resize, we'll handle it manually with ResizeObserver enableAutoSizeColumns: true, // true as when using a tree, there are only 3 columns to work with showHeaderRow: false, // this actually hides the filter-view, not the header: https://ghiscoding.gitbook.io/slickgrid-universal/grid-functionalities/tree-data-grid#parentchild-relation-dataset:~:text=If%20you%20don%27t,showHeaderRow%3A%20false @@ -62,7 +65,6 @@ export const DataViewPanelTree = ({ liveData }: Props): React.JSX.Element => { // we can also add a custom Formatter just for the title text portion titleFormatter: (_row, _cell, value, _def, _dataContext) => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/restrict-plus-operands //return `${value} (id: ${dataContext.id} | ${dataContext.parentId ? `parentId: ` + dataContext.parentId : `root`})`; return `${value}`; }, @@ -77,14 +79,33 @@ export const DataViewPanelTree = ({ liveData }: Props): React.JSX.Element => { enableHeaderMenu: false, }; - // Empty dependency array means this runs only once, like componentDidMount + // Setup ResizeObserver to watch the results container and manually trigger grid resize + React.useEffect(() => { + const container = document.querySelector('.resultsDisplayArea'); + let resizeObserver: ResizeObserver | null = null; + + if (container) { + const debouncedResizeHandler = debounce(() => { + void gridRef.current?.resizerService?.resizeGrid(10); + }, 200); + + resizeObserver = new ResizeObserver(debouncedResizeHandler); + resizeObserver.observe(container); + } + + return () => { + if (resizeObserver) { + resizeObserver.disconnect(); + } + }; + }, []); return ( console.log('Tree View created')} /> diff --git a/src/webviews/documentdb/collectionView/components/LoadingAnimationTable.tsx b/src/webviews/documentdb/collectionView/components/resultsTab/LoadingAnimationTable.tsx similarity index 100% rename from src/webviews/documentdb/collectionView/components/LoadingAnimationTable.tsx rename to src/webviews/documentdb/collectionView/components/resultsTab/LoadingAnimationTable.tsx diff --git a/src/webviews/documentdb/collectionView/components/dataViewPanelTableV2.scss b/src/webviews/documentdb/collectionView/components/resultsTab/dataViewPanelTable.scss similarity index 100% rename from src/webviews/documentdb/collectionView/components/dataViewPanelTableV2.scss rename to src/webviews/documentdb/collectionView/components/resultsTab/dataViewPanelTable.scss diff --git a/src/webviews/documentdb/collectionView/components/toolbar/ToolbarMainView.tsx b/src/webviews/documentdb/collectionView/components/toolbar/ToolbarMainView.tsx index 7a4d7d917..917bda6ea 100644 --- a/src/webviews/documentdb/collectionView/components/toolbar/ToolbarMainView.tsx +++ b/src/webviews/documentdb/collectionView/components/toolbar/ToolbarMainView.tsx @@ -12,6 +12,7 @@ import { Toolbar, ToolbarButton, ToolbarDivider, + ToolbarToggleButton, Tooltip, } from '@fluentui/react-components'; import { @@ -21,11 +22,15 @@ import { CommentCheckmarkRegular, EmojiSmileSlightRegular, PlayRegular, + SparkleFilled, + SparkleRegular, } from '@fluentui/react-icons'; import * as l10n from '@vscode/l10n'; import { useContext, type JSX } from 'react'; import { useTrpcClient } from '../../../../api/webview-client/useTrpcClient'; import { CollectionViewContext } from '../../collectionViewContext'; +import { ENABLE_AI_QUERY_GENERATION } from '../../constants'; +import { useHideScrollbarsDuringResize } from '../../hooks/useHideScrollbarsDuringResize'; import { ToolbarDividerTransparent } from './ToolbarDividerTransparent'; export const ToolbarMainView = (): JSX.Element => { @@ -73,6 +78,7 @@ const ToolbarQueryOperations = (): JSX.Element => { const { trpcClient } = useTrpcClient(); const [currentContext, setCurrentContext] = useContext(CollectionViewContext); + const hideScrollbarsTemporarily = useHideScrollbarsDuringResize(); const handleExecuteQuery = () => { // return to the root level @@ -84,11 +90,28 @@ const ToolbarQueryOperations = (): JSX.Element => { }, })); - // execute the query - const queryContent = currentContext.queryEditor?.getCurrentContent() ?? ''; + // execute the query - get all values from the query editor at once + const query = currentContext.queryEditor?.getCurrentQuery() ?? { + filter: '{ }', + project: '{ }', + sort: '{ }', + skip: 0, + limit: 0, + }; + setCurrentContext((prev) => ({ ...prev, - currentQueryDefinition: { ...prev.currentQueryDefinition, queryText: queryContent, pageNumber: 1 }, + activeQuery: { + ...prev.activeQuery, + queryText: query.filter, // deprecated: kept in sync with filter for backward compatibility + filter: query.filter, + project: query.project, + sort: query.sort, + skip: query.skip, + limit: query.limit, + pageNumber: 1, + executionIntent: 'initial', + }, })); trpcClient.common.reportEvent @@ -98,7 +121,7 @@ const ToolbarQueryOperations = (): JSX.Element => { ui: 'button', }, measurements: { - queryLength: queryContent.length, + queryLength: query.filter.length, }, }) .catch((error) => { @@ -110,7 +133,10 @@ const ToolbarQueryOperations = (): JSX.Element => { // basically, do not modify the query at all, do not use the input from the editor setCurrentContext((prev) => ({ ...prev, - currentQueryDefinition: { ...prev.currentQueryDefinition }, + activeQuery: { + ...prev.activeQuery, + executionIntent: 'refresh', + }, })); trpcClient.common.reportEvent @@ -121,9 +147,9 @@ const ToolbarQueryOperations = (): JSX.Element => { view: currentContext.currentView, }, measurements: { - page: currentContext.currentQueryDefinition.pageNumber, - pageSize: currentContext.currentQueryDefinition.pageSize, - queryLength: currentContext.currentQueryDefinition.queryText.length, + page: currentContext.activeQuery.pageNumber, + pageSize: currentContext.activeQuery.pageSize, + queryLength: currentContext.activeQuery.queryText.length, }, }) .catch((error) => { @@ -131,8 +157,27 @@ const ToolbarQueryOperations = (): JSX.Element => { }); }; + const checkedValues = { + aiToggle: currentContext.isAiRowVisible ? ['copilot'] : [], + }; + + const handleCheckedValueChange: React.ComponentProps['onCheckedValueChange'] = ( + _e, + { name, checkedItems }, + ) => { + if (name === 'aiToggle') { + setCurrentContext((prev) => ({ + ...prev, + isAiRowVisible: checkedItems.includes('copilot'), + })); + + // Temporarily hide scrollbars during the transition to improve UX responsiveness + hideScrollbarsTemporarily(); + } + }; + return ( - + { + {ENABLE_AI_QUERY_GENERATION && ( + <> + : } + name="aiToggle" + value="copilot" + > + {l10n.t('Generate')} + + + + )} + { }; const handleExportEntireCollection = () => { - void trpcClient.mongoClusters.collectionView.exportDocuments.query({ query: '{}' }); + void trpcClient.mongoClusters.collectionView.exportDocuments.query({ + filter: '{}', + project: undefined, + sort: undefined, + skip: undefined, + limit: undefined, + }); }; const handleExportQueryResults = () => { void trpcClient.mongoClusters.collectionView.exportDocuments.query({ - query: currentContext.currentQueryDefinition.queryText, + filter: currentContext.activeQuery.filter, + project: currentContext.activeQuery.project, + sort: currentContext.activeQuery.sort, + skip: currentContext.activeQuery.skip, + limit: currentContext.activeQuery.limit, }); }; diff --git a/src/webviews/documentdb/collectionView/components/toolbar/ToolbarViewNavigation.tsx b/src/webviews/documentdb/collectionView/components/toolbar/ToolbarViewNavigation.tsx index 1307ed650..f5b5461a1 100644 --- a/src/webviews/documentdb/collectionView/components/toolbar/ToolbarViewNavigation.tsx +++ b/src/webviews/documentdb/collectionView/components/toolbar/ToolbarViewNavigation.tsx @@ -21,12 +21,12 @@ export const ToolbarViewNavigation = (): JSX.Element => { const [currentContext, setCurrentContext] = useContext(CollectionViewContext); function goToNextPage() { - const newPage = currentContext.currentQueryDefinition.pageNumber + 1; + const newPage = currentContext.activeQuery.pageNumber + 1; setCurrentContext({ ...currentContext, - currentQueryDefinition: { - ...currentContext.currentQueryDefinition, + activeQuery: { + ...currentContext.activeQuery, pageNumber: newPage, }, }); @@ -41,7 +41,7 @@ export const ToolbarViewNavigation = (): JSX.Element => { }, measurements: { page: newPage, - pageSize: currentContext.currentQueryDefinition.pageSize, + pageSize: currentContext.activeQuery.pageSize, }, }) .catch((error) => { @@ -52,12 +52,12 @@ export const ToolbarViewNavigation = (): JSX.Element => { } function goToPreviousPage() { - const newPage = Math.max(1, currentContext.currentQueryDefinition.pageNumber - 1); + const newPage = Math.max(1, currentContext.activeQuery.pageNumber - 1); setCurrentContext({ ...currentContext, - currentQueryDefinition: { - ...currentContext.currentQueryDefinition, + activeQuery: { + ...currentContext.activeQuery, pageNumber: newPage, }, }); @@ -72,7 +72,7 @@ export const ToolbarViewNavigation = (): JSX.Element => { }, measurements: { page: newPage, - pageSize: currentContext.currentQueryDefinition.pageSize, + pageSize: currentContext.activeQuery.pageSize, }, }) .catch((error) => { @@ -85,7 +85,7 @@ export const ToolbarViewNavigation = (): JSX.Element => { function goToFirstPage() { setCurrentContext({ ...currentContext, - currentQueryDefinition: { ...currentContext.currentQueryDefinition, pageNumber: 1 }, + activeQuery: { ...currentContext.activeQuery, pageNumber: 1 }, }); trpcClient.common.reportEvent @@ -98,7 +98,7 @@ export const ToolbarViewNavigation = (): JSX.Element => { }, measurements: { page: 1, - pageSize: currentContext.currentQueryDefinition.pageSize, + pageSize: currentContext.activeQuery.pageSize, }, }) .catch((error) => { @@ -109,8 +109,8 @@ export const ToolbarViewNavigation = (): JSX.Element => { function setPageSize(pageSize: number) { setCurrentContext({ ...currentContext, - currentQueryDefinition: { - ...currentContext.currentQueryDefinition, + activeQuery: { + ...currentContext.activeQuery, pageSize: pageSize, pageNumber: 1, }, @@ -125,7 +125,7 @@ export const ToolbarViewNavigation = (): JSX.Element => { view: currentContext.currentView, }, measurements: { - page: currentContext.currentQueryDefinition.pageNumber, + page: currentContext.activeQuery.pageNumber, pageSize: pageSize, }, }) @@ -169,14 +169,14 @@ export const ToolbarViewNavigation = (): JSX.Element => { { - setPageSize(parseInt(data.optionText ?? '10')); + setPageSize(parseInt(data.optionText ?? currentContext.activeQuery.pageSize.toString())); }} style={{ minWidth: '100px', maxWidth: '100px' }} - defaultValue="10" - defaultSelectedOptions={['10']} + value={currentContext.activeQuery.pageSize.toString()} + selectedOptions={[currentContext.activeQuery.pageSize.toString()]} > - + @@ -185,7 +185,7 @@ export const ToolbarViewNavigation = (): JSX.Element => { ); diff --git a/src/webviews/documentdb/collectionView/constants.ts b/src/webviews/documentdb/collectionView/constants.ts new file mode 100644 index 000000000..822a46fe5 --- /dev/null +++ b/src/webviews/documentdb/collectionView/constants.ts @@ -0,0 +1,10 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Feature flag to enable/disable AI query generation features. + * When false, the Generate button and AI input area are disabled. + */ +export const ENABLE_AI_QUERY_GENERATION = false; diff --git a/src/webviews/documentdb/collectionView/hooks/useHideScrollbarsDuringResize.ts b/src/webviews/documentdb/collectionView/hooks/useHideScrollbarsDuringResize.ts new file mode 100644 index 000000000..e2f98e637 --- /dev/null +++ b/src/webviews/documentdb/collectionView/hooks/useHideScrollbarsDuringResize.ts @@ -0,0 +1,51 @@ +/*--------------------------------------------------------------------------------------------- + * 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 } from 'react'; + +/** + * Custom hook to temporarily hide scrollbars during layout transitions. + * + * This hook provides a function that hides scrollbars on .resultsDisplayArea for 500ms + * to improve UX during QueryEditor transitions (Collapse animations). While the window-level + * scrollbar flickering is fixed by a media query on .collectionView, this logic remains + * useful for speeding up scrollbar re-rendering in SlickGrid (Table/Tree views) during + * the ~100ms debounce period before resize handlers complete. + * + * @returns A function that temporarily hides scrollbars for 500ms + */ +export const useHideScrollbarsDuringResize = (): (() => void) => { + const timeoutRef = useRef(null); + + const hideScrollbarsTemporarily = () => { + const resultsArea = document.querySelector('.resultsDisplayArea'); + if (resultsArea) { + resultsArea.classList.add('resizing'); + + // Clear any existing timeout + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + + // Show scrollbars after 500ms + timeoutRef.current = setTimeout(() => { + resultsArea.classList.remove('resizing'); + timeoutRef.current = null; + }, 500); + } + }; + + // Cleanup timeout on unmount + useEffect(() => { + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + }; + }, []); + + return hideScrollbarsTemporarily; +}; diff --git a/src/webviews/documentdb/collectionView/types/queryInsights.ts b/src/webviews/documentdb/collectionView/types/queryInsights.ts new file mode 100644 index 000000000..f8d13235d --- /dev/null +++ b/src/webviews/documentdb/collectionView/types/queryInsights.ts @@ -0,0 +1,334 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Type definitions for Query Insights feature + * These types are used for the three-stage progressive loading of query performance data + */ + +// ============================================================================ +// Stage 1: Initial Performance View Types +// ============================================================================ + +/** + * Information extracted from a single stage (basic) + */ +export interface StageInfo { + stage: string; + name: string; + nReturned: number; + executionTimeMs?: number; + indexName?: string; + keysExamined?: number; + docsExamined?: number; +} + +/** + * Information about a shard in a sharded query + */ +export interface ShardInfo { + shardName: string; + stages: StageInfo[]; + nReturned?: number; + keysExamined?: number; + docsExamined?: number; + executionTimeMs?: number; + hasCollscan?: boolean; + hasBlockedSort?: boolean; +} + +/** + * Response from Stage 1 - Initial performance view with query planner data + * Stage 1 provides immediate metrics using queryPlanner verbosity (no execution) + * + * @remarks + * Stage 1 uses explain("queryPlanner") which does NOT execute the query. + */ +export interface QueryInsightsStage1Response { + executionTime: number; // Client-side measurement in milliseconds + stages: StageInfo[]; + efficiencyAnalysis: { + executionStrategy: string; + indexUsed: string | null; + hasInMemorySort: boolean; + // Performance rating not available in Stage 1 (requires execution metrics) + }; + /** Shard information for sharded collections */ + shards?: ShardInfo[]; + isSharded?: boolean; +} + +// ============================================================================ +// Stage 2: Detailed Execution Analysis Types +// ============================================================================ + +/** + * Response from Stage 2 - Detailed execution statistics + * + * @remarks + * This response contains two `concerns` arrays that serve different purposes: + * 1. Top-level `concerns: string[]` - Query-level warnings about performance issues + * (e.g., "Collection scan detected", "In-memory sort required") + * 2. `efficiencyAnalysis.performanceRating.concerns: string[]` - Rating-specific concerns + * (e.g., "Very low selectivity", "Needs index optimization") + * + * The `examinedToReturnedRatio` appears in two forms: + * - Top-level `examinedToReturnedRatio: number` - Raw ratio for calculations (e.g., 50.5) + * - `efficiencyAnalysis.examinedReturnedRatio: string` - Formatted for display (e.g., "50:1") + */ +export interface QueryInsightsStage2Response { + executionTimeMs: number; + totalKeysExamined: number; + totalDocsExamined: number; + documentsReturned: number; + /** Raw ratio for calculations (e.g., 50.5 means 50.5 docs examined per doc returned) */ + examinedToReturnedRatio: number; + keysToDocsRatio: number | null; + executionStrategy: string; + indexUsed: boolean; + usedIndexNames: string[]; + hadInMemorySort: boolean; + hadCollectionScan: boolean; + isCoveringQuery: boolean; + /** Top-level query warnings (collection scan, in-memory sort, etc.) */ + concerns: string[]; + efficiencyAnalysis: { + executionStrategy: string; + indexUsed: string | null; + /** Formatted ratio for display (e.g., "50:1") */ + examinedReturnedRatio: string; + hasInMemorySort: boolean; + /** Performance rating with detailed reasons and rating-specific concerns */ + performanceRating: PerformanceRating; + }; + stages: DetailedStageInfo[]; + extendedStageInfo?: ExtendedStageInfo[]; + rawExecutionStats: Record; + /** Shard information for sharded collections */ + shards?: ShardInfo[]; + isSharded?: boolean; +} + +/** + * Error information when query execution fails + */ +export interface QueryExecutionError { + failed: true; + executionSuccess: false; + errorMessage: string; + errorCode?: number; + failedStage?: { + stage: string; + details?: Record; + }; + partialStats: { + docsExamined: number; + executionTimeMs: number; + }; +} + +/** + * Response when query execution fails (alternative to Stage2Response) + */ +export interface QueryInsightsErrorResponse { + stage: 'execution-error'; + error: { + message: string; + code?: number; + failedStage?: string; + partialStats: { + docsExamined: number; + executionTimeMs: number; + }; + }; + // Include partial metrics for context + executionTimeMs: number; + docsExamined: number; + performanceRating: PerformanceRating; + // Include raw stats for debugging + rawExplainPlan?: Record; +} + +/** + * Diagnostic detail about query performance + */ +export interface PerformanceDiagnostic { + type: 'positive' | 'negative' | 'neutral'; + /** Short message for badge text (e.g., "Low efficiency ratio") */ + message: string; + /** Detailed explanation shown in tooltip (e.g., "You return 2% of examined documents. This is bad because...") */ + details: string; +} + +/** + * Performance rating with score and detailed diagnostics + * + * @remarks + * Diagnostics always include consistent assessments: + * - Efficiency ratio (positive/neutral/negative based on returned/examined ratio) + * - Execution time (positive/neutral/negative based on milliseconds) + * - Index usage (positive if indexed, negative if collection scan, neutral otherwise) + * - Sort strategy (positive if no in-memory sort, negative if in-memory sort required) + * + * UI can render diagnostics with icons: + * - positive: ✓ (green checkmark) + * - neutral: ● (gray dot) + * - negative: ⚠ (yellow/red warning) + */ +export interface PerformanceRating { + score: 'excellent' | 'good' | 'fair' | 'poor'; + /** Diagnostic messages explaining the rating, categorized by type */ + diagnostics: PerformanceDiagnostic[]; +} + +/** + * Detailed stage information with execution metrics + */ +export interface DetailedStageInfo extends StageInfo { + works?: number; + advanced?: number; + needTime?: number; + needYield?: number; + saveState?: number; + restoreState?: number; + isEOF?: boolean; +} + +/** + * Extended information for a single stage (for UI display) + */ +export interface ExtendedStageInfo { + stageId?: string; + stageName: string; + properties: Record; +} + +// ============================================================================ +// Stage 3: AI-Powered Recommendations Types +// ============================================================================ + +/** + * Analysis card containing overall AI analysis of query performance + */ +export interface AnalysisCard { + type: 'analysis'; + content: string; // The overall analysis from AI +} + +/** + * Improvement card with actionable recommendation and action buttons + */ +export interface ImprovementCard { + type: 'improvement'; + cardId: string; // Unique identifier + + // Card header + title: string; // e.g., "Recommendation: Create Index" + priority: 'high' | 'medium' | 'low'; + + // Main content + description: string; // Justification field from AI + recommendedIndex: string; // Stringified indexSpec, e.g., "{ user_id: 1 }" + indexName: string; // Name of the index to be created/updated/dropped + recommendedIndexDetails: string; // Additional explanation about the index + indexOptions?: string; // Stringified indexOptions, e.g., "{ unique: true }" + + // Additional info + details: string; // Risks or additional considerations + mongoShellCommand: string; // The mongoShell command to execute + + // Action buttons with complete context for execution (both optional) + primaryButton?: ActionButton; + secondaryButton?: ActionButton; +} + +/** + * Action button with payload for execution + */ +export interface ActionButton { + label: string; // e.g., "Create Index" + actionId: string; // e.g., "createIndex", "dropIndex", "learnMore" + payload?: unknown; // Context needed to perform the action (optional for some actions like "learnMore") +} + +/** + * Complete Stage 3 response from router + */ +export interface QueryInsightsStage3Response { + analysisCard: AnalysisCard; + improvementCards: ImprovementCard[]; + performanceTips?: { + tips: Array<{ + title: string; + description: string; + }>; + dismissible: boolean; + }; + verificationSteps: string; // How to verify improvements + educationalContent?: string; // Optional markdown content for educational cards + animation?: { + staggerDelay: number; + showTipsDuringLoading: boolean; + }; + metadata?: OptimizationMetadata; +} + +/** + * Metadata about the optimization context + */ +export interface OptimizationMetadata { + collectionName: string; + collectionStats?: { + count: number; + size: number; + }; + indexStats?: Array<{ + name: string; + key: Record; + }>; + executionStats?: unknown; + derived?: { + totalKeysExamined: number; + totalDocsExamined: number; + keysToDocsRatio: number; + usedIndex: string; + }; +} + +// ============================================================================ +// Action Payload Types (for button actions) +// ============================================================================ + +/** + * Payload for createIndex action + */ +export interface CreateIndexPayload { + clusterId: string; + databaseName: string; + collectionName: string; + action: 'create'; + indexSpec: Record; + indexOptions?: Record; + mongoShell: string; +} + +/** + * Payload for dropIndex action + */ +export interface DropIndexPayload { + clusterId: string; + databaseName: string; + collectionName: string; + action: 'drop'; + indexSpec: Record; + mongoShell: string; +} + +/** + * Payload for learnMore action + */ +export interface LearnMorePayload { + topic: string; // e.g., "compound-indexes", "index-optimization" +} diff --git a/src/webviews/documentdb/collectionView/utils/errorCodeExtractor.ts b/src/webviews/documentdb/collectionView/utils/errorCodeExtractor.ts new file mode 100644 index 000000000..3b1e52871 --- /dev/null +++ b/src/webviews/documentdb/collectionView/utils/errorCodeExtractor.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. + *--------------------------------------------------------------------------------------------*/ + +/** + * Extracts error code from a tRPC error by accessing error.cause.cause.code. + * + * TRPCClientError wraps errors in nested structures, and when serialized across + * process boundaries, Error instances may become plain objects. + * + * The error code is typically at: error.cause.cause.code (depth 3) + * + * @param error - The error to extract the code from (Error instance or plain object) + * @returns The extracted error code string, or null if not found + * + * @example + * ```typescript + * const errorCode = extractErrorCode(trpcError); + * if (errorCode === 'QUERY_INSIGHTS_PLATFORM_NOT_SUPPORTED_RU') { + * // Handle specific error + * } + * ``` + */ +export function extractErrorCode(error: unknown): string | null { + // Direct access: error.cause.cause.code using optional chaining + type ErrorWithCause = { cause?: ErrorWithCause; code?: unknown }; + const code = (error as ErrorWithCause | null)?.cause?.cause?.code; + + return typeof code === 'string' ? code : null; +} diff --git a/src/webviews/documentdb/documentView/documentView.scss b/src/webviews/documentdb/documentView/documentView.scss index 413b6f42a..7e15c4759 100644 --- a/src/webviews/documentdb/documentView/documentView.scss +++ b/src/webviews/documentdb/documentView/documentView.scss @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -@use '../sharedStyles'; +@use '../../index'; .documentView { display: flex; diff --git a/src/webviews/documentdb/documentView/documentView.tsx b/src/webviews/documentdb/documentView/documentView.tsx index cf453f60b..abfa00283 100644 --- a/src/webviews/documentdb/documentView/documentView.tsx +++ b/src/webviews/documentdb/documentView/documentView.tsx @@ -14,7 +14,7 @@ import { UsageImpact } from '../../../utils/surveyTypes'; import { useConfiguration } from '../../api/webview-client/useConfiguration'; import { useTrpcClient } from '../../api/webview-client/useTrpcClient'; import { useSelectiveContextMenuPrevention } from '../../api/webview-client/utils/useSelectiveContextMenuPrevention'; -import { MonacoEditor } from '../../MonacoEditor'; +import { MonacoEditor } from '../../components/MonacoEditor'; import { ToolbarDocuments } from './components/toolbarDocuments'; import { type DocumentsViewWebviewConfigurationType } from './documentsViewController'; import './documentView.scss'; diff --git a/src/webviews/index.scss b/src/webviews/index.scss index 616c8c778..f5fb4a7cd 100644 --- a/src/webviews/index.scss +++ b/src/webviews/index.scss @@ -8,3 +8,82 @@ .selectionDisabled { user-select: none; } + +$media-breakpoint-query-control-area: 1024px; + +/** + * Shared Fluent UI Input-like Component Styling + * + * These mixins replicate Fluent UI Input/Textarea component styling + * for consistency across custom components like MonacoAutoHeight. + * + * Uses Fluent UI design tokens: + * - Border: --colorNeutralStroke1, --borderRadiusMedium + * - Background: --colorNeutralBackground1 + * - Spacing: --spacingVerticalS, --spacingHorizontalS + * - Animation: --durationFast, --curveEasyEase + * - Focus: --colorCompoundBrandStroke + */ + +/** + * Base border styling for input-like components + */ +@mixin input-border { + border: 1px solid var(--vscode-checkbox-border); // TODO: find the style name of a fluent input field var(--colorNeutralStroke1) doesn't work well, maybe we failed with style adatpation on this one. + border-radius: var(--borderRadiusMedium); +} + +/** + * Base input container styling (background, padding, positioning) + */ +@mixin input-base { + background-color: var(--colorNeutralBackground1); + padding: var(--spacingVerticalS) var(--spacingHorizontalS); + position: relative; // Required for ::after pseudo-element +} + +/** + * Fluent UI's animated bottom border focus effect + * Creates an ::after pseudo-element that scales horizontally on focus + */ +@mixin input-focus-animation { + &::after { + content: ''; + position: absolute; + left: 0; + bottom: 0; + right: 0; + height: 0; + border-bottom: 2px solid var(--colorCompoundBrandStroke); + border-bottom-left-radius: var(--borderRadiusMedium); + border-bottom-right-radius: var(--borderRadiusMedium); + transform: scaleX(0); + transition-property: transform; + transition-duration: var(--durationFast); + transition-timing-function: var(--curveEasyEase); + } + + &:focus-within::after { + transform: scaleX(1); + } +} + +/** + * Hover effect for input-like components + */ +@mixin input-hover { + &:hover:not(:focus-within) { + border-color: var(--colorNeutralStroke1Hover); + } +} + +/** + * Complete Fluent UI input styling + * Combines all input mixins for a complete Fluent UI Input appearance + */ +@mixin fluent-input { + @include input-base; + @include input-border; + @include input-focus-animation; + @include input-hover; +} diff --git a/webpack.config.ext.js b/webpack.config.ext.js index 7c51ed07a..d1937b067 100644 --- a/webpack.config.ext.js +++ b/webpack.config.ext.js @@ -184,6 +184,10 @@ module.exports = (env, { mode }) => { from: './node_modules/@microsoft/vscode-azext-azureutils/resources/azureIcons/MongoClusters.svg', to: 'resources/from_node_modules/@microsoft/vscode-azext-azureutils/resources/azureIcons/MongoClusters.svg', }, + { + from: './node_modules/@microsoft/vscode-azext-azureutils/resources/azureIcons/AzureCosmosDb.svg', + to: 'resources/from_node_modules/@microsoft/vscode-azext-azureutils/resources/azureIcons/AzureCosmosDb.svg', + }, ], }), ].filter(Boolean),