mirror of
https://github.com/Azure/cosmos-explorer.git
synced 2025-12-23 10:51:30 +00:00
Compare commits
2 Commits
refresh-ar
...
index-arch
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a96d679087 | ||
|
|
dc8bb69359 |
5
.vscode/settings.json
vendored
5
.vscode/settings.json
vendored
@@ -24,5 +24,8 @@
|
||||
"source.organizeImports": "explicit"
|
||||
},
|
||||
"typescript.preferences.importModuleSpecifier": "non-relative",
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"[typescriptreact]": {
|
||||
"editor.defaultFormatter": "vscode.typescript-language-features"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,6 +55,8 @@ import { BrowseQueriesPane } from "../../Panes/BrowseQueriesPane/BrowseQueriesPa
|
||||
import { SaveQueryPane } from "../../Panes/SaveQueryPane/SaveQueryPane";
|
||||
import TabsBase from "../TabsBase";
|
||||
import "./QueryTabComponent.less";
|
||||
import { useQueryMetadataStore } from "./useQueryMetadataStore"; // adjust path if needed
|
||||
|
||||
|
||||
enum ToggleState {
|
||||
Result,
|
||||
@@ -196,7 +198,10 @@ class QueryTabComponentImpl extends React.Component<QueryTabComponentImplProps,
|
||||
enabled: !!this.state.sqlQueryEditorContent && this.state.sqlQueryEditorContent.length > 0,
|
||||
visible: true,
|
||||
};
|
||||
|
||||
// const query=this.state.sqlQueryEditorContent;
|
||||
// const db = this.props.collection.databaseId;
|
||||
// const container = this.props.collection.id();
|
||||
|
||||
const isSaveQueryBtnEnabled = userContext.apiType === "SQL" || userContext.apiType === "Gremlin";
|
||||
this.saveQueryButton = {
|
||||
enabled: isSaveQueryBtnEnabled,
|
||||
@@ -260,6 +265,10 @@ class QueryTabComponentImpl extends React.Component<QueryTabComponentImplProps,
|
||||
}
|
||||
|
||||
public onExecuteQueryClick = async (): Promise<void> => {
|
||||
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 () => {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { FontIcon } from "@fluentui/react";
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
DataGrid,
|
||||
DataGridBody,
|
||||
DataGridCell,
|
||||
@@ -8,26 +10,36 @@ import {
|
||||
DataGridRow,
|
||||
SelectTabData,
|
||||
SelectTabEvent,
|
||||
Spinner,
|
||||
Tab,
|
||||
TabList,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableColumnDefinition,
|
||||
createTableColumn,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
createTableColumn
|
||||
} from "@fluentui/react-components";
|
||||
import { ArrowDownloadRegular, CopyRegular } from "@fluentui/react-icons";
|
||||
import { ArrowDownloadRegular, ChevronDown20Regular, ChevronRight20Regular, CircleFilled, 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 { useQueryTabStyles } from "Explorer/Tabs/QueryTab/Styles";
|
||||
import { useQueryMetadataStore } from "Explorer/Tabs/QueryTab/useQueryMetadataStore";
|
||||
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 { client } from "../../../Common/CosmosClient";
|
||||
import { handleError } from "../../../Common/ErrorHandlingUtils";
|
||||
import { ResultsViewProps } from "./QueryResultSection";
|
||||
|
||||
enum ResultsTabs {
|
||||
Results = "results",
|
||||
QueryStats = "queryStats",
|
||||
IndexAdvisor = "indexadv",
|
||||
}
|
||||
|
||||
const ResultsTab: React.FC<ResultsViewProps> = ({ queryResults, isMongoDB, executeQueryDocumentsPage }) => {
|
||||
@@ -380,9 +392,8 @@ const QueryStatsTab: React.FC<Pick<ResultsViewProps, "queryResults">> = ({ query
|
||||
},
|
||||
{
|
||||
metric: "User defined function execution time",
|
||||
value: `${
|
||||
aggregatedQueryMetrics.runtimeExecutionTimes?.userDefinedFunctionExecutionTime?.toString() || 0
|
||||
} ms`,
|
||||
value: `${aggregatedQueryMetrics.runtimeExecutionTimes?.userDefinedFunctionExecutionTime?.toString() || 0
|
||||
} ms`,
|
||||
toolTip: "Total time spent executing user-defined functions",
|
||||
},
|
||||
{
|
||||
@@ -523,6 +534,394 @@ const QueryStatsTab: React.FC<Pick<ResultsViewProps, "queryResults">> = ({ query
|
||||
);
|
||||
};
|
||||
|
||||
interface IIndexMetric {
|
||||
index: string;
|
||||
impact: string;
|
||||
section: "Included" | "Not Included" | "Header";
|
||||
}
|
||||
const IndexAdvisorTab: React.FC = () => {
|
||||
const { userQuery, databaseId, containerId } = useQueryMetadataStore();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [indexMetrics, setIndexMetrics] = useState<any>(null);
|
||||
const [showIncluded, setShowIncluded] = useState(true);
|
||||
const [showNotIncluded, setShowNotIncluded] = useState(true);
|
||||
const [selectedIndexes, setSelectedIndexes] = useState<any[]>([]);
|
||||
const [selectAll, setSelectAll] = useState(false);
|
||||
const [updateMessageShown, setUpdateMessageShown] = useState(false);
|
||||
const [included, setIncludedIndexes] = useState<IIndexMetric[]>([]);
|
||||
const [notIncluded, setNotIncludedIndexes] = useState<IIndexMetric[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchIndexMetrics() {
|
||||
const clearMessage = logConsoleProgress(`Querying items with IndexMetrics in container ${containerId}`);
|
||||
try {
|
||||
const querySpec = {
|
||||
query: userQuery || "SELECT TOP 10 c.id FROM c WHERE c.Item = 'value1234' ",
|
||||
};
|
||||
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: any[] = [];
|
||||
const notIncluded: any[] = [];
|
||||
const lines = indexMetrics.split("\n").map((line: string) => 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 indexObj: any = { index, impact };
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
setIncludedIndexes(included);
|
||||
setNotIncludedIndexes(notIncluded);
|
||||
}, [indexMetrics]);
|
||||
|
||||
useEffect(() => {
|
||||
const allSelected = notIncluded.length > 0 && notIncluded.every((item) => selectedIndexes.some((s) => s.index === item.index));
|
||||
setSelectAll(allSelected);
|
||||
}, [selectedIndexes, notIncluded]);
|
||||
|
||||
const handleCheckboxChange = (indexObj: any, 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 () => {
|
||||
if (selectedIndexes.length === 0) {
|
||||
console.log("No indexes selected for update");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { resource: containerDef } = await client()
|
||||
.database(databaseId)
|
||||
.container(containerId)
|
||||
.read();
|
||||
|
||||
const newIncludedPaths = selectedIndexes
|
||||
.filter(index => !index.composite)
|
||||
.map(index => {
|
||||
|
||||
return {
|
||||
path: index.path,
|
||||
};
|
||||
});
|
||||
|
||||
const newCompositeIndexes = selectedIndexes
|
||||
.filter(index => index.composite)
|
||||
.map(index => index.composite);
|
||||
|
||||
const updatedPolicy = {
|
||||
...containerDef.indexingPolicy,
|
||||
includedPaths: [
|
||||
...(containerDef.indexingPolicy?.includedPaths || []),
|
||||
...newIncludedPaths,
|
||||
],
|
||||
compositeIndexes: [
|
||||
...(containerDef.indexingPolicy?.compositeIndexes || []),
|
||||
...newCompositeIndexes,
|
||||
],
|
||||
};
|
||||
|
||||
await client()
|
||||
.database(databaseId)
|
||||
.container(containerId)
|
||||
.replace({
|
||||
id: containerId,
|
||||
partitionKey: containerDef.partitionKey,
|
||||
indexingPolicy: updatedPolicy,
|
||||
});
|
||||
|
||||
const newIncluded = [...included, ...notIncluded.filter(item =>
|
||||
selectedIndexes.find(s => s.index === item.index)
|
||||
)];
|
||||
const newNotIncluded = notIncluded.filter(item =>
|
||||
!selectedIndexes.find(s => s.index === item.index)
|
||||
);
|
||||
|
||||
setSelectedIndexes([]);
|
||||
setSelectAll(false);
|
||||
setIndexMetricsFromParsed(newIncluded, newNotIncluded);
|
||||
setUpdateMessageShown(true);
|
||||
} catch (err) {
|
||||
console.error("Failed to update indexing policy:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const setIndexMetricsFromParsed = (included: { index: string; impact: string }[], notIncluded: { index: string; impact: string }[]) => {
|
||||
const serialize = (sectionTitle: string, items: { index: string; impact: string }[], isUtilized: boolean) =>
|
||||
items.length
|
||||
? `${sectionTitle}\n` +
|
||||
items
|
||||
.map((item) => `Index Spec: ${item.index}\nIndex Impact Score: ${item.impact}`)
|
||||
.join("\n") + "\n"
|
||||
: "";
|
||||
const composedMetrics =
|
||||
serialize("Utilized Single Indexes", included, true) +
|
||||
serialize("Potential Single Indexes", notIncluded, false);
|
||||
|
||||
setIndexMetrics(composedMetrics.trim());
|
||||
};
|
||||
|
||||
const renderImpactDots = (impact: string) => {
|
||||
let count = 0;
|
||||
if (impact === "High") count = 3;
|
||||
else if (impact === "Medium") count = 2;
|
||||
else if (impact === "Low") count = 1;
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "4px" }}>
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<CircleFilled
|
||||
key={i}
|
||||
style={{
|
||||
color: "#0078D4",
|
||||
fontSize: "12px",
|
||||
display: "inline-flex",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderRow = (item: IIndexMetric, index: number) => {
|
||||
const isHeader = item.section === "Header";
|
||||
const isNotIncluded = item.section === "Not Included";
|
||||
const isIncluded = item.section === "Included";
|
||||
|
||||
return (
|
||||
<TableRow key={index}>
|
||||
<TableCell colSpan={2}>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "30px 30px 1fr 50px 120px",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
}}>
|
||||
{isNotIncluded ? (
|
||||
<Checkbox
|
||||
checked={selectedIndexes.some((selected) => selected.index === item.index)}
|
||||
onChange={(_, data) => handleCheckboxChange(item, data.checked === true)}
|
||||
/>
|
||||
) : isHeader && item.index === "Not Included in Current Policy" && notIncluded.length > 0 ? (
|
||||
<Checkbox
|
||||
checked={selectAll}
|
||||
onChange={(_, data) => handleSelectAll(data.checked === true)}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ width: "18px", height: "18px" }}></div>
|
||||
)}
|
||||
{isHeader ? (
|
||||
<span
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => {
|
||||
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
|
||||
? <ChevronDown20Regular />
|
||||
: <ChevronRight20Regular />
|
||||
: showNotIncluded
|
||||
? <ChevronDown20Regular />
|
||||
: <ChevronRight20Regular />
|
||||
}
|
||||
</span>
|
||||
) : (
|
||||
<div style={{ width: "24px" }}></div>
|
||||
)}
|
||||
<div style={{ fontWeight: isHeader ? "bold" : "normal" }}>
|
||||
{item.index}
|
||||
</div>
|
||||
<div style={{ fontSize: isHeader ? 0 : undefined }}>
|
||||
{isHeader ? null : item.impact}
|
||||
</div>
|
||||
<div>
|
||||
{isHeader ? null : renderImpactDots(item.impact)}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
};
|
||||
const generateIndexMetricItems = (
|
||||
|
||||
included: { index: string; impact: string }[],
|
||||
notIncluded: { index: string; impact: string }[]
|
||||
): IIndexMetric[] => {
|
||||
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;
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div>
|
||||
<Spinner
|
||||
size="small"
|
||||
style={{
|
||||
'--spinner-size': '16px',
|
||||
'--spinner-thickness': '2px',
|
||||
'--spinner-color': '#0078D4',
|
||||
} as React.CSSProperties} />
|
||||
</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ padding: "1rem", fontSize: "1.2rem", display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
||||
{updateMessageShown ? (
|
||||
<>
|
||||
<span
|
||||
style={{
|
||||
width: 18,
|
||||
height: 18,
|
||||
borderRadius: "50%",
|
||||
backgroundColor: "#107C10",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}>
|
||||
<FontIcon iconName="CheckMark" style={{ color: "white", fontSize: 12 }} />
|
||||
</span>
|
||||
<span>
|
||||
Your indexing policy has been updated with the new included paths. You may review the changes in Scale & Settings.
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
"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."
|
||||
)}
|
||||
</div>
|
||||
<div style={{ padding: "1rem", fontSize: "1.3rem", fontWeight: "bold" }}>Indexes analysis</div>
|
||||
<Table style={{ display: "block", alignItems: "center", marginBottom: "7rem" }}>
|
||||
<TableHeader>
|
||||
<TableRow >
|
||||
<TableCell colSpan={2}>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "30px 30px 1fr 50px 120px",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
fontWeight: "bold",
|
||||
}}
|
||||
>
|
||||
<div style={{ width: "18px", height: "18px" }}></div>
|
||||
<div style={{ width: "24px" }}></div>
|
||||
<div>Index</div>
|
||||
<div><span style={{ whiteSpace: "nowrap" }}>Estimated Impact</span></div>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{generateIndexMetricItems(included, notIncluded).map(renderRow)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{selectedIndexes.length > 0 && (
|
||||
<div style={{ padding: "1rem", marginTop: "-7rem", flexWrap: "wrap" }}>
|
||||
<button
|
||||
onClick={handleUpdatePolicy}
|
||||
style={{
|
||||
backgroundColor: "#0078D4",
|
||||
color: "white",
|
||||
padding: "8px 16px",
|
||||
border: "none",
|
||||
borderRadius: "4px",
|
||||
cursor: "pointer",
|
||||
marginTop: "1rem",
|
||||
}}
|
||||
>
|
||||
Update Indexing Policy with selected index(es)
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export const ResultsView: React.FC<ResultsViewProps> = ({ isMongoDB, queryResults, executeQueryDocumentsPage }) => {
|
||||
const styles = useQueryTabStyles();
|
||||
const [activeTab, setActiveTab] = useState<ResultsTabs>(ResultsTabs.Results);
|
||||
@@ -548,6 +947,13 @@ export const ResultsView: React.FC<ResultsViewProps> = ({ isMongoDB, queryResult
|
||||
>
|
||||
Query Stats
|
||||
</Tab>
|
||||
<Tab
|
||||
data-test="QueryTab/ResultsPane/ResultsView/IndexAdvisorTab"
|
||||
id={ResultsTabs.IndexAdvisor}
|
||||
value={ResultsTabs.IndexAdvisor}
|
||||
>
|
||||
Index Advisor
|
||||
</Tab>
|
||||
</TabList>
|
||||
<div className={styles.queryResultsTabContentContainer}>
|
||||
{activeTab === ResultsTabs.Results && (
|
||||
@@ -558,6 +964,7 @@ export const ResultsView: React.FC<ResultsViewProps> = ({ isMongoDB, queryResult
|
||||
/>
|
||||
)}
|
||||
{activeTab === ResultsTabs.QueryStats && <QueryStatsTab queryResults={queryResults} />}
|
||||
{activeTab === ResultsTabs.IndexAdvisor && <IndexAdvisorTab />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
16
src/Explorer/Tabs/QueryTab/useQueryMetadataStore.ts
Normal file
16
src/Explorer/Tabs/QueryTab/useQueryMetadataStore.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import create from "zustand";
|
||||
|
||||
interface QueryMetadataStore {
|
||||
userQuery: string;
|
||||
databaseId: string;
|
||||
containerId: string;
|
||||
setMetadata: (query1: string, db: string, container: string) => void;
|
||||
}
|
||||
|
||||
export const useQueryMetadataStore = create<QueryMetadataStore>((set) => ({
|
||||
userQuery: "",
|
||||
databaseId: "",
|
||||
containerId: "",
|
||||
setMetadata: (query1, db, container) =>
|
||||
set({ userQuery: query1, databaseId: db, containerId: container }),
|
||||
}));
|
||||
Reference in New Issue
Block a user