diff --git a/src/Explorer/Controls/Settings/SettingsComponent.test.tsx b/src/Explorer/Controls/Settings/SettingsComponent.test.tsx index 825c3fb14..186ba7f3b 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 React from "react"; import { updateCollection } from "../../../Common/dataAccess/updateCollection"; @@ -287,3 +290,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 a458675d9..4d9531904 100644 --- a/src/Explorer/Controls/Settings/SettingsComponent.tsx +++ b/src/Explorer/Controls/Settings/SettingsComponent.tsx @@ -13,6 +13,7 @@ import { ThroughputBucketsComponent, ThroughputBucketsComponentProps, } from "Explorer/Controls/Settings/SettingsSubComponents/ThroughputInputComponents/ThroughputBucketsComponent"; +import { useIndexingPolicyStore } from "Explorer/Tabs/QueryTab/ResultsView"; import { useDatabases } from "Explorer/useDatabases"; import { isFabricNative } from "Platform/Fabric/FabricUtil"; import { isVectorSearchEnabled } from "Utils/CapabilityUtils"; @@ -72,7 +73,6 @@ import { parseConflictResolutionMode, parseConflictResolutionProcedure, } from "./SettingsUtils"; - interface SettingsV2TabInfo { tab: SettingsV2TabTypes; content: JSX.Element; @@ -175,7 +175,7 @@ export class SettingsComponent extends React.Component void; constructor(props: SettingsComponentProps) { super(props); @@ -305,8 +305,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()); @@ -788,7 +799,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 || @@ -1172,7 +1203,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 732bdf3a7..8b9c96735 100644 --- a/src/Explorer/Controls/Settings/__snapshots__/SettingsComponent.test.tsx.snap +++ b/src/Explorer/Controls/Settings/__snapshots__/SettingsComponent.test.tsx.snap @@ -72,6 +72,16 @@ exports[`SettingsComponent renders 1`] = ` "partitionKey", ], "rawDataModel": { + "indexingPolicy": { + "automatic": true, + "compositeIndexes": [], + "excludedPaths": [], + "fullTextIndexes": [], + "includedPaths": [], + "indexingMode": "consistent", + "spatialIndexes": [], + "vectorIndexes": [], + }, "uniqueKeyPolicy": { "uniqueKeys": [ { @@ -164,6 +174,16 @@ exports[`SettingsComponent renders 1`] = ` "partitionKey", ], "rawDataModel": { + "indexingPolicy": { + "automatic": true, + "compositeIndexes": [], + "excludedPaths": [], + "fullTextIndexes": [], + "includedPaths": [], + "indexingMode": "consistent", + "spatialIndexes": [], + "vectorIndexes": [], + }, "uniqueKeyPolicy": { "uniqueKeys": [ { @@ -238,17 +258,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} @@ -321,6 +349,16 @@ exports[`SettingsComponent renders 1`] = ` "partitionKey", ], "rawDataModel": { + "indexingPolicy": { + "automatic": true, + "compositeIndexes": [], + "excludedPaths": [], + "fullTextIndexes": [], + "includedPaths": [], + "indexingMode": "consistent", + "spatialIndexes": [], + "vectorIndexes": [], + }, "uniqueKeyPolicy": { "uniqueKeys": [ { @@ -461,6 +499,16 @@ exports[`SettingsComponent renders 1`] = ` "partitionKey", ], "rawDataModel": { + "indexingPolicy": { + "automatic": true, + "compositeIndexes": [], + "excludedPaths": [], + "fullTextIndexes": [], + "includedPaths": [], + "indexingMode": "consistent", + "spatialIndexes": [], + "vectorIndexes": [], + }, "uniqueKeyPolicy": { "uniqueKeys": [ { diff --git a/src/Explorer/Tabs/QueryTab/IndexAdvisorUtils.tsx b/src/Explorer/Tabs/QueryTab/IndexAdvisorUtils.tsx new file mode 100644 index 000000000..7e3dfafbc --- /dev/null +++ b/src/Explorer/Tabs/QueryTab/IndexAdvisorUtils.tsx @@ -0,0 +1,107 @@ +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"; + +// SDK response format +export interface IndexMetricsResponse { + UtilizedIndexes?: { + SingleIndexes?: Array<{ IndexSpec: string; IndexImpactScore?: string }>; + CompositeIndexes?: Array<{ IndexSpecs: string[]; IndexImpactScore?: string }>; + }; + PotentialIndexes?: { + SingleIndexes?: Array<{ IndexSpec: string; IndexImpactScore?: string }>; + CompositeIndexes?: Array<{ IndexSpecs: string[]; IndexImpactScore?: string }>; + }; +} + +export function parseIndexMetrics(indexMetrics: IndexMetricsResponse): { + included: IIndexMetric[]; + notIncluded: IIndexMetric[]; +} { + const included: IIndexMetric[] = []; + const notIncluded: IIndexMetric[] = []; + + // Process UtilizedIndexes (Included in Current Policy) + if (indexMetrics.UtilizedIndexes) { + // Single indexes + indexMetrics.UtilizedIndexes.SingleIndexes?.forEach((index) => { + included.push({ + index: index.IndexSpec, + impact: index.IndexImpactScore || "Utilized", + section: "Included", + path: index.IndexSpec, + }); + }); + + // Composite indexes + indexMetrics.UtilizedIndexes.CompositeIndexes?.forEach((index) => { + const compositeSpec = index.IndexSpecs.join(", "); + included.push({ + index: compositeSpec, + impact: index.IndexImpactScore || "Utilized", + section: "Included", + composite: index.IndexSpecs.map((spec) => { + const [path, order] = spec.trim().split(/\s+/); + return { + path: path.trim(), + order: order?.toLowerCase() === "desc" ? "descending" : "ascending", + }; + }), + }); + }); + } + + // Process PotentialIndexes (Not Included in Current Policy) + if (indexMetrics.PotentialIndexes) { + // Single indexes + indexMetrics.PotentialIndexes.SingleIndexes?.forEach((index) => { + notIncluded.push({ + index: index.IndexSpec, + impact: index.IndexImpactScore || "Unknown", + section: "Not Included", + path: index.IndexSpec, + }); + }); + + // Composite indexes + indexMetrics.PotentialIndexes.CompositeIndexes?.forEach((index) => { + const compositeSpec = index.IndexSpecs.join(", "); + notIncluded.push({ + index: compositeSpec, + impact: index.IndexImpactScore || "Unknown", + section: "Not Included", + composite: index.IndexSpecs.map((spec) => { + const [path, order] = spec.trim().split(/\s+/); + return { + path: path.trim(), + order: order?.toLowerCase() === "desc" ? "descending" : "ascending", + }; + }), + }); + }); + } + + 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/QueryResultSection.tsx b/src/Explorer/Tabs/QueryTab/QueryResultSection.tsx index 90a8a5672..9c9200aab 100644 --- a/src/Explorer/Tabs/QueryTab/QueryResultSection.tsx +++ b/src/Explorer/Tabs/QueryTab/QueryResultSection.tsx @@ -3,18 +3,21 @@ import QueryError from "Common/QueryError"; import { IndeterminateProgressBar } from "Explorer/Controls/IndeterminateProgressBar"; import { MessageBanner } from "Explorer/Controls/MessageBanner"; import { useQueryTabStyles } from "Explorer/Tabs/QueryTab/Styles"; +import useZoomLevel from "hooks/useZoomLevel"; import React from "react"; +import { conditionalClass } from "Utils/StyleUtils"; import RunQuery from "../../../../images/RunQuery.png"; import { QueryResults } from "../../../Contracts/ViewModels"; import { ErrorList } from "./ErrorList"; import { ResultsView } from "./ResultsView"; -import useZoomLevel from "hooks/useZoomLevel"; -import { conditionalClass } from "Utils/StyleUtils"; export interface ResultsViewProps { isMongoDB: boolean; queryResults: QueryResults; executeQueryDocumentsPage: (firstItemIndex: number) => Promise; + queryEditorContent?: string; + databaseId?: string; + containerId?: string; } interface QueryResultProps extends ResultsViewProps { @@ -49,6 +52,8 @@ export const QueryResultSection: React.FC = ({ queryResults, executeQueryDocumentsPage, isExecuting, + databaseId, + containerId, }: QueryResultProps): JSX.Element => { const styles = useQueryTabStyles(); const maybeSubQuery = queryEditorContent && /.*\(.*SELECT.*\)/i.test(queryEditorContent); @@ -91,6 +96,9 @@ export const QueryResultSection: React.FC = ({ queryResults={queryResults} executeQueryDocumentsPage={executeQueryDocumentsPage} isMongoDB={isMongoDB} + queryEditorContent={queryEditorContent} + databaseId={databaseId} + containerId={containerId} /> ) : ( diff --git a/src/Explorer/Tabs/QueryTab/QueryTabComponent.test.tsx b/src/Explorer/Tabs/QueryTab/QueryTabComponent.test.tsx index bc2e2f213..bf3ed538f 100644 --- a/src/Explorer/Tabs/QueryTab/QueryTabComponent.test.tsx +++ b/src/Explorer/Tabs/QueryTab/QueryTabComponent.test.tsx @@ -52,8 +52,9 @@ describe("QueryTabComponent", () => { copilotVersion: "v3.0", }, }); + const propsMock: Readonly = { - collection: { databaseId: "CopilotSampleDB" }, + collection: { databaseId: "CopilotSampleDB", id: () => "CopilotContainer" }, onTabAccessor: () => jest.fn(), isExecutionError: false, tabId: "mockTabId", diff --git a/src/Explorer/Tabs/QueryTab/QueryTabComponent.tsx b/src/Explorer/Tabs/QueryTab/QueryTabComponent.tsx index 06a0ddbf3..4dc60d916 100644 --- a/src/Explorer/Tabs/QueryTab/QueryTabComponent.tsx +++ b/src/Explorer/Tabs/QueryTab/QueryTabComponent.tsx @@ -27,6 +27,7 @@ import { TabsState, useTabs } from "hooks/useTabs"; import React, { Fragment, createRef } from "react"; import "react-splitter-layout/lib/index.css"; import { format } from "react-string-format"; +import create from "zustand"; //TODO: Uncomment next two lines when query copilot is reinstated in DE // import QueryCommandIcon from "../../../../images/CopilotCommand.svg"; // import LaunchCopilot from "../../../../images/CopilotTabIcon.svg"; @@ -56,6 +57,20 @@ 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, @@ -260,6 +275,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 () => { @@ -775,6 +794,8 @@ class QueryTabComponentImpl extends React.Component QueryDocumentsPerPage( firstItemIndex, @@ -790,6 +811,8 @@ class QueryTabComponentImpl extends React.Component this._executeQueryDocumentsPage(firstItemIndex) } diff --git a/src/Explorer/Tabs/QueryTab/ResultsView.test.tsx b/src/Explorer/Tabs/QueryTab/ResultsView.test.tsx new file mode 100644 index 000000000..31bdf939c --- /dev/null +++ b/src/Explorer/Tabs/QueryTab/ResultsView.test.tsx @@ -0,0 +1,170 @@ +import "@testing-library/jest-dom"; +import { 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 indexMetricsResponse = { + UtilizedIndexes: { + SingleIndexes: [{ IndexSpec: "/foo/?", IndexImpactScore: "High" }], + CompositeIndexes: [{ IndexSpecs: ["/baz/? DESC", "/qux/? ASC"], IndexImpactScore: "Low" }], + }, + PotentialIndexes: { + SingleIndexes: [{ IndexSpec: "/bar/?", IndexImpactScore: "Medium" }], + CompositeIndexes: [] as Array<{ IndexSpecs: string[]; IndexImpactScore?: string }>, + }, +}; + +const mockQueryResults = { + documents: [] as unknown[], + hasMoreResults: false, + itemCount: 0, + firstItemIndex: 0, + lastItemIndex: 0, + requestCharge: 0, + activityId: "test-activity-id", +}; + +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("Common/CosmosClient", () => ({ + client: () => ({ + database: () => ({ + container: () => ({ + items: { + query: () => ({ + fetchAll: mockFetchAll, + }), + }, + read: mockRead, + replace: mockReplace, + }), + }), + }), +})); + +jest.mock("./StylesAdvisor", () => ({ + useIndexAdvisorStyles: () => ({}), +})); + +jest.mock("../../../Utils/NotificationConsoleUtils", () => ({ + logConsoleProgress: (...args: unknown[]) => { + mockLogConsoleProgress(...args); + return () => {}; + }, +})); + +jest.mock("../../../Common/ErrorHandlingUtils", () => ({ + handleError: (...args: unknown[]) => mockHandleError(...args), +})); + +beforeEach(() => { + jest.clearAllMocks(); + mockFetchAll.mockResolvedValue({ indexMetrics: indexMetricsResponse }); +}); + +describe("IndexAdvisorTab Basic Tests", () => { + test("component renders without crashing", () => { + const { container } = render( + , + ); + expect(container).toBeTruthy(); + }); + + test("renders component and handles missing parameters", () => { + const { container } = render(); + expect(container).toBeTruthy(); + // Should not crash when parameters are missing + }); + + test("fetches index metrics with query results", async () => { + render( + , + ); + await waitFor(() => expect(mockFetchAll).toHaveBeenCalled()); + }); + + test("displays content after loading", async () => { + render( + , + ); + // Wait for the component to finish loading + await waitFor(() => expect(mockFetchAll).toHaveBeenCalled()); + // Component should have rendered some content + expect(screen.getByText(/Index Advisor/i)).toBeInTheDocument(); + }); + + test("calls log console progress when fetching metrics", async () => { + render( + , + ); + await waitFor(() => expect(mockLogConsoleProgress).toHaveBeenCalled()); + }); + + test("handles error when fetch fails", async () => { + mockFetchAll.mockRejectedValueOnce(new Error("fetch failed")); + render( + , + ); + await waitFor(() => expect(mockHandleError).toHaveBeenCalled(), { timeout: 3000 }); + }); + + test("renders with all required props", () => { + const { container } = render( + , + ); + expect(container).toBeTruthy(); + expect(container.firstChild).toBeTruthy(); + }); +}); diff --git a/src/Explorer/Tabs/QueryTab/ResultsView.tsx b/src/Explorer/Tabs/QueryTab/ResultsView.tsx index 64b987b69..fbc56e89f 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,45 @@ 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 { QueryResults } from "Contracts/ViewModels"; import { EditorReact } from "Explorer/Controls/Editor/EditorReact"; +import { + parseIndexMetrics, + renderImpactDots, + type IndexMetricsResponse, +} from "Explorer/Tabs/QueryTab/IndexAdvisorUtils"; import { IDocument } 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 { sampleDataClient } from "../../../Common/SampleDataClient"; 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(); /* eslint-disable react/prop-types */ @@ -523,14 +543,331 @@ const QueryStatsTab: React.FC> = ({ query ); }; -export const ResultsView: React.FC = ({ isMongoDB, queryResults, executeQueryDocumentsPage }) => { +export interface IIndexMetric { + index: string; + impact: string; + section: "Included" | "Not Included" | "Header"; + path?: string; + composite?: { path: string; order: string }[]; +} +export const IndexAdvisorTab: React.FC<{ + queryResults?: QueryResults; + queryEditorContent?: string; + databaseId?: string; + containerId?: string; +}> = ({ queryResults, queryEditorContent, databaseId, containerId }) => { + const style = useIndexAdvisorStyles(); + + const [loading, setLoading] = useState(false); + 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); + const indexingMetricsDocLink = "https://learn.microsoft.com/azure/cosmos-db/nosql/index-metrics"; + + const fetchIndexMetrics = async () => { + if (!queryEditorContent || !databaseId || !containerId) { + return; + } + + setLoading(true); + const clearMessage = logConsoleProgress(`Querying items with IndexMetrics in container ${containerId}`); + try { + const querySpec = { + query: queryEditorContent, + }; + + // Use sampleDataClient for CopilotSampleDB, regular client for other databases + const cosmosClient = databaseId === "CopilotSampleDB" ? sampleDataClient() : client(); + + const sdkResponse = await cosmosClient + .database(databaseId) + .container(containerId) + .items.query(querySpec, { + populateIndexMetrics: true, + }) + .fetchAll(); + + const parsedMetrics = + typeof sdkResponse.indexMetrics === "string" ? JSON.parse(sdkResponse.indexMetrics) : sdkResponse.indexMetrics; + + setIndexMetrics(parsedMetrics); + } catch (error) { + handleError(error, "queryItemsWithIndexMetrics", `Error querying items from ${containerId}`); + } finally { + clearMessage(); + setLoading(false); + } + }; + + // Fetch index metrics when query results change (i.e., when Execute Query is clicked) + useEffect(() => { + if (queryEditorContent && databaseId && containerId && queryResults) { + fetchIndexMetrics(); + } + }, [queryResults]); + + 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. + + + ) : ( + <> + + Index Advisor uses Indexing Metrics to suggest query paths that, when included in your indexing policy, + can improve the performance of this query by reducing RU costs and lowering latency.{" "} + + Learn more about Indexing Metrics + + .{" "} + + + )} +
+
Indexes analysis
+ + + + +
+
+
+
Index
+
+ Estimated Impact +
+
+
+
+
+ {indexMetricItems.map(renderRow)} +
+ {selectedIndexes.length > 0 && ( +
+ {isUpdating ? ( +
+ {" "} +
+ ) : ( + + )} +
+ )} +
+ ); +}; +export const ResultsView: React.FC = ({ + isMongoDB, + queryResults, + executeQueryDocumentsPage, + queryEditorContent, + databaseId, + containerId, +}) => { const styles = useQueryTabStyles(); const [activeTab, setActiveTab] = useState(ResultsTabs.Results); const onTabSelect = useCallback((event: SelectTabEvent, data: SelectTabData) => { setActiveTab(data.value as ResultsTabs); }, []); - return (
@@ -548,6 +885,13 @@ export const ResultsView: React.FC = ({ isMongoDB, queryResult > Query Stats + + Index Advisor +
{activeTab === ResultsTabs.Results && ( @@ -558,7 +902,30 @@ 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 }), +}));