From 47f991cce14f524fee7cbaa709adde49422ffb8a Mon Sep 17 00:00:00 2001 From: Archie Agarwal Date: Wed, 11 Jun 2025 18:17:23 +0530 Subject: [PATCH] feat: Add Index Advisor feature --- .../Settings/SettingsComponent.test.tsx | 47 +++ .../Controls/Settings/SettingsComponent.tsx | 42 ++- .../IndexingPolicyRefreshComponent.tsx | 4 +- .../SettingsComponent.test.tsx.snap | 186 ++++++++++ .../Tabs/QueryTab/IndexAdvisorUtils.tsx | 114 ++++++ .../Tabs/QueryTab/QueryTabComponent.tsx | 20 ++ .../Tabs/QueryTab/ResultsView.test.tsx | 202 +++++++++++ src/Explorer/Tabs/QueryTab/ResultsView.tsx | 335 +++++++++++++++++- src/Explorer/Tabs/QueryTab/StylesAdvisor.ts | 95 +++++ .../Tabs/QueryTab/useQueryMetadataStore.ts | 15 + 10 files changed, 1045 insertions(+), 15 deletions(-) create mode 100644 src/Explorer/Tabs/QueryTab/IndexAdvisorUtils.tsx create mode 100644 src/Explorer/Tabs/QueryTab/ResultsView.test.tsx create mode 100644 src/Explorer/Tabs/QueryTab/StylesAdvisor.ts create mode 100644 src/Explorer/Tabs/QueryTab/useQueryMetadataStore.ts diff --git a/src/Explorer/Controls/Settings/SettingsComponent.test.tsx b/src/Explorer/Controls/Settings/SettingsComponent.test.tsx index 5a7a47def..374b682e0 100644 --- a/src/Explorer/Controls/Settings/SettingsComponent.test.tsx +++ b/src/Explorer/Controls/Settings/SettingsComponent.test.tsx @@ -1,5 +1,8 @@ +import { IndexingPolicy } from "@azure/cosmos"; +import { act } from "@testing-library/react"; import { AuthType } from "AuthType"; import { shallow } from "enzyme"; +import { useIndexingPolicyStore } from "Explorer/Tabs/QueryTab/ResultsView"; import ko from "knockout"; import { Features } from "Platform/Hosted/extractFeatures"; import React from "react"; @@ -288,3 +291,47 @@ describe("SettingsComponent", () => { expect(wrapper.state("isThroughputBucketsSaveable")).toBe(false); }); }); + +describe("SettingsComponent - indexing policy subscription", () => { + const baseProps: SettingsComponentProps = { + settingsTab: new CollectionSettingsTabV2({ + collection: collection, + tabKind: ViewModels.CollectionTabKind.CollectionSettingsV2, + title: "Scale & Settings", + tabPath: "", + node: undefined, + }), + }; + + it("subscribes to the correct container's indexing policy and updates state on change", async () => { + const containerId = collection.id(); + const mockIndexingPolicy: IndexingPolicy = { + automatic: false, + indexingMode: "lazy", + includedPaths: [{ path: "/foo/*" }], + excludedPaths: [{ path: "/bar/*" }], + compositeIndexes: [], + spatialIndexes: [], + vectorIndexes: [], + fullTextIndexes: [], + }; + + const wrapper = shallow(); + const instance = wrapper.instance() as SettingsComponent; + + await act(async () => { + useIndexingPolicyStore.setState({ + indexingPolicies: { + [containerId]: mockIndexingPolicy, + }, + }); + }); + + wrapper.update(); + + expect(wrapper.state("indexingPolicyContent")).toEqual(mockIndexingPolicy); + expect(wrapper.state("indexingPolicyContentBaseline")).toEqual(mockIndexingPolicy); + // @ts-expect-error: rawDataModel is intentionally accessed for test validation + expect(instance.collection.rawDataModel.indexingPolicy).toEqual(mockIndexingPolicy); + }); +}); diff --git a/src/Explorer/Controls/Settings/SettingsComponent.tsx b/src/Explorer/Controls/Settings/SettingsComponent.tsx index 7aca73f57..c5aef800c 100644 --- a/src/Explorer/Controls/Settings/SettingsComponent.tsx +++ b/src/Explorer/Controls/Settings/SettingsComponent.tsx @@ -11,6 +11,7 @@ import { ThroughputBucketsComponent, ThroughputBucketsComponentProps, } from "Explorer/Controls/Settings/SettingsSubComponents/ThroughputInputComponents/ThroughputBucketsComponent"; +import { useIndexingPolicyStore } from "Explorer/Tabs/QueryTab/ResultsView"; import { useDatabases } from "Explorer/useDatabases"; import { isFullTextSearchEnabled, isVectorSearchEnabled } from "Utils/CapabilityUtils"; import { isRunningOnPublicCloud } from "Utils/CloudUtils"; @@ -65,7 +66,6 @@ import { parseConflictResolutionMode, parseConflictResolutionProcedure, } from "./SettingsUtils"; - interface SettingsV2TabInfo { tab: SettingsV2TabTypes; content: JSX.Element; @@ -167,7 +167,7 @@ export class SettingsComponent extends React.Component void; constructor(props: SettingsComponentProps) { super(props); @@ -298,8 +298,19 @@ export class SettingsComponent extends React.Component { + this.refreshCollectionData(); + }, + (state) => state.indexingPolicies[this.collection.id()], + ); + this.refreshCollectionData(); + } + componentWillUnmount(): void { + if (this.unsubscribe) { + this.unsubscribe(); + } } - componentDidUpdate(): void { if (this.props.settingsTab.isActive()) { useCommandBar.getState().setContextButtons(this.getTabsButtons()); @@ -772,7 +783,6 @@ export class SettingsComponent extends React.Component => { + const containerId = this.collection.id(); + const latestIndexingPolicy = useIndexingPolicyStore.getState().indexingPolicies[containerId]; + const rawPolicy = latestIndexingPolicy ?? this.collection.indexingPolicy(); + + const latestCollection: DataModels.IndexingPolicy = { + automatic: rawPolicy?.automatic ?? true, + indexingMode: rawPolicy?.indexingMode ?? "consistent", + includedPaths: rawPolicy?.includedPaths ?? [], + excludedPaths: rawPolicy?.excludedPaths ?? [], + compositeIndexes: rawPolicy?.compositeIndexes ?? [], + spatialIndexes: rawPolicy?.spatialIndexes ?? [], + vectorIndexes: rawPolicy?.vectorIndexes ?? [], + fullTextIndexes: rawPolicy?.fullTextIndexes ?? [], + }; + + this.collection.rawDataModel.indexingPolicy = latestCollection; + this.setState({ + indexingPolicyContent: latestCollection, + indexingPolicyContentBaseline: latestCollection, + }); + }; private saveCollectionSettings = async (startKey: number): Promise => { const newCollection: DataModels.Collection = { ...this.collection.rawDataModel }; - if ( this.state.isSubSettingsSaveable || this.state.isContainerPolicyDirty || @@ -1155,7 +1186,6 @@ export class SettingsComponent extends React.Component diff --git a/src/Explorer/Controls/Settings/SettingsSubComponents/IndexingPolicyRefresh/IndexingPolicyRefreshComponent.tsx b/src/Explorer/Controls/Settings/SettingsSubComponents/IndexingPolicyRefresh/IndexingPolicyRefreshComponent.tsx index d601e3857..509373b8d 100644 --- a/src/Explorer/Controls/Settings/SettingsSubComponents/IndexingPolicyRefresh/IndexingPolicyRefreshComponent.tsx +++ b/src/Explorer/Controls/Settings/SettingsSubComponents/IndexingPolicyRefresh/IndexingPolicyRefreshComponent.tsx @@ -1,10 +1,10 @@ -import * as React from "react"; import { MessageBar, MessageBarType } from "@fluentui/react"; +import * as React from "react"; +import { handleError } from "../../../../../Common/ErrorHandlingUtils"; import { mongoIndexTransformationRefreshingMessage, renderMongoIndexTransformationRefreshMessage, } from "../../SettingsRenderUtils"; -import { handleError } from "../../../../../Common/ErrorHandlingUtils"; import { isIndexTransforming } from "../../SettingsUtils"; export interface IndexingPolicyRefreshComponentProps { diff --git a/src/Explorer/Controls/Settings/__snapshots__/SettingsComponent.test.tsx.snap b/src/Explorer/Controls/Settings/__snapshots__/SettingsComponent.test.tsx.snap index ea6fe2864..d68e260a9 100644 --- a/src/Explorer/Controls/Settings/__snapshots__/SettingsComponent.test.tsx.snap +++ b/src/Explorer/Controls/Settings/__snapshots__/SettingsComponent.test.tsx.snap @@ -69,6 +69,27 @@ exports[`SettingsComponent renders 1`] = ` "partitionKeyProperties": [ "partitionKey", ], + "rawDataModel": { + "indexingPolicy": { + "automatic": true, + "compositeIndexes": [], + "excludedPaths": [], + "fullTextIndexes": [], + "includedPaths": [], + "indexingMode": "consistent", + "spatialIndexes": [], + "vectorIndexes": [], + }, + "uniqueKeyPolicy": { + "uniqueKeys": [ + { + "paths": [ + "/id", + ], + }, + ], + }, + }, "readSettings": [Function], "uniqueKeyPolicy": {}, "usageSizeInKB": [Function], @@ -148,6 +169,27 @@ exports[`SettingsComponent renders 1`] = ` "partitionKeyProperties": [ "partitionKey", ], + "rawDataModel": { + "indexingPolicy": { + "automatic": true, + "compositeIndexes": [], + "excludedPaths": [], + "fullTextIndexes": [], + "includedPaths": [], + "indexingMode": "consistent", + "spatialIndexes": [], + "vectorIndexes": [], + }, + "uniqueKeyPolicy": { + "uniqueKeys": [ + { + "paths": [ + "/id", + ], + }, + ], + }, + }, "readSettings": [Function], "uniqueKeyPolicy": {}, "usageSizeInKB": [Function], @@ -187,17 +229,25 @@ exports[`SettingsComponent renders 1`] = ` indexingPolicyContent={ { "automatic": true, + "compositeIndexes": [], "excludedPaths": [], + "fullTextIndexes": [], "includedPaths": [], "indexingMode": "consistent", + "spatialIndexes": [], + "vectorIndexes": [], } } indexingPolicyContentBaseline={ { "automatic": true, + "compositeIndexes": [], "excludedPaths": [], + "fullTextIndexes": [], "includedPaths": [], "indexingMode": "consistent", + "spatialIndexes": [], + "vectorIndexes": [], } } isVectorSearchEnabled={false} @@ -267,6 +317,27 @@ exports[`SettingsComponent renders 1`] = ` "partitionKeyProperties": [ "partitionKey", ], + "rawDataModel": { + "indexingPolicy": { + "automatic": true, + "compositeIndexes": [], + "excludedPaths": [], + "fullTextIndexes": [], + "includedPaths": [], + "indexingMode": "consistent", + "spatialIndexes": [], + "vectorIndexes": [], + }, + "uniqueKeyPolicy": { + "uniqueKeys": [ + { + "paths": [ + "/id", + ], + }, + ], + }, + }, "readSettings": [Function], "uniqueKeyPolicy": {}, "usageSizeInKB": [Function], @@ -336,6 +407,121 @@ exports[`SettingsComponent renders 1`] = ` shouldDiscardComputedProperties={false} /> + + + diff --git a/src/Explorer/Tabs/QueryTab/IndexAdvisorUtils.tsx b/src/Explorer/Tabs/QueryTab/IndexAdvisorUtils.tsx new file mode 100644 index 000000000..bb7c38018 --- /dev/null +++ b/src/Explorer/Tabs/QueryTab/IndexAdvisorUtils.tsx @@ -0,0 +1,114 @@ +import { CircleFilled } from "@fluentui/react-icons"; +import type { IIndexMetric } from "Explorer/Tabs/QueryTab/ResultsView"; +import { useIndexAdvisorStyles } from "Explorer/Tabs/QueryTab/StylesAdvisor"; +import * as React from "react"; +interface IndexObject { + index: string; + impact: string; + section: "Included" | "Not Included" | "Header"; + composite?: { + path: string; + order: "ascending" | "descending"; + }[]; + path?: string; +} + +export interface IndexMetricsJson { + included?: IIndexMetric[]; + notIncluded?: IIndexMetric[]; +} +export function parseIndexMetrics(indexMetrics: string | IndexMetricsJson): { + included: IIndexMetric[]; + notIncluded: IIndexMetric[]; +} { + // If already JSON, just extract arrays + if (typeof indexMetrics === "object" && indexMetrics !== null) { + return { + included: Array.isArray(indexMetrics.included) ? indexMetrics.included : [], + notIncluded: Array.isArray(indexMetrics.notIncluded) ? indexMetrics.notIncluded : [], + }; + } + + // Otherwise, parse as string (current SDK) + const included: IIndexMetric[] = []; + const notIncluded: IIndexMetric[] = []; + const lines = (indexMetrics as string) + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); + let currentSection = ""; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (line.startsWith("Utilized Single Indexes") || line.startsWith("Utilized Composite Indexes")) { + currentSection = "included"; + } else if (line.startsWith("Potential Single Indexes") || line.startsWith("Potential Composite Indexes")) { + currentSection = "notIncluded"; + } else if (line.startsWith("Index Spec:")) { + const index = line.replace("Index Spec:", "").trim(); + const impactLine = lines[i + 1]; + const impact = impactLine?.includes("Index Impact Score:") ? impactLine.split(":")[1].trim() : "Unknown"; + + const isComposite = index.includes(","); + + const sectionMap: Record = { + included: "Included", + notIncluded: "Not Included", + }; + + const indexObj: IndexObject = { index, impact, section: sectionMap[currentSection] ?? "Header" }; + if (isComposite) { + indexObj.composite = index.split(",").map((part: string) => { + const [path, order] = part.trim().split(/\s+/); + return { + path: path.trim(), + order: order?.toLowerCase() === "desc" ? "descending" : "ascending", + }; + }); + } else { + let path = "/unknown/*"; + const pathRegex = /\/[^/\s*?]+(?:\/[^/\s*?]+)*(\/\*|\?)/; + const match = index.match(pathRegex); + if (match) { + path = match[0]; + } else { + const simplePathRegex = /\/[^/\s]+/; + const simpleMatch = index.match(simplePathRegex); + if (simpleMatch) { + path = simpleMatch[0] + "/*"; + } + } + indexObj.path = path; + } + + if (currentSection === "included") { + included.push(indexObj); + } else if (currentSection === "notIncluded") { + notIncluded.push(indexObj); + } + } + } + return { included, notIncluded }; +} + +export const renderImpactDots = (impact: string): JSX.Element => { + const style = useIndexAdvisorStyles(); + let count = 0; + + if (impact === "High") { + count = 3; + } else if (impact === "Medium") { + count = 2; + } else if (impact === "Low") { + count = 1; + } + + return ( +
+ {Array.from({ length: count }).map((_, i) => ( + + ))} +
+ ); +}; + + diff --git a/src/Explorer/Tabs/QueryTab/QueryTabComponent.tsx b/src/Explorer/Tabs/QueryTab/QueryTabComponent.tsx index 640f04b48..4a9c4377e 100644 --- a/src/Explorer/Tabs/QueryTab/QueryTabComponent.tsx +++ b/src/Explorer/Tabs/QueryTab/QueryTabComponent.tsx @@ -27,6 +27,7 @@ import { monacoTheme } from "hooks/useTheme"; import React, { Fragment, createRef } from "react"; import "react-splitter-layout/lib/index.css"; import { format } from "react-string-format"; +import create from "zustand"; import QueryCommandIcon from "../../../../images/CopilotCommand.svg"; import LaunchCopilot from "../../../../images/CopilotTabIcon.svg"; import DownloadQueryIcon from "../../../../images/DownloadQuery.svg"; @@ -54,6 +55,21 @@ import { BrowseQueriesPane } from "../../Panes/BrowseQueriesPane/BrowseQueriesPa import { SaveQueryPane } from "../../Panes/SaveQueryPane/SaveQueryPane"; import TabsBase from "../TabsBase"; import "./QueryTabComponent.less"; + +export interface QueryMetadataStore { + userQuery: string; + databaseId: string; + containerId: string; + setMetadata: (query1: string, db: string, container: string) => void; +} + +export const useQueryMetadataStore = create((set) => ({ + userQuery: "", + databaseId: "", + containerId: "", + setMetadata: (query1, db, container) => set({ userQuery: query1, databaseId: db, containerId: container }), +})); + enum ToggleState { Result, QueryMetrics, @@ -258,6 +274,10 @@ class QueryTabComponentImpl extends React.Component => { + const query1 = this.state.sqlQueryEditorContent; + const db = this.props.collection.databaseId; + const container = this.props.collection.id(); + useQueryMetadataStore.getState().setMetadata(query1, db, container); this._iterator = undefined; setTimeout(async () => { diff --git a/src/Explorer/Tabs/QueryTab/ResultsView.test.tsx b/src/Explorer/Tabs/QueryTab/ResultsView.test.tsx new file mode 100644 index 000000000..700495dd4 --- /dev/null +++ b/src/Explorer/Tabs/QueryTab/ResultsView.test.tsx @@ -0,0 +1,202 @@ +import "@testing-library/jest-dom"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { IndexAdvisorTab } from "Explorer/Tabs/QueryTab/ResultsView"; +import React from "react"; + +const mockReplace = jest.fn(); +const mockFetchAll = jest.fn(); +const mockRead = jest.fn(); +const mockLogConsoleProgress = jest.fn(); +const mockHandleError = jest.fn(); + +const indexMetricsString = ` +Utilized Single Indexes +Index Spec: /foo/? +Index Impact Score: High +Potential Single Indexes +Index Spec: /bar/? +Index Impact Score: Medium +Utilized Composite Indexes +Index Spec: /baz/? DESC, /qux/? ASC +Index Impact Score: Low +`; +mockRead.mockResolvedValue({ + resource: { + indexingPolicy: { + automatic: true, + indexingMode: "consistent", + includedPaths: [{ path: "/*" }, { path: "/foo/?" }], + excludedPaths: [], + }, + partitionKey: "pk", + }, +}); +mockReplace.mockResolvedValue({ + resource: { + indexingPolicy: { + automatic: true, + indexingMode: "consistent", + includedPaths: [{ path: "/*" }], + excludedPaths: [], + }, + }, +}); + +jest.mock("./QueryTabComponent", () => ({ + useQueryMetadataStore: () => ({ + userQuery: "SELECT * FROM c", + databaseId: "db1", + containerId: "col1", + }), +})); +jest.mock("Common/CosmosClient", () => ({ + client: () => ({ + database: () => ({ + container: () => ({ + items: { + query: () => ({ + fetchAll: mockFetchAll.mockResolvedValueOnce({ indexMetrics: indexMetricsString }), + }), + }, + read: mockRead, + replace: mockReplace, + }), + }), + }), +})); +jest.mock("./StylesAdvisor", () => ({ + useIndexAdvisorStyles: () => ({}), +})); + +jest.mock("../../../Utils/NotificationConsoleUtils", () => ({ + logConsoleProgress: (...args: unknown[]) => { + mockLogConsoleProgress(...args); + return () => {}; + }, +})); + +jest.mock("../../../Common/ErrorHandlingUtils", () => { + return { + handleError: (...args: unknown[]) => mockHandleError(...args), + }; +}); + +test("logs progress message when fetching index metrics", async () => { + render(); + await waitFor(() => expect(mockLogConsoleProgress).toHaveBeenCalledWith(expect.stringContaining("IndexMetrics"))); +}); +test("renders both Included and Not Included sections after loading", async () => { + render(); + await waitFor(() => expect(screen.getByText("Included in Current Policy")).toBeInTheDocument()); + expect(screen.getByText("Not Included in Current Policy")).toBeInTheDocument(); + expect(screen.getByText("/foo/?")).toBeInTheDocument(); + expect(screen.getByText("/bar/?")).toBeInTheDocument(); +}); +test("shows update button only when an index is selected", async () => { + render(); + await waitFor(() => expect(screen.getByText("/bar/?")).toBeInTheDocument()); + const checkboxes = screen.getAllByRole("checkbox"); + expect(checkboxes.length).toBeGreaterThan(1); + fireEvent.click(checkboxes[1]); + expect(screen.getByText(/Update Indexing Policy/)).toBeInTheDocument(); + + fireEvent.click(checkboxes[1]); + expect(screen.queryByText(/Update Indexing Policy/)).not.toBeInTheDocument(); +}); +test("calls replace when update policy is confirmed", async () => { + render(); + await waitFor(() => expect(screen.getByText("/bar/?")).toBeInTheDocument()); + const checkboxes = screen.getAllByRole("checkbox"); + fireEvent.click(checkboxes[1]); + const updateButton = screen.getByText(/Update Indexing Policy/); + fireEvent.click(updateButton); + await waitFor(() => expect(mockReplace).toHaveBeenCalled()); +}); + +test("calls replace when update button is clicked", async () => { + render(); + await waitFor(() => expect(screen.getByText("/bar/?")).toBeInTheDocument()); + const checkboxes = screen.getAllByRole("checkbox"); + fireEvent.click(checkboxes[1]); // Select /bar/? + fireEvent.click(screen.getByText(/Update Indexing Policy/)); + await waitFor(() => expect(mockReplace).toHaveBeenCalled()); +}); + +test("fetches indexing policy via read", async () => { + render(); + await waitFor(() => { + expect(mockRead).toHaveBeenCalled(); + }); +}); + +test("selects all indexes when select-all is clicked", async () => { + render(); + await waitFor(() => expect(screen.getByText("/bar/?")).toBeInTheDocument()); + const checkboxes = screen.getAllByRole("checkbox"); + + fireEvent.click(checkboxes[0]); + checkboxes.forEach((cb) => { + expect(cb).toBeChecked(); + }); +}); + +test("shows spinner while loading and hides after fetchIndexMetrics resolves", async () => { + render(); + expect(screen.getByRole("progressbar")).toBeInTheDocument(); + await waitFor(() => expect(screen.queryByRole("progressbar")).not.toBeInTheDocument()); +}); + +test("calls fetchAll with correct query and options", async () => { + render(); + await waitFor(() => expect(mockFetchAll).toHaveBeenCalled()); +}); +test("renders IndexAdvisorTab when clicked from ResultsView", async () => { + render(); + await waitFor(() => expect(screen.getByText("Included in Current Policy")).toBeInTheDocument()); + expect(screen.getByText("/foo/?")).toBeInTheDocument(); +}); +test("renders index metrics from SDK response", async () => { + render(); + await waitFor(() => expect(screen.getByText("/foo/?")).toBeInTheDocument()); + expect(screen.getByText("/bar/?")).toBeInTheDocument(); + expect(screen.getByText("/baz/? DESC, /qux/? ASC")).toBeInTheDocument(); +}); + +test("calls handleError if fetchIndexMetrics throws", async () => { + mockFetchAll.mockRejectedValueOnce(new Error("fail")); + render(); + await waitFor(() => expect(mockHandleError).toHaveBeenCalled()); +}); + +test("calls handleError if fetchIndexMetrics throws2nd", async () => { + mockFetchAll.mockRejectedValueOnce(new Error("fail")); + + render(); + await waitFor(() => expect(mockHandleError).toHaveBeenCalled()); + expect(screen.queryByRole("status")).not.toBeInTheDocument(); +}); + +test("IndexingPolicyStore stores updated policy on componentDidMount", async () => { + render(); + await waitFor(() => expect(mockRead).toHaveBeenCalled()); + + const readResult = await mockRead.mock.results[0].value; + const policy = readResult.resource.indexingPolicy; + + expect(policy).toBeDefined(); + expect(policy.automatic).toBe(true); + expect(policy.indexingMode).toBe("consistent"); + expect(policy.includedPaths).toEqual(expect.arrayContaining([{ path: "/*" }, { path: "/foo/?" }])); +}); + +test("refreshCollectionData updates observable and re-renders", async () => { + render(); + await waitFor(() => expect(screen.getByText("/bar/?")).toBeInTheDocument()); + + const checkboxes = screen.getAllByRole("checkbox"); + fireEvent.click(checkboxes[1]); // Select /bar/? + fireEvent.click(screen.getByText(/Update Indexing Policy/)); + + await waitFor(() => expect(mockReplace).toHaveBeenCalled()); + expect(screen.getByText("/bar/?")).toBeInTheDocument(); +}); diff --git a/src/Explorer/Tabs/QueryTab/ResultsView.tsx b/src/Explorer/Tabs/QueryTab/ResultsView.tsx index 4bb02484c..074ba38e4 100644 --- a/src/Explorer/Tabs/QueryTab/ResultsView.tsx +++ b/src/Explorer/Tabs/QueryTab/ResultsView.tsx @@ -1,5 +1,8 @@ +import type { CompositePath, IndexingPolicy } from "@azure/cosmos"; +import { FontIcon } from "@fluentui/react"; import { Button, + Checkbox, DataGrid, DataGridBody, DataGridCell, @@ -8,28 +11,44 @@ import { DataGridRow, SelectTabData, SelectTabEvent, + Spinner, Tab, TabList, + Table, + TableBody, + TableCell, TableColumnDefinition, + TableHeader, + TableRow, createTableColumn, } from "@fluentui/react-components"; -import { ArrowDownloadRegular, CopyRegular } from "@fluentui/react-icons"; +import { + ArrowDownloadRegular, + ChevronDown20Regular, + ChevronRight20Regular, + CopyRegular +} from "@fluentui/react-icons"; +import copy from "clipboard-copy"; import { HttpHeaders } from "Common/Constants"; import MongoUtility from "Common/MongoUtility"; import { QueryMetrics } from "Contracts/DataModels"; import { EditorReact } from "Explorer/Controls/Editor/EditorReact"; -import { IDocument } from "Explorer/Tabs/QueryTab/QueryTabComponent"; +import { parseIndexMetrics, renderImpactDots } from "Explorer/Tabs/QueryTab/IndexAdvisorUtils"; +import { IDocument, useQueryMetadataStore } from "Explorer/Tabs/QueryTab/QueryTabComponent"; import { useQueryTabStyles } from "Explorer/Tabs/QueryTab/Styles"; +import React, { useCallback, useEffect, useState } from "react"; import { userContext } from "UserContext"; -import copy from "clipboard-copy"; -import React, { useCallback, useState } from "react"; +import { logConsoleProgress } from "Utils/NotificationConsoleUtils"; +import create from "zustand"; +import { client } from "../../../Common/CosmosClient"; +import { handleError } from "../../../Common/ErrorHandlingUtils"; import { ResultsViewProps } from "./QueryResultSection"; - +import { useIndexAdvisorStyles } from "./StylesAdvisor"; enum ResultsTabs { Results = "results", QueryStats = "queryStats", + IndexAdvisor = "indexadv", } - const ResultsTab: React.FC = ({ queryResults, isMongoDB, executeQueryDocumentsPage }) => { const styles = useQueryTabStyles(); const queryResultsString = queryResults @@ -355,6 +374,286 @@ const QueryStatsTab: React.FC> = ({ query ); }; +export interface IIndexMetric { + index: string; + impact: string; + section: "Included" | "Not Included" | "Header"; + path?: string; + composite?: { path: string; order: string }[]; +} +export const IndexAdvisorTab: React.FC = () => { + const style = useIndexAdvisorStyles(); + const { userQuery, databaseId, containerId } = useQueryMetadataStore(); + const [loading, setLoading] = useState(true); + const [indexMetrics, setIndexMetrics] = useState(null); + const [showIncluded, setShowIncluded] = useState(true); + const [showNotIncluded, setShowNotIncluded] = useState(true); + const [selectedIndexes, setSelectedIndexes] = useState([]); + const [selectAll, setSelectAll] = useState(false); + const [updateMessageShown, setUpdateMessageShown] = useState(false); + const [included, setIncludedIndexes] = useState([]); + const [notIncluded, setNotIncludedIndexes] = useState([]); + const [isUpdating, setIsUpdating] = useState(false); + const [justUpdatedPolicy, setJustUpdatedPolicy] = useState(false); + useEffect(() => { + const fetchIndexMetrics = async () => { + const clearMessage = logConsoleProgress(`Querying items with IndexMetrics in container ${containerId}`); + try { + const querySpec = { + query: userQuery, + }; + const sdkResponse = await client() + .database(databaseId) + .container(containerId) + .items.query(querySpec, { + populateIndexMetrics: true, + }) + .fetchAll(); + setIndexMetrics(sdkResponse.indexMetrics); + } catch (error) { + handleError(error, "queryItemsWithIndexMetrics", `Error querying items from ${containerId}`); + } finally { + clearMessage(); + setLoading(false); + } + }; + if (userQuery && databaseId && containerId) { + fetchIndexMetrics(); + } + }, [userQuery, databaseId, containerId]); + + useEffect(() => { + if (!indexMetrics) { + return; + } + + const { included, notIncluded } = parseIndexMetrics(indexMetrics); + setIncludedIndexes(included); + setNotIncludedIndexes(notIncluded); + if (justUpdatedPolicy) { + setJustUpdatedPolicy(false); + } else { + setUpdateMessageShown(false); + } + }, [indexMetrics]); + + useEffect(() => { + const allSelected = + notIncluded.length > 0 && notIncluded.every((item) => selectedIndexes.some((s) => s.index === item.index)); + setSelectAll(allSelected); + }, [selectedIndexes, notIncluded]); + + const handleCheckboxChange = (indexObj: IIndexMetric, checked: boolean) => { + if (checked) { + setSelectedIndexes((prev) => [...prev, indexObj]); + } else { + setSelectedIndexes((prev) => prev.filter((item) => item.index !== indexObj.index)); + } + }; + + const handleSelectAll = (checked: boolean) => { + setSelectAll(checked); + setSelectedIndexes(checked ? notIncluded : []); + }; + + const handleUpdatePolicy = async () => { + setIsUpdating(true); + try { + const containerRef = client().database(databaseId).container(containerId); + const { resource: containerDef } = await containerRef.read(); + + const newIncludedPaths = selectedIndexes + .filter((index) => !index.composite) + .map((index) => { + return { + path: index.path, + }; + }); + + const newCompositeIndexes: CompositePath[][] = selectedIndexes + .filter((index) => Array.isArray(index.composite)) + .map( + (index) => + (index.composite as { path: string; order: string }[]).map((comp) => ({ + path: comp.path, + order: comp.order === "descending" ? "descending" : "ascending", + })) as CompositePath[], + ); + + const updatedPolicy: IndexingPolicy = { + ...containerDef.indexingPolicy, + includedPaths: [...(containerDef.indexingPolicy?.includedPaths || []), ...newIncludedPaths], + compositeIndexes: [...(containerDef.indexingPolicy?.compositeIndexes || []), ...newCompositeIndexes], + automatic: containerDef.indexingPolicy?.automatic ?? true, + indexingMode: containerDef.indexingPolicy?.indexingMode ?? "consistent", + excludedPaths: containerDef.indexingPolicy?.excludedPaths ?? [], + }; + await containerRef.replace({ + id: containerId, + partitionKey: containerDef.partitionKey, + indexingPolicy: updatedPolicy, + }); + useIndexingPolicyStore.getState().setIndexingPolicyFor(containerId, updatedPolicy); + const selectedIndexSet = new Set(selectedIndexes.map((s) => s.index)); + const updatedNotIncluded: typeof notIncluded = []; + const newlyIncluded: typeof included = []; + for (const item of notIncluded) { + if (selectedIndexSet.has(item.index)) { + newlyIncluded.push(item); + } else { + updatedNotIncluded.push(item); + } + } + const newIncluded = [...included, ...newlyIncluded]; + const newNotIncluded = updatedNotIncluded; + setIncludedIndexes(newIncluded); + setNotIncludedIndexes(newNotIncluded); + setSelectedIndexes([]); + setSelectAll(false); + setUpdateMessageShown(true); + setJustUpdatedPolicy(true); + } catch (err) { + console.error("Failed to update indexing policy:", err); + } finally { + setIsUpdating(false); + } + }; + + const renderRow = (item: IIndexMetric, index: number) => { + const isHeader = item.section === "Header"; + const isNotIncluded = item.section === "Not Included"; + + return ( + + +
+ {isNotIncluded ? ( + selected.index === item.index)} + onChange={(_, data) => handleCheckboxChange(item, data.checked === true)} + /> + ) : isHeader && item.index === "Not Included in Current Policy" && notIncluded.length > 0 ? ( + handleSelectAll(data.checked === true)} /> + ) : ( +
+ )} + {isHeader ? ( + { + if (item.index === "Included in Current Policy") { + setShowIncluded(!showIncluded); + } else if (item.index === "Not Included in Current Policy") { + setShowNotIncluded(!showNotIncluded); + } + }} + > + {item.index === "Included in Current Policy" ? ( + showIncluded ? ( + + ) : ( + + ) + ) : showNotIncluded ? ( + + ) : ( + + )} + + ) : ( +
+ )} +
{item.index}
+
+ {!isHeader && item.impact} +
+
{!isHeader && renderImpactDots(item.impact)}
+
+
+
+ ); + }; + const indexMetricItems = React.useMemo(() => { + const items: IIndexMetric[] = []; + items.push({ index: "Not Included in Current Policy", impact: "", section: "Header" }); + if (showNotIncluded) { + notIncluded.forEach((item) => items.push({ ...item, section: "Not Included" })); + } + items.push({ index: "Included in Current Policy", impact: "", section: "Header" }); + if (showIncluded) { + included.forEach((item) => items.push({ ...item, section: "Included" })); + } + return items; + }, [included, notIncluded, showIncluded, showNotIncluded]); + + if (loading) { + return ( +
+ +
+ ); + } + + return ( +
+
+ {updateMessageShown ? ( + <> + + + + + Your indexing policy has been updated with the new included paths. You may review the changes in Scale & + Settings. + + + ) : ( + "Here is an analysis on the indexes utilized for executing the query. Based on the analysis, Cosmos DB recommends adding the selected indexes to your indexing policy to optimize the performance of this particular query." + )} +
+
Indexes analysis
+ + + + +
+
+
+
Index
+
+ Estimated Impact +
+
+
+
+
+ {indexMetricItems.map(renderRow)} +
+ {selectedIndexes.length > 0 && ( +
+ {isUpdating ? ( +
+ {" "} +
+ ) : ( + + )} +
+ )} +
+ ); +}; export const ResultsView: React.FC = ({ isMongoDB, queryResults, executeQueryDocumentsPage }) => { const styles = useQueryTabStyles(); const [activeTab, setActiveTab] = useState(ResultsTabs.Results); @@ -362,7 +661,6 @@ export const ResultsView: React.FC = ({ isMongoDB, queryResult const onTabSelect = useCallback((event: SelectTabEvent, data: SelectTabData) => { setActiveTab(data.value as ResultsTabs); }, []); - return (
@@ -380,6 +678,13 @@ export const ResultsView: React.FC = ({ isMongoDB, queryResult > Query Stats + + Index Advisor +
{activeTab === ResultsTabs.Results && ( @@ -390,7 +695,23 @@ export const ResultsView: React.FC = ({ isMongoDB, queryResult /> )} {activeTab === ResultsTabs.QueryStats && } + {activeTab === ResultsTabs.IndexAdvisor && }
); }; +export interface IndexingPolicyStore { + indexingPolicies: { [containerId: string]: IndexingPolicy }; + setIndexingPolicyFor: (containerId: string, indexingPolicy: IndexingPolicy) => void; +} + +export const useIndexingPolicyStore = create((set) => ({ + indexingPolicies: {}, + setIndexingPolicyFor: (containerId, indexingPolicy) => + set((state) => ({ + indexingPolicies: { + ...state.indexingPolicies, + [containerId]: { ...indexingPolicy }, + }, + })), +})); diff --git a/src/Explorer/Tabs/QueryTab/StylesAdvisor.ts b/src/Explorer/Tabs/QueryTab/StylesAdvisor.ts new file mode 100644 index 000000000..29f62b35a --- /dev/null +++ b/src/Explorer/Tabs/QueryTab/StylesAdvisor.ts @@ -0,0 +1,95 @@ +import { makeStyles } from "@fluentui/react-components"; +export type IndexAdvisorStyles = ReturnType; +export const useIndexAdvisorStyles = makeStyles({ + indexAdvisorMessage: { + padding: "1rem", + fontSize: "1.2rem", + display: "flex", + alignItems: "center", + gap: "0.5rem", + }, + indexAdvisorSuccessIcon: { + width: "18px", + height: "18px", + borderRadius: "50%", + backgroundColor: "#107C10", + display: "flex", + alignItems: "center", + justifyContent: "center", + }, + indexAdvisorTitle: { + padding: "1rem", + fontSize: "1.3rem", + fontWeight: "bold", + }, + indexAdvisorTable: { + display: "block", + alignItems: "center", + marginBottom: "7rem", + }, + indexAdvisorGrid: { + display: "grid", + gridTemplateColumns: "30px 30px 1fr 50px 120px", + alignItems: "center", + gap: "15px", + fontWeight: "bold", + }, + indexAdvisorCheckboxSpacer: { + width: "18px", + height: "18px", + }, + indexAdvisorChevronSpacer: { + width: "24px", + }, + indexAdvisorRowBold: { + fontWeight: "bold", + }, + indexAdvisorRowNormal: { + fontWeight: "normal", + }, + indexAdvisorRowImpactHeader: { + fontSize: 0, + }, + indexAdvisorRowImpact: { + fontWeight: "normal", + }, + indexAdvisorImpactDot: { + color: "#0078D4", + fontSize: "12px", + display: "inline-flex", + }, + indexAdvisorImpactDots: { + display: "flex", + alignItems: "center", + gap: "4px", + }, + indexAdvisorButtonBar: { + padding: "1rem", + marginTop: "-7rem", + flexWrap: "wrap", + }, + indexAdvisorButtonSpinner: { + marginTop: "1rem", + minWidth: "320px", + minHeight: "40px", + display: "flex", + alignItems: "left", + justifyContent: "left", + marginLeft: "10rem", + }, + indexAdvisorButton: { + backgroundColor: "#0078D4", + color: "white", + padding: "8px 16px", + border: "none", + borderRadius: "4px", + cursor: "pointer", + marginTop: "1rem", + fontSize: "1rem", + fontWeight: 500, + transition: "background 0.2s", + ":hover": { + backgroundColor: "#005a9e", + }, + }, +}); diff --git a/src/Explorer/Tabs/QueryTab/useQueryMetadataStore.ts b/src/Explorer/Tabs/QueryTab/useQueryMetadataStore.ts new file mode 100644 index 000000000..cccf3c7bb --- /dev/null +++ b/src/Explorer/Tabs/QueryTab/useQueryMetadataStore.ts @@ -0,0 +1,15 @@ +import create from "zustand"; + +interface QueryMetadataStore { + userQuery: string; + databaseId: string; + containerId: string; + setMetadata: (query1: string, db: string, container: string) => void; +} + +export const useQueryMetadataStore = create((set) => ({ + userQuery: "", + databaseId: "", + containerId: "", + setMetadata: (query1, db, container) => set({ userQuery: query1, databaseId: db, containerId: container }), +}));