mirror of
https://github.com/Azure/cosmos-explorer.git
synced 2026-01-01 15:22:08 +00:00
Compare commits
25 Commits
users/rbac
...
users/lang
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88e47403d8 | ||
|
|
582d581edf | ||
|
|
f65e7ab1d6 | ||
|
|
bf1474e101 | ||
|
|
4fda518a3f | ||
|
|
9aa55aa4e5 | ||
|
|
96b2ef728a | ||
|
|
5871c1e2d0 | ||
|
|
81dccbe5be | ||
|
|
49c3d0f0cb | ||
|
|
375bb5f567 | ||
|
|
e9f83a8efd | ||
|
|
093ddba2db | ||
|
|
dfe79b20f5 | ||
|
|
1021e9c969 | ||
|
|
c30a9681fe | ||
|
|
17754cba05 | ||
|
|
b07fa89a23 | ||
|
|
28db549fa1 | ||
|
|
fe892dcc62 | ||
|
|
380caba5f5 | ||
|
|
62ab0e3e60 | ||
|
|
d199311633 | ||
|
|
bf225f91c4 | ||
|
|
4d0b1a6db8 |
@@ -133,6 +133,7 @@ export enum MongoBackendEndpointType {
|
||||
export class BackendApi {
|
||||
public static readonly GenerateToken: string = "GenerateToken";
|
||||
public static readonly PortalSettings: string = "PortalSettings";
|
||||
public static readonly AccountRestrictions: string = "AccountRestrictions";
|
||||
}
|
||||
|
||||
export class PortalBackendEndpoints {
|
||||
|
||||
@@ -3,16 +3,15 @@ import { getAuthorizationTokenUsingResourceTokens } from "Common/getAuthorizatio
|
||||
import { AuthorizationToken } from "Contracts/FabricMessageTypes";
|
||||
import { checkDatabaseResourceTokensValidity } from "Platform/Fabric/FabricUtil";
|
||||
import { LocalStorageUtility, StorageKey } from "Shared/StorageUtility";
|
||||
import { listKeys } from "Utils/arm/generatedClients/cosmos/databaseAccounts";
|
||||
import { DatabaseAccountListKeysResult } from "Utils/arm/generatedClients/cosmos/types";
|
||||
import { AuthType } from "../AuthType";
|
||||
import { PriorityLevel } from "../Common/Constants";
|
||||
import { Platform, configContext } from "../ConfigContext";
|
||||
import { updateUserContext, userContext } from "../UserContext";
|
||||
import { userContext } from "../UserContext";
|
||||
import { logConsoleError } from "../Utils/NotificationConsoleUtils";
|
||||
import * as PriorityBasedExecutionUtils from "../Utils/PriorityBasedExecutionUtils";
|
||||
import { EmulatorMasterKey, HttpHeaders } from "./Constants";
|
||||
import { getErrorMessage } from "./ErrorHandlingUtils";
|
||||
import * as Logger from "../Common/Logger";
|
||||
|
||||
const _global = typeof self === "undefined" ? window : self;
|
||||
|
||||
@@ -23,6 +22,10 @@ export const tokenProvider = async (requestInfo: Cosmos.RequestInfo) => {
|
||||
userContext.features.enableAadDataPlane && userContext.databaseAccount.properties.disableLocalAuth;
|
||||
const dataPlaneRBACOptionEnabled = userContext.dataPlaneRbacEnabled && userContext.apiType === "SQL";
|
||||
if (aadDataPlaneFeatureEnabled || (!userContext.features.enableAadDataPlane && dataPlaneRBACOptionEnabled)) {
|
||||
Logger.logInfo(
|
||||
`AAD Data Plane Feature flag set to ${userContext.features.enableAadDataPlane} for account with disable local auth ${userContext.databaseAccount.properties.disableLocalAuth} `,
|
||||
"Explorer/tokenProvider",
|
||||
);
|
||||
if (!userContext.aadToken) {
|
||||
logConsoleError(
|
||||
`AAD token does not exist. Please use "Login for Entra ID" prior to performing Entra ID RBAC operations`,
|
||||
@@ -82,6 +85,7 @@ export const tokenProvider = async (requestInfo: Cosmos.RequestInfo) => {
|
||||
}
|
||||
|
||||
if (userContext.masterKey) {
|
||||
Logger.logInfo(`Master Key exists`, "Explorer/tokenProvider");
|
||||
// TODO This SDK method mutates the headers object. Find a better one or fix the SDK.
|
||||
await Cosmos.setAuthorizationTokenHeaderUsingMasterKey(
|
||||
verb,
|
||||
@@ -91,22 +95,6 @@ export const tokenProvider = async (requestInfo: Cosmos.RequestInfo) => {
|
||||
userContext.masterKey,
|
||||
);
|
||||
return decodeURIComponent(headers.authorization);
|
||||
} else if (userContext.dataPlaneRbacEnabled == false) {
|
||||
const { databaseAccount: account, subscriptionId, resourceGroup } = userContext;
|
||||
const keys: DatabaseAccountListKeysResult = await listKeys(subscriptionId, resourceGroup, account.name);
|
||||
|
||||
if (keys.primaryMasterKey) {
|
||||
updateUserContext({ masterKey: keys.primaryMasterKey });
|
||||
// TODO This SDK method mutates the headers object. Find a better one or fix the SDK.
|
||||
await Cosmos.setAuthorizationTokenHeaderUsingMasterKey(
|
||||
verb,
|
||||
resourceId,
|
||||
resourceType,
|
||||
headers,
|
||||
keys.primaryMasterKey,
|
||||
);
|
||||
return decodeURIComponent(headers.authorization);
|
||||
}
|
||||
}
|
||||
|
||||
if (userContext.resourceToken) {
|
||||
@@ -170,8 +158,11 @@ enum SDKSupportedCapabilities {
|
||||
let _client: Cosmos.CosmosClient;
|
||||
|
||||
export function client(): Cosmos.CosmosClient {
|
||||
if (_client) return _client;
|
||||
|
||||
if (_client) {
|
||||
if (!userContext.hasDataPlaneRbacSettingChanged) {
|
||||
return _client;
|
||||
}
|
||||
}
|
||||
let _defaultHeaders: Cosmos.CosmosHeaders = {};
|
||||
_defaultHeaders["x-ms-cosmos-sdk-supportedcapabilities"] =
|
||||
SDKSupportedCapabilities.None | SDKSupportedCapabilities.PartitionMerge;
|
||||
@@ -190,6 +181,7 @@ export function client(): Cosmos.CosmosClient {
|
||||
|
||||
const options: Cosmos.CosmosClientOptions = {
|
||||
endpoint: endpoint() || "https://cosmos.azure.com", // CosmosClient gets upset if we pass a bad URL. This should never actually get called
|
||||
key: userContext.dataPlaneRbacEnabled ? "" : userContext.masterKey,
|
||||
tokenProvider,
|
||||
userAgentSuffix: "Azure Portal",
|
||||
defaultHeaders: _defaultHeaders,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { FabricMessageTypes } from "Contracts/FabricMessageTypes";
|
||||
import Q from "q";
|
||||
import * as _ from "underscore";
|
||||
import * as Logger from "../Common/Logger";
|
||||
import { MessageTypes } from "../Contracts/ExplorerContracts";
|
||||
import { getDataExplorerWindow } from "../Utils/WindowUtils";
|
||||
import * as Constants from "./Constants";
|
||||
@@ -96,10 +97,18 @@ const _sendMessage = (message: any): void => {
|
||||
const portalChildWindow = getDataExplorerWindow(window) || window;
|
||||
if (portalChildWindow === window) {
|
||||
// Current window is a child of portal, send message to portal window
|
||||
portalChildWindow.parent.postMessage(message, portalChildWindow.document.referrer || "*");
|
||||
if (portalChildWindow.document.referrer) {
|
||||
portalChildWindow.parent.postMessage(message, portalChildWindow.document.referrer);
|
||||
} else {
|
||||
Logger.logError("Iframe failed to send message to portal", "MessageHandler");
|
||||
}
|
||||
} else {
|
||||
// Current window is not a child of portal, send message to the child window instead (which is data explorer)
|
||||
portalChildWindow.postMessage(message, portalChildWindow.location.origin || "*");
|
||||
if (portalChildWindow.location.origin) {
|
||||
portalChildWindow.postMessage(message, portalChildWindow.location.origin);
|
||||
} else {
|
||||
Logger.logError("Iframe failed to send message to data explorer", "MessageHandler");
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,7 +2,6 @@ import { CosmosClient } from "@azure/cosmos";
|
||||
import { sampleDataClient } from "Common/SampleDataClient";
|
||||
import { userContext } from "UserContext";
|
||||
import * as DataModels from "../../Contracts/DataModels";
|
||||
import { logConsoleProgress } from "../../Utils/NotificationConsoleUtils";
|
||||
import { client } from "../CosmosClient";
|
||||
import { handleError } from "../ErrorHandlingUtils";
|
||||
|
||||
@@ -31,7 +30,6 @@ export async function readCollectionInternal(
|
||||
collectionId: string,
|
||||
): Promise<DataModels.Collection> {
|
||||
let collection: DataModels.Collection;
|
||||
const clearMessage = logConsoleProgress(`Querying container ${collectionId}`);
|
||||
try {
|
||||
const response = await cosmosClient.database(databaseId).container(collectionId).read();
|
||||
collection = response.resource as DataModels.Collection;
|
||||
@@ -39,6 +37,5 @@ export async function readCollectionInternal(
|
||||
handleError(error, "ReadCollection", `Error while querying container ${collectionId}`);
|
||||
throw error;
|
||||
}
|
||||
clearMessage();
|
||||
return collection;
|
||||
}
|
||||
|
||||
@@ -109,14 +109,14 @@ let configContext: Readonly<ConfigContext> = {
|
||||
PORTAL_BACKEND_ENDPOINT: PortalBackendEndpoints.Prod,
|
||||
MONGO_PROXY_ENDPOINT: MongoProxyEndpoints.Prod,
|
||||
NEW_MONGO_APIS: [
|
||||
"resourcelist",
|
||||
"queryDocuments",
|
||||
"createDocument",
|
||||
"readDocument",
|
||||
"updateDocument",
|
||||
"deleteDocument",
|
||||
"createCollectionWithProxy",
|
||||
"legacyMongoShell",
|
||||
// "resourcelist",
|
||||
// "queryDocuments",
|
||||
// "createDocument",
|
||||
// "readDocument",
|
||||
// "updateDocument",
|
||||
// "deleteDocument",
|
||||
// "createCollectionWithProxy",
|
||||
// "legacyMongoShell",
|
||||
],
|
||||
MONGO_PROXY_OUTBOUND_IPS_ALLOWLISTED: false,
|
||||
CASSANDRA_PROXY_ENDPOINT: CassandraProxyEndpoints.Prod,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
export interface QueryRequestOptions {
|
||||
$skipToken?: string;
|
||||
$top?: number;
|
||||
subscriptions: string[];
|
||||
$allowPartialScopes: boolean;
|
||||
subscriptions?: string[];
|
||||
}
|
||||
|
||||
export interface QueryResponse {
|
||||
|
||||
@@ -162,7 +162,7 @@ export const addRootChildToGraph = (
|
||||
* @param value
|
||||
*/
|
||||
export const escapeDoubleQuotes = (value: string): string => {
|
||||
return value === undefined ? value : value.replace(/"/g, '\\"');
|
||||
return value === undefined ? value : value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -186,5 +186,5 @@ export const getQuotedPropValue = (ip: ViewModels.InputPropertyValue): string =>
|
||||
* @param value
|
||||
*/
|
||||
export const escapeSingleQuotes = (value: string): string => {
|
||||
return value === undefined ? value : value.replace(/'/g, "\\'");
|
||||
return value === undefined ? value : value.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
||||
};
|
||||
|
||||
@@ -576,9 +576,7 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
|
||||
</TooltipHost>
|
||||
</Stack>
|
||||
|
||||
<Text variant="small" aria-label="pkDescription">
|
||||
{this.getPartitionKeySubtext()}
|
||||
</Text>
|
||||
<Text variant="small">{this.getPartitionKeySubtext()}</Text>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
|
||||
@@ -264,7 +264,7 @@ export const ChangePartitionKeyPane: React.FC<ChangePartitionKeyPaneProps> = ({
|
||||
</TooltipHost>
|
||||
</Stack>
|
||||
|
||||
<Text variant="small" aria-label="pkDescription">
|
||||
<Text variant="small">
|
||||
{getPartitionKeySubtext(userContext.features.partitionKeyDefault, userContext.apiType)}
|
||||
</Text>
|
||||
|
||||
|
||||
@@ -36,6 +36,8 @@ import Explorer from "../../Explorer";
|
||||
import { RightPaneForm, RightPaneFormProps } from "../RightPaneForm/RightPaneForm";
|
||||
import { AuthType } from "AuthType";
|
||||
import create, { UseStore } from "zustand";
|
||||
import { DatabaseAccountListKeysResult } from "@azure/arm-cosmosdb/esm/models";
|
||||
import { listKeys } from "Utils/arm/generatedClients/cosmos/databaseAccounts";
|
||||
|
||||
export interface DataPlaneRbacState {
|
||||
dataPlaneRbacEnabled: boolean;
|
||||
@@ -160,13 +162,24 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
|
||||
) {
|
||||
updateUserContext({
|
||||
dataPlaneRbacEnabled: true,
|
||||
hasDataPlaneRbacSettingChanged: true,
|
||||
});
|
||||
useDataPlaneRbac.setState({ dataPlaneRbacEnabled: true });
|
||||
} else {
|
||||
updateUserContext({
|
||||
dataPlaneRbacEnabled: false,
|
||||
hasDataPlaneRbacSettingChanged: true,
|
||||
});
|
||||
useDataPlaneRbac.setState({ dataPlaneRbacEnabled: false });
|
||||
const { databaseAccount: account, subscriptionId, resourceGroup } = userContext;
|
||||
if (!userContext.features.enableAadDataPlane) {
|
||||
const keys: DatabaseAccountListKeysResult = await listKeys(subscriptionId, resourceGroup, account.name);
|
||||
|
||||
if (keys.primaryMasterKey) {
|
||||
updateUserContext({ masterKey: keys.primaryMasterKey });
|
||||
|
||||
useDataPlaneRbac.setState({ dataPlaneRbacEnabled: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LocalStorageUtility.setEntryBoolean(StorageKey.RUThresholdEnabled, ruThresholdEnabled);
|
||||
|
||||
@@ -223,7 +223,6 @@ exports[`AddCollectionPanel should render Default properly 1`] = `
|
||||
</StyledTooltipHostBase>
|
||||
</Stack>
|
||||
<Text
|
||||
aria-label="pkDescription"
|
||||
variant="small"
|
||||
/>
|
||||
<input
|
||||
|
||||
@@ -363,18 +363,24 @@ export class CassandraAPIDataClient extends TableDataClient {
|
||||
entitiesToDelete: Entities.ITableEntity[],
|
||||
): Promise<any> {
|
||||
const query = `DELETE FROM ${collection.databaseId}.${collection.id()} WHERE `;
|
||||
const partitionKeyProperty = this.getCassandraPartitionKeyProperty(collection);
|
||||
|
||||
const partitionKeys: CassandraTableKey[] = collection.cassandraKeys.partitionKeys;
|
||||
await Promise.all(
|
||||
entitiesToDelete.map(async (currEntityToDelete: Entities.ITableEntity) => {
|
||||
const clearMessage = NotificationConsoleUtils.logConsoleProgress(`Deleting row ${currEntityToDelete.RowKey._}`);
|
||||
const partitionKeyValue = currEntityToDelete[partitionKeyProperty];
|
||||
const currQuery =
|
||||
query +
|
||||
(this.isStringType(partitionKeyValue.$)
|
||||
? `${partitionKeyProperty} = '${partitionKeyValue._}'`
|
||||
: `${partitionKeyProperty} = ${partitionKeyValue._}`);
|
||||
|
||||
let currQuery = query;
|
||||
for (let partitionKeyIndex = 0; partitionKeyIndex < partitionKeys.length; partitionKeyIndex++) {
|
||||
const partitionKey: CassandraTableKey = partitionKeys[partitionKeyIndex];
|
||||
const partitionKeyValue: Entities.ITableEntityAttribute = currEntityToDelete[partitionKey.property];
|
||||
currQuery =
|
||||
currQuery +
|
||||
(this.isStringType(partitionKeyValue.$)
|
||||
? `${partitionKey.property} = '${partitionKeyValue._}'`
|
||||
: `${partitionKey.property} = ${partitionKeyValue._}`);
|
||||
if (partitionKeyIndex < partitionKeys.length - 1) {
|
||||
currQuery = `${currQuery} AND `;
|
||||
}
|
||||
}
|
||||
try {
|
||||
await this.queryDocuments(collection, currQuery);
|
||||
NotificationConsoleUtils.logConsoleInfo(`Successfully deleted row ${currEntityToDelete.RowKey._}`);
|
||||
|
||||
68
src/Explorer/Tabs/DocumentsTabV2/DocumentsTabStateUtil.ts
Normal file
68
src/Explorer/Tabs/DocumentsTabV2/DocumentsTabStateUtil.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
// Definitions of State data
|
||||
|
||||
import { loadState, saveState, saveStateDebounced } from "Shared/AppStatePersistenceUtility";
|
||||
import { userContext } from "UserContext";
|
||||
import * as ViewModels from "../../../Contracts/ViewModels";
|
||||
|
||||
// Component states
|
||||
export interface DocumentsTabStateData {
|
||||
leftPaneWidthPercent: number;
|
||||
}
|
||||
|
||||
const defaultState: DocumentsTabStateData = {
|
||||
leftPaneWidthPercent: 35,
|
||||
};
|
||||
|
||||
const ComponentName = "DocumentsTab";
|
||||
|
||||
export const readDocumentsTabState = (): DocumentsTabStateData => {
|
||||
const state = loadState({ componentName: ComponentName });
|
||||
return (state as DocumentsTabStateData) || defaultState;
|
||||
};
|
||||
|
||||
export const saveDocumentsTabState = (state: DocumentsTabStateData): void => {
|
||||
saveStateDebounced({ componentName: ComponentName }, state);
|
||||
};
|
||||
|
||||
export type ColumnSizesMap = { [columnId: string]: WidthDefinition };
|
||||
export type WidthDefinition = { idealWidth?: number; minWidth?: number };
|
||||
|
||||
export const readSubComponentState = <T>(
|
||||
subComponentName: "ColumnSizes" | "FilterHistory",
|
||||
collection: ViewModels.CollectionBase,
|
||||
defaultValue: T,
|
||||
): T => {
|
||||
const globalAccountName = userContext.databaseAccount?.name;
|
||||
// TODO what if databaseAccount doesn't exist?
|
||||
|
||||
const state = loadState({
|
||||
componentName: ComponentName,
|
||||
subComponentName,
|
||||
globalAccountName,
|
||||
databaseName: collection.databaseId,
|
||||
containerName: collection.id(),
|
||||
}) as T;
|
||||
|
||||
return state || defaultValue;
|
||||
};
|
||||
|
||||
export const saveSubComponentState = <T>(
|
||||
subComponentName: "ColumnSizes" | "FilterHistory",
|
||||
collection: ViewModels.CollectionBase,
|
||||
state: T,
|
||||
debounce?: boolean,
|
||||
): void => {
|
||||
const globalAccountName = userContext.databaseAccount?.name;
|
||||
// TODO what if databaseAccount doesn't exist?
|
||||
|
||||
(debounce ? saveStateDebounced : saveState)(
|
||||
{
|
||||
componentName: ComponentName,
|
||||
subComponentName,
|
||||
globalAccountName,
|
||||
databaseName: collection.databaseId,
|
||||
containerName: collection.id(),
|
||||
},
|
||||
state,
|
||||
);
|
||||
};
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
SAVE_BUTTON_ID,
|
||||
UPDATE_BUTTON_ID,
|
||||
UPLOAD_BUTTON_ID,
|
||||
addStringsNoDuplicate,
|
||||
buildQuery,
|
||||
getDiscardExistingDocumentChangesButtonState,
|
||||
getDiscardNewDocumentChangesButtonState,
|
||||
@@ -339,8 +340,11 @@ describe("Documents tab (noSql API)", () => {
|
||||
const createMockProps = (): IDocumentsTabComponentProps => ({
|
||||
isPreferredApiMongoDB: false,
|
||||
documentIds: [],
|
||||
collection: undefined,
|
||||
partitionKey: undefined,
|
||||
collection: {
|
||||
id: ko.observable<string>("collectionId"),
|
||||
databaseId: "databaseId",
|
||||
} as ViewModels.CollectionBase,
|
||||
partitionKey: { kind: "Hash", paths: ["/foo"], version: 2 },
|
||||
onLoadStartKey: 0,
|
||||
tabTitle: "",
|
||||
onExecutionErrorChange: (isExecutionError: boolean): void => {
|
||||
@@ -474,3 +478,13 @@ describe("Documents tab (noSql API)", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Documents tab", () => {
|
||||
it("should add strings to array without duplicate", () => {
|
||||
const array1 = ["a", "b", "c"];
|
||||
const array2 = ["b", "c", "d"];
|
||||
|
||||
const array3 = addStringsNoDuplicate(array1, array2);
|
||||
expect(array3).toEqual(["a", "b", "c", "d"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
import { Item, ItemDefinition, PartitionKey, PartitionKeyDefinition, QueryIterator, Resource } from "@azure/cosmos";
|
||||
import { Button, FluentProvider, Input, TableRowId } from "@fluentui/react-components";
|
||||
import {
|
||||
Button,
|
||||
FluentProvider,
|
||||
Input,
|
||||
Menu,
|
||||
MenuItem,
|
||||
MenuList,
|
||||
MenuPopover,
|
||||
MenuProps,
|
||||
PositioningImperativeRef,
|
||||
TableRowId,
|
||||
useRestoreFocusTarget,
|
||||
} from "@fluentui/react-components";
|
||||
import { ArrowClockwise16Filled, Dismiss16Filled } from "@fluentui/react-icons";
|
||||
import Split from "@uiw/react-split";
|
||||
import { KeyCodes, QueryCopilotSampleContainerId, QueryCopilotSampleDatabaseId } from "Common/Constants";
|
||||
@@ -7,7 +19,10 @@ import { getErrorMessage, getErrorStack } from "Common/ErrorHandlingUtils";
|
||||
import MongoUtility from "Common/MongoUtility";
|
||||
import { StyleConstants } from "Common/StyleConstants";
|
||||
import { createDocument } from "Common/dataAccess/createDocument";
|
||||
import { deleteDocuments as deleteNoSqlDocuments } from "Common/dataAccess/deleteDocument";
|
||||
import {
|
||||
deleteDocument as deleteNoSqlDocument,
|
||||
deleteDocuments as deleteNoSqlDocuments,
|
||||
} from "Common/dataAccess/deleteDocument";
|
||||
import { queryDocuments } from "Common/dataAccess/queryDocuments";
|
||||
import { readDocument } from "Common/dataAccess/readDocument";
|
||||
import { updateDocument } from "Common/dataAccess/updateDocument";
|
||||
@@ -18,6 +33,12 @@ import { EditorReact } from "Explorer/Controls/Editor/EditorReact";
|
||||
import Explorer from "Explorer/Explorer";
|
||||
import { useCommandBar } from "Explorer/Menus/CommandBar/CommandBarComponentAdapter";
|
||||
import { querySampleDocuments, readSampleDocument } from "Explorer/QueryCopilot/QueryCopilotUtilities";
|
||||
import {
|
||||
DocumentsTabStateData,
|
||||
readDocumentsTabState,
|
||||
readSubComponentState,
|
||||
saveSubComponentState,
|
||||
} from "Explorer/Tabs/DocumentsTabV2/DocumentsTabStateUtil";
|
||||
import { getPlatformTheme } from "Explorer/Theme/ThemeUtil";
|
||||
import { useSelectedNode } from "Explorer/useSelectedNode";
|
||||
import { KeyboardAction, KeyboardActionGroup, useKeyboardActionGroup } from "KeyboardShortcuts";
|
||||
@@ -417,6 +438,24 @@ export const buildQuery = (
|
||||
return QueryUtils.buildDocumentsQuery(filter, partitionKeyProperties, partitionKey);
|
||||
};
|
||||
|
||||
/**
|
||||
* Export to expose to unit tests
|
||||
*
|
||||
* Add array2 to array1 without duplicates
|
||||
* @param array1
|
||||
* @param array2
|
||||
* @return array1 with array2 added without duplicates
|
||||
*/
|
||||
export const addStringsNoDuplicate = (array1: string[], array2: string[]): string[] => {
|
||||
const result = [...array1];
|
||||
array2.forEach((item) => {
|
||||
if (!result.includes(item)) {
|
||||
result.push(item);
|
||||
}
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
// Export to expose to unit tests
|
||||
export interface IDocumentsTabComponentProps {
|
||||
isPreferredApiMongoDB: boolean;
|
||||
@@ -431,6 +470,11 @@ export interface IDocumentsTabComponentProps {
|
||||
isTabActive: boolean;
|
||||
}
|
||||
|
||||
const getUniqueId = (collection: ViewModels.CollectionBase): string => `${collection.databaseId}-${collection.id()}`;
|
||||
|
||||
const defaultSqlFilters = ['WHERE c.id = "foo"', "ORDER BY c._ts DESC", 'WHERE c.id = "foo" ORDER BY c._ts DESC'];
|
||||
const defaultMongoFilters = ['{"id":"foo"}', "{ qty: { $gte: 20 } }"];
|
||||
|
||||
// Export to expose to unit tests
|
||||
export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabComponentProps> = ({
|
||||
isPreferredApiMongoDB,
|
||||
@@ -477,6 +521,9 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
||||
ViewModels.DocumentExplorerState.noDocumentSelected,
|
||||
);
|
||||
|
||||
// State
|
||||
const [tabStateData, setTabStateData] = useState<DocumentsTabStateData>(() => readDocumentsTabState());
|
||||
|
||||
const isQueryCopilotSampleContainer =
|
||||
_collection?.isSampleCollection &&
|
||||
_collection?.databaseId === QueryCopilotSampleDatabaseId &&
|
||||
@@ -485,6 +532,11 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
||||
// For Mongo only
|
||||
const [continuationToken, setContinuationToken] = useState<string>(undefined);
|
||||
|
||||
// User's filter history
|
||||
const [lastFilterContents, setLastFilterContents] = useState<string[]>(() =>
|
||||
readSubComponentState("FilterHistory", _collection, []),
|
||||
);
|
||||
|
||||
const setKeyboardActions = useKeyboardActionGroup(KeyboardActionGroup.ACTIVE_TAB);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -510,8 +562,6 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
||||
}
|
||||
}, [documentIds, clickedRowIndex, editorState]);
|
||||
|
||||
let lastFilterContents = ['WHERE c.id = "foo"', "ORDER BY c._ts DESC", 'WHERE c.id = "foo" ORDER BY c._ts DESC'];
|
||||
|
||||
const applyFilterButton = {
|
||||
enabled: true,
|
||||
visible: true,
|
||||
@@ -824,7 +874,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
||||
}, [initialDocumentContent, selectedDocumentContentBaseline, setSelectedDocumentContent]);
|
||||
|
||||
/**
|
||||
* Implementation using bulk delete
|
||||
* Implementation using bulk delete NoSQL API
|
||||
*/
|
||||
let _deleteDocuments = useCallback(
|
||||
async (toDeleteDocumentIds: DocumentId[]): Promise<DocumentId[]> => {
|
||||
@@ -834,7 +884,14 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
||||
tabTitle,
|
||||
});
|
||||
setIsExecuting(true);
|
||||
return deleteNoSqlDocuments(_collection, toDeleteDocumentIds)
|
||||
|
||||
// TODO: Once JS SDK Bug fix for bulk deleting legacy containers (whose systemKey==1) is released:
|
||||
// Remove the check for systemKey, remove call to deleteNoSqlDocument(). deleteNoSqlDocuments() should always be called.
|
||||
return (
|
||||
partitionKey.systemKey
|
||||
? deleteNoSqlDocument(_collection, toDeleteDocumentIds[0]).then(() => [toDeleteDocumentIds[0]])
|
||||
: deleteNoSqlDocuments(_collection, toDeleteDocumentIds)
|
||||
)
|
||||
.then(
|
||||
(deletedIds) => {
|
||||
TelemetryProcessor.traceSuccess(
|
||||
@@ -1293,6 +1350,26 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
||||
return () => resizeObserver.disconnect(); // clean up
|
||||
}, []);
|
||||
|
||||
const [filterMenuOpen, setFilterMenuOpen] = React.useState(false);
|
||||
const onFilterMenuOpenChange: MenuProps["onOpenChange"] = (e, data) => {
|
||||
// do not close menu as an outside click if clicking on the custom trigger/target
|
||||
// this prevents it from closing & immediately re-opening when clicking custom triggers
|
||||
if (data.type === "clickOutside" && (e.target === filterInput.current || e.target === inputRef.current)) {
|
||||
return;
|
||||
}
|
||||
setFilterMenuOpen(data.open);
|
||||
};
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
const positioningRef = React.useRef<PositioningImperativeRef>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (filterInput.current) {
|
||||
positioningRef.current?.setTarget(filterInput.current);
|
||||
}
|
||||
}, [filterInput, positioningRef]);
|
||||
|
||||
const restoreFocusTargetAttribute = useRestoreFocusTarget();
|
||||
|
||||
const columnHeaders = {
|
||||
idHeader: isPreferredApiMongoDB ? "_id" : "id",
|
||||
partitionKeyHeaders: (showPartitionKey(_collection, isPreferredApiMongoDB) && partitionKeyPropertyHeaders) || [],
|
||||
@@ -1359,7 +1436,6 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
||||
return partitionKey;
|
||||
};
|
||||
|
||||
lastFilterContents = ['{"id":"foo"}', "{ qty: { $gte: 20 } }"];
|
||||
partitionKeyProperties = partitionKeyProperties?.map((partitionKeyProperty, i) => {
|
||||
if (partitionKeyProperty && ~partitionKeyProperty.indexOf(`"`)) {
|
||||
partitionKeyProperty = partitionKeyProperty.replace(/["]+/g, "");
|
||||
@@ -1636,6 +1712,20 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
||||
}
|
||||
// ***************** Mongo ***************************
|
||||
|
||||
const onApplyFilterClick = (): void => {
|
||||
refreshDocumentsGrid(true);
|
||||
|
||||
// Remove duplicates, but keep order
|
||||
if (lastFilterContents.includes(filterContent)) {
|
||||
lastFilterContents.splice(lastFilterContents.indexOf(filterContent), 1);
|
||||
}
|
||||
|
||||
// Save filter content to local storage
|
||||
lastFilterContents.unshift(filterContent);
|
||||
setLastFilterContents([...lastFilterContents]);
|
||||
saveSubComponentState("FilterHistory", _collection, lastFilterContents);
|
||||
};
|
||||
|
||||
const refreshDocumentsGrid = useCallback(
|
||||
(applyFilterButtonPressed: boolean): void => {
|
||||
// clear documents grid
|
||||
@@ -1698,8 +1788,10 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
||||
<Input
|
||||
id="filterInput"
|
||||
ref={filterInput}
|
||||
{...restoreFocusTargetAttribute}
|
||||
autocomplete="off"
|
||||
type="text"
|
||||
list="filtersList"
|
||||
list={`filtersList-${getUniqueId(_collection)}`}
|
||||
className={`${filterContent.length === 0 ? "placeholderVisible" : ""}`}
|
||||
style={{ width: "100%" }}
|
||||
title="Type a query predicate or choose one from the list."
|
||||
@@ -1711,21 +1803,38 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
||||
value={filterContent}
|
||||
autoFocus={true}
|
||||
onKeyDown={onFilterKeyDown}
|
||||
onChange={(e) => setFilterContent(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setFilterContent(e.target.value);
|
||||
setFilterMenuOpen(true);
|
||||
}}
|
||||
onBlur={() => setIsFilterFocused(false)}
|
||||
/>
|
||||
|
||||
<datalist id="filtersList">
|
||||
{lastFilterContents.map((filter) => (
|
||||
<Menu open={filterMenuOpen} onOpenChange={onFilterMenuOpenChange} positioning={{ positioningRef }}>
|
||||
<MenuPopover>
|
||||
<MenuList>
|
||||
{addStringsNoDuplicate(
|
||||
lastFilterContents,
|
||||
isPreferredApiMongoDB ? defaultMongoFilters : defaultSqlFilters,
|
||||
).map((filter) => (
|
||||
<MenuItem key={filter}>{filter}</MenuItem>
|
||||
))}
|
||||
</MenuList>
|
||||
</MenuPopover>
|
||||
</Menu>
|
||||
{/* <datalist id={`filtersList-${getUniqueId(_collection)}`}>
|
||||
{addStringsNoDuplicate(
|
||||
lastFilterContents,
|
||||
isPreferredApiMongoDB ? defaultMongoFilters : defaultSqlFilters,
|
||||
).map((filter) => (
|
||||
<option key={filter} value={filter} />
|
||||
))}
|
||||
</datalist>
|
||||
</datalist> */}
|
||||
|
||||
<span className="filterbuttonpad">
|
||||
<Button
|
||||
appearance="primary"
|
||||
style={filterButtonStyle}
|
||||
onClick={() => refreshDocumentsGrid(true)}
|
||||
onClick={onApplyFilterClick}
|
||||
disabled={!applyFilterButton.enabled}
|
||||
aria-label="Apply filter"
|
||||
tabIndex={0}
|
||||
@@ -1762,7 +1871,13 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
||||
)}
|
||||
{/* <Split> doesn't like to be a flex child */}
|
||||
<div style={{ overflow: "hidden", height: "100%" }}>
|
||||
<Split>
|
||||
<Split
|
||||
onDragEnd={(preSize: number) => {
|
||||
tabStateData.leftPaneWidthPercent = Math.min(100, Math.max(0, Math.round(100 * preSize) / 100));
|
||||
// saveDocumentsTabState(tabStateData); // Disable saving split position for now
|
||||
setTabStateData({ ...tabStateData });
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{ minWidth: 120, width: "35%", overflow: "hidden", position: "relative" }}
|
||||
ref={tableContainerRef}
|
||||
@@ -1800,8 +1915,10 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
||||
size={tableContainerSizePx}
|
||||
columnHeaders={columnHeaders}
|
||||
isSelectionDisabled={
|
||||
configContext.platform === Platform.Fabric && userContext.fabricContext?.isReadOnly
|
||||
(partitionKey.systemKey && !isPreferredApiMongoDB) ||
|
||||
(configContext.platform === Platform.Fabric && userContext.fabricContext?.isReadOnly)
|
||||
}
|
||||
collection={_collection}
|
||||
/>
|
||||
{tableItems.length > 0 && (
|
||||
<a
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { TableRowId } from "@fluentui/react-components";
|
||||
import { mount } from "enzyme";
|
||||
import React from "react";
|
||||
import * as ViewModels from "../../../Contracts/ViewModels";
|
||||
import { DocumentsTableComponent, IDocumentsTableComponentProps } from "./DocumentsTableComponent";
|
||||
|
||||
const PARTITION_KEY_HEADER = "partitionKey";
|
||||
@@ -25,6 +26,10 @@ describe("DocumentsTableComponent", () => {
|
||||
partitionKeyHeaders: [PARTITION_KEY_HEADER],
|
||||
},
|
||||
isSelectionDisabled: false,
|
||||
collection: {
|
||||
databaseId: "db",
|
||||
id: ((): string => "coll") as ko.Observable<string>,
|
||||
} as ViewModels.CollectionBase,
|
||||
});
|
||||
|
||||
it("should render documents and partition keys in header", () => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
createTableColumn,
|
||||
Menu,
|
||||
MenuItem,
|
||||
MenuList,
|
||||
@@ -16,17 +17,23 @@ import {
|
||||
TableRow,
|
||||
TableRowId,
|
||||
TableSelectionCell,
|
||||
createTableColumn,
|
||||
useArrowNavigationGroup,
|
||||
useTableColumnSizing_unstable,
|
||||
useTableFeatures,
|
||||
useTableSelection,
|
||||
} from "@fluentui/react-components";
|
||||
import { NormalizedEventKey } from "Common/Constants";
|
||||
import {
|
||||
ColumnSizesMap,
|
||||
readSubComponentState,
|
||||
saveSubComponentState,
|
||||
WidthDefinition,
|
||||
} from "Explorer/Tabs/DocumentsTabV2/DocumentsTabStateUtil";
|
||||
import { selectionHelper } from "Explorer/Tabs/DocumentsTabV2/SelectionHelper";
|
||||
import { isEnvironmentCtrlPressed, isEnvironmentShiftPressed } from "Utils/KeyboardUtils";
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import { FixedSizeList as List, ListChildComponentProps } from "react-window";
|
||||
import * as ViewModels from "../../../Contracts/ViewModels";
|
||||
|
||||
export type DocumentsTableComponentItem = {
|
||||
id: string;
|
||||
@@ -45,6 +52,7 @@ export interface IDocumentsTableComponentProps {
|
||||
columnHeaders: ColumnHeaders;
|
||||
style?: React.CSSProperties;
|
||||
isSelectionDisabled?: boolean;
|
||||
collection: ViewModels.CollectionBase;
|
||||
}
|
||||
|
||||
interface TableRowData extends RowStateBase<DocumentsTableComponentItem> {
|
||||
@@ -57,6 +65,11 @@ interface ReactWindowRenderFnProps extends ListChildComponentProps {
|
||||
data: TableRowData[];
|
||||
}
|
||||
|
||||
const defaultSize: WidthDefinition = {
|
||||
idealWidth: 200,
|
||||
minWidth: 50,
|
||||
};
|
||||
|
||||
export const DocumentsTableComponent: React.FC<IDocumentsTableComponentProps> = ({
|
||||
items,
|
||||
onSelectedRowsChange,
|
||||
@@ -65,30 +78,32 @@ export const DocumentsTableComponent: React.FC<IDocumentsTableComponentProps> =
|
||||
size,
|
||||
columnHeaders,
|
||||
isSelectionDisabled,
|
||||
collection,
|
||||
}: IDocumentsTableComponentProps) => {
|
||||
const initialSizingOptions: TableColumnSizingOptions = {
|
||||
id: {
|
||||
idealWidth: 280,
|
||||
minWidth: 50,
|
||||
},
|
||||
};
|
||||
columnHeaders.partitionKeyHeaders.forEach((pkHeader) => {
|
||||
initialSizingOptions[pkHeader] = {
|
||||
idealWidth: 200,
|
||||
minWidth: 50,
|
||||
};
|
||||
const [columnSizingOptions, setColumnSizingOptions] = React.useState<TableColumnSizingOptions>(() => {
|
||||
const columnIds = ["id"].concat(columnHeaders.partitionKeyHeaders);
|
||||
const columnSizesMap: ColumnSizesMap = readSubComponentState("ColumnSizes", collection, {});
|
||||
const columnSizesPx: ColumnSizesMap = {};
|
||||
columnIds.forEach((columnId) => {
|
||||
columnSizesPx[columnId] = (columnSizesMap && columnSizesMap[columnId]) || defaultSize;
|
||||
});
|
||||
return columnSizesPx;
|
||||
});
|
||||
|
||||
const [columnSizingOptions, setColumnSizingOptions] = React.useState<TableColumnSizingOptions>(initialSizingOptions);
|
||||
|
||||
const onColumnResize = React.useCallback((_, { columnId, width }) => {
|
||||
setColumnSizingOptions((state) => ({
|
||||
...state,
|
||||
[columnId]: {
|
||||
...state[columnId],
|
||||
idealWidth: width,
|
||||
},
|
||||
}));
|
||||
setColumnSizingOptions((state) => {
|
||||
const newSizingOptions = {
|
||||
...state,
|
||||
[columnId]: {
|
||||
...state[columnId],
|
||||
idealWidth: width,
|
||||
},
|
||||
};
|
||||
|
||||
saveSubComponentState("ColumnSizes", collection, newSizingOptions, true);
|
||||
|
||||
return newSizingOptions;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Columns must be a static object and cannot change on re-renders otherwise React will complain about too many refreshes
|
||||
|
||||
@@ -485,6 +485,7 @@ exports[`Documents tab (noSql API) when rendered should render the page 1`] = `
|
||||
>
|
||||
<Split
|
||||
mode="horizontal"
|
||||
onDragEnd={[Function]}
|
||||
prefixCls="w-split"
|
||||
visiable={true}
|
||||
>
|
||||
@@ -526,6 +527,12 @@ exports[`Documents tab (noSql API) when rendered should render the page 1`] = `
|
||||
}
|
||||
>
|
||||
<DocumentsTableComponent
|
||||
collection={
|
||||
Object {
|
||||
"databaseId": "databaseId",
|
||||
"id": [Function],
|
||||
}
|
||||
}
|
||||
columnHeaders={
|
||||
Object {
|
||||
"idHeader": "id",
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
|
||||
exports[`DocumentsTableComponent should not render selection column when isSelectionDisabled is true 1`] = `
|
||||
<DocumentsTableComponent
|
||||
collection={
|
||||
Object {
|
||||
"databaseId": "db",
|
||||
"id": [Function],
|
||||
}
|
||||
}
|
||||
columnHeaders={
|
||||
Object {
|
||||
"idHeader": "id",
|
||||
@@ -994,6 +1000,12 @@ exports[`DocumentsTableComponent should not render selection column when isSelec
|
||||
|
||||
exports[`DocumentsTableComponent should render documents and partition keys in header 1`] = `
|
||||
<DocumentsTableComponent
|
||||
collection={
|
||||
Object {
|
||||
"databaseId": "db",
|
||||
"id": [Function],
|
||||
}
|
||||
}
|
||||
columnHeaders={
|
||||
Object {
|
||||
"idHeader": "id",
|
||||
|
||||
@@ -132,7 +132,7 @@ export default class MongoShellTabComponent extends Component<
|
||||
data: {
|
||||
resourceId: resourceId,
|
||||
accountName: accountName,
|
||||
mongoEndpoint: mongoEndpoint,
|
||||
mongoEndpoint: this._useMongoProxyEndpoint ? documentEndpoint : mongoEndpoint,
|
||||
authorization: authorization,
|
||||
databaseId: databaseId,
|
||||
collectionId: collectionId,
|
||||
|
||||
@@ -51,13 +51,18 @@ export const fetchEncryptedToken_ToBeDeprecated = async (connectionString: strin
|
||||
export const isAccountRestrictedForConnectionStringLogin = async (connectionString: string): Promise<boolean> => {
|
||||
const headers = new Headers();
|
||||
headers.append(HttpHeaders.connectionString, connectionString);
|
||||
const url = configContext.BACKEND_ENDPOINT + "/api/guest/accountrestrictions/checkconnectionstringlogin";
|
||||
|
||||
const backendEndpoint: string = useNewPortalBackendEndpoint(BackendApi.PortalSettings)
|
||||
? configContext.PORTAL_BACKEND_ENDPOINT
|
||||
: configContext.BACKEND_ENDPOINT;
|
||||
|
||||
const url = backendEndpoint + "/api/guest/accountrestrictions/checkconnectionstringlogin";
|
||||
const response = await fetch(url, { headers, method: "POST" });
|
||||
if (!response.ok) {
|
||||
throw response;
|
||||
}
|
||||
|
||||
return (await response.text()) === "True";
|
||||
return (await response.text()).toLowerCase() === "true";
|
||||
};
|
||||
|
||||
export const ConnectExplorer: React.FunctionComponent<Props> = ({
|
||||
|
||||
76
src/Shared/AppStatePersistenceUtility.ts
Normal file
76
src/Shared/AppStatePersistenceUtility.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { LocalStorageUtility, StorageKey } from "Shared/StorageUtility";
|
||||
|
||||
// The component name whose state is being saved. Component name must not include special characters.
|
||||
export type ComponentName = "DocumentsTab" | "DocumentsTab.columnSizes";
|
||||
|
||||
const SCHEMA_VERSION = 1;
|
||||
export interface StateData {
|
||||
schemaVersion: number;
|
||||
timestamp: number;
|
||||
data: unknown;
|
||||
}
|
||||
|
||||
type StorePath = {
|
||||
componentName: string;
|
||||
subComponentName?: string;
|
||||
globalAccountName?: string;
|
||||
databaseName?: string;
|
||||
containerName?: string;
|
||||
};
|
||||
|
||||
// Load and save state data
|
||||
export const loadState = (path: StorePath): unknown => {
|
||||
const appState =
|
||||
LocalStorageUtility.getEntryObject<ApplicationState>(StorageKey.AppState) || ({} as ApplicationState);
|
||||
const key = createKeyFromPath(path);
|
||||
return appState[key]?.data;
|
||||
};
|
||||
export const saveState = (path: StorePath, state: unknown): void => {
|
||||
// Retrieve state object
|
||||
const appState =
|
||||
LocalStorageUtility.getEntryObject<ApplicationState>(StorageKey.AppState) || ({} as ApplicationState);
|
||||
const key = createKeyFromPath(path);
|
||||
appState[key] = {
|
||||
schemaVersion: SCHEMA_VERSION,
|
||||
timestamp: Date.now(),
|
||||
data: state,
|
||||
};
|
||||
|
||||
// TODO Add logic to clean up old state data based on timestamp
|
||||
|
||||
LocalStorageUtility.setEntryObject(StorageKey.AppState, appState);
|
||||
};
|
||||
|
||||
// This is for high-frequency state changes
|
||||
let timeoutId: NodeJS.Timeout | undefined;
|
||||
export const saveStateDebounced = (path: StorePath, state: unknown, debounceDelayMs = 1000): void => {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
timeoutId = setTimeout(() => saveState(path, state), debounceDelayMs);
|
||||
};
|
||||
|
||||
interface ApplicationState {
|
||||
[statePath: string]: StateData;
|
||||
}
|
||||
|
||||
const orderedPathSegments: (keyof StorePath)[] = [
|
||||
"subComponentName",
|
||||
"globalAccountName",
|
||||
"databaseName",
|
||||
"containerName",
|
||||
];
|
||||
|
||||
/**
|
||||
* /componentName/subComponentName/globalAccountName/databaseName/containerName/
|
||||
* Any of the path segments can be "" except componentName
|
||||
* @param path
|
||||
*/
|
||||
const createKeyFromPath = (path: StorePath): string => {
|
||||
let key = `/${path.componentName}`; // ComponentName is always there
|
||||
orderedPathSegments.forEach((segment) => {
|
||||
const segmentValue = path[segment as keyof StorePath];
|
||||
key += `/${segmentValue !== undefined ? segmentValue : ""}`;
|
||||
});
|
||||
return key;
|
||||
};
|
||||
@@ -20,3 +20,14 @@ export const setEntryNumber = (key: StorageKey, value: number): void =>
|
||||
|
||||
export const setEntryBoolean = (key: StorageKey, value: boolean): void =>
|
||||
localStorage.setItem(StorageKey[key], value.toString());
|
||||
|
||||
export const setEntryObject = (key: StorageKey, value: unknown): void => {
|
||||
localStorage.setItem(StorageKey[key], JSON.stringify(value));
|
||||
};
|
||||
export const getEntryObject = <T>(key: StorageKey): T | null => {
|
||||
const item = localStorage.getItem(StorageKey[key]);
|
||||
if (item) {
|
||||
return JSON.parse(item) as T;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -30,6 +30,7 @@ export enum StorageKey {
|
||||
VisitedAccounts,
|
||||
PriorityLevel,
|
||||
DefaultQueryResultsView,
|
||||
AppState,
|
||||
}
|
||||
|
||||
export const hasRUThresholdBeenConfigured = (): boolean => {
|
||||
|
||||
@@ -102,6 +102,7 @@ export interface UserContext {
|
||||
readonly vcoreMongoConnectionParams?: VCoreMongoConnectionParams;
|
||||
readonly feedbackPolicies?: AdminFeedbackPolicySettings;
|
||||
readonly dataPlaneRbacEnabled?: boolean;
|
||||
readonly hasDataPlaneRbacSettingChanged?: boolean;
|
||||
}
|
||||
|
||||
export type ApiType = "SQL" | "Mongo" | "Gremlin" | "Tables" | "Cassandra" | "Postgres" | "VCoreMongo";
|
||||
|
||||
@@ -164,6 +164,7 @@ export function useNewPortalBackendEndpoint(backendApi: string): boolean {
|
||||
PortalBackendEndpoints.Mpac,
|
||||
PortalBackendEndpoints.Prod,
|
||||
],
|
||||
[BackendApi.AccountRestrictions]: [PortalBackendEndpoints.Development, PortalBackendEndpoints.Mpac],
|
||||
};
|
||||
|
||||
if (!newBackendApiEnvironmentMap[backendApi] || !configContext.PORTAL_BACKEND_ENDPOINT) {
|
||||
|
||||
@@ -52,11 +52,17 @@ export async function fetchDatabaseAccountsFromGraph(
|
||||
const body = {
|
||||
query: databaseAccountsQuery,
|
||||
subscriptions: [subscriptionId],
|
||||
...(skipToken && {
|
||||
options: {
|
||||
$skipToken: skipToken,
|
||||
} as QueryRequestOptions,
|
||||
}),
|
||||
...(skipToken
|
||||
? {
|
||||
options: {
|
||||
$skipToken: skipToken,
|
||||
} as QueryRequestOptions,
|
||||
}
|
||||
: {
|
||||
options: {
|
||||
$top: 150,
|
||||
} as QueryRequestOptions,
|
||||
}),
|
||||
};
|
||||
|
||||
const response = await fetch(managementResourceGraphAPIURL, {
|
||||
|
||||
@@ -41,9 +41,9 @@ import { Node, PortalEnv, updateUserContext, userContext } from "../UserContext"
|
||||
import { acquireTokenWithMsal, getAuthorizationHeader, getMsalInstance } from "../Utils/AuthorizationUtils";
|
||||
import { isInvalidParentFrameOrigin, shouldProcessMessage } from "../Utils/MessageValidation";
|
||||
import { listKeys } from "../Utils/arm/generatedClients/cosmos/databaseAccounts";
|
||||
import { DatabaseAccountListKeysResult } from "../Utils/arm/generatedClients/cosmos/types";
|
||||
import { applyExplorerBindings } from "../applyExplorerBindings";
|
||||
import { useDataPlaneRbac } from "Explorer/Panes/SettingsPane/SettingsPane";
|
||||
import * as Logger from "../Common/Logger";
|
||||
|
||||
// This hook will create a new instance of Explorer.ts and bind it to the DOM
|
||||
// This hook has a LOT of magic, but ideally we can delete it once we have removed KO and switched entirely to React
|
||||
@@ -276,26 +276,79 @@ async function configureHostedWithAAD(config: AAD): Promise<Explorer> {
|
||||
updateUserContext({
|
||||
databaseAccount: config.databaseAccount,
|
||||
});
|
||||
|
||||
Logger.logInfo(
|
||||
`Configuring Data Explorer for ${userContext.apiType} account ${account.name}`,
|
||||
"Explorer/configureHostedWithAAD",
|
||||
);
|
||||
if (!userContext.features.enableAadDataPlane) {
|
||||
Logger.logInfo(`AAD Feature flag is not enabled for account ${account.name}`, "Explorer/configureHostedWithAAD");
|
||||
if (userContext.apiType === "SQL") {
|
||||
if (LocalStorageUtility.hasItem(StorageKey.DataPlaneRbacEnabled)) {
|
||||
const isDataPlaneRbacSetting = LocalStorageUtility.getEntryString(StorageKey.DataPlaneRbacEnabled);
|
||||
Logger.logInfo(
|
||||
`Local storage RBAC setting for ${userContext.apiType} account ${account.name} is ${isDataPlaneRbacSetting}`,
|
||||
"Explorer/configureHostedWithAAD",
|
||||
);
|
||||
|
||||
let dataPlaneRbacEnabled;
|
||||
if (isDataPlaneRbacSetting === Constants.RBACOptions.setAutomaticRBACOption) {
|
||||
dataPlaneRbacEnabled = account.properties.disableLocalAuth;
|
||||
Logger.logInfo(
|
||||
`Data Plane RBAC value for ${userContext.apiType} account ${account.name} with disable local auth set to ${account.properties.disableLocalAuth} is ${dataPlaneRbacEnabled}`,
|
||||
"Explorer/configureHostedWithAAD",
|
||||
);
|
||||
} else {
|
||||
dataPlaneRbacEnabled = isDataPlaneRbacSetting === Constants.RBACOptions.setTrueRBACOption;
|
||||
Logger.logInfo(
|
||||
`Data Plane RBAC value for ${userContext.apiType} account ${account.name} with disable local auth set to ${account.properties.disableLocalAuth} is ${dataPlaneRbacEnabled}`,
|
||||
"Explorer/configureHostedWithAAD",
|
||||
);
|
||||
}
|
||||
if (!dataPlaneRbacEnabled) {
|
||||
Logger.logInfo(
|
||||
`Calling fetch keys for ${userContext.apiType} account ${account.name} with RBAC setting ${dataPlaneRbacEnabled}`,
|
||||
"Explorer/configureHostedWithAAD",
|
||||
);
|
||||
await fetchAndUpdateKeys(subscriptionId, resourceGroup, account.name);
|
||||
}
|
||||
|
||||
updateUserContext({ dataPlaneRbacEnabled });
|
||||
} else {
|
||||
const dataPlaneRbacEnabled = account.properties.disableLocalAuth;
|
||||
Logger.logInfo(
|
||||
`Local storage setting does not exist : Data Plane RBAC value for ${userContext.apiType} account ${account.name} with disable local auth set to ${account.properties.disableLocalAuth} is ${dataPlaneRbacEnabled}`,
|
||||
"Explorer/configureHostedWithAAD",
|
||||
);
|
||||
|
||||
if (!dataPlaneRbacEnabled) {
|
||||
Logger.logInfo(
|
||||
`Fetching keys for ${userContext.apiType} account ${account.name} with RBAC setting ${dataPlaneRbacEnabled}`,
|
||||
"Explorer/configureHostedWithAAD",
|
||||
);
|
||||
await fetchAndUpdateKeys(subscriptionId, resourceGroup, account.name);
|
||||
}
|
||||
|
||||
updateUserContext({ dataPlaneRbacEnabled });
|
||||
useDataPlaneRbac.setState({ dataPlaneRbacEnabled: dataPlaneRbacEnabled });
|
||||
}
|
||||
} else {
|
||||
const keys: DatabaseAccountListKeysResult = await listKeys(subscriptionId, resourceGroup, account.name);
|
||||
updateUserContext({
|
||||
masterKey: keys.primaryMasterKey,
|
||||
});
|
||||
Logger.logInfo(
|
||||
`Fetching keys for ${userContext.apiType} account ${account.name}`,
|
||||
"Explorer/configureHostedWithAAD",
|
||||
);
|
||||
await fetchAndUpdateKeys(subscriptionId, resourceGroup, account.name);
|
||||
}
|
||||
} else {
|
||||
Logger.logInfo(
|
||||
`AAD Feature flag is enabled for account ${account.name} with disable local auth set to ${account.properties.disableLocalAuth} `,
|
||||
"Explorer/configureHostedWithAAD",
|
||||
);
|
||||
if (!account.properties.disableLocalAuth) {
|
||||
Logger.logInfo(
|
||||
`Fetching keys for ${userContext.apiType} account ${account.name} with AAD data plane feature enabled`,
|
||||
"Explorer/configureHostedWithAAD",
|
||||
);
|
||||
await fetchAndUpdateKeys(subscriptionId, resourceGroup, account.name);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -412,10 +465,34 @@ function configureEmulator(): Explorer {
|
||||
return explorer;
|
||||
}
|
||||
|
||||
async function fetchAndUpdateKeys(subscriptionId: string, resourceGroup: string, account: string) {
|
||||
try {
|
||||
Logger.logInfo(`Fetching keys for ${userContext.apiType} account ${account}`, "Explorer/fetchAndUpdateKeys");
|
||||
const keys = await listKeys(subscriptionId, resourceGroup, account);
|
||||
Logger.logInfo(`Keys fetched for ${userContext.apiType} account ${account}`, "Explorer/fetchAndUpdateKeys");
|
||||
|
||||
updateUserContext({
|
||||
masterKey: keys.primaryMasterKey,
|
||||
});
|
||||
Logger.logInfo(
|
||||
`User context updated with Master key for ${userContext.apiType} account ${account}`,
|
||||
"Explorer/fetchAndUpdateKeys",
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error during fetching keys or updating user context:", error);
|
||||
Logger.logError(
|
||||
`Error during fetching keys or updating user context: ${error} for ${userContext.apiType} account ${account}`,
|
||||
"Explorer/fetchAndUpdateKeys",
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function configurePortal(): Promise<Explorer> {
|
||||
updateUserContext({
|
||||
authType: AuthType.AAD,
|
||||
});
|
||||
|
||||
let explorer: Explorer;
|
||||
return new Promise((resolve) => {
|
||||
// In development mode, try to load the iframe message from session storage.
|
||||
@@ -430,6 +507,7 @@ async function configurePortal(): Promise<Explorer> {
|
||||
console.dir(message);
|
||||
updateContextsFromPortalMessage(message);
|
||||
explorer = new Explorer();
|
||||
|
||||
// In development mode, save the iframe message from the portal in session storage.
|
||||
// This allows webpack hot reload to funciton properly
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
@@ -465,34 +543,58 @@ async function configurePortal(): Promise<Explorer> {
|
||||
}
|
||||
|
||||
updateContextsFromPortalMessage(inputs);
|
||||
explorer = new Explorer();
|
||||
resolve(explorer);
|
||||
|
||||
if (userContext.apiType === "Postgres" || userContext.apiType === "SQL" || userContext.apiType === "Mongo") {
|
||||
setTimeout(() => explorer.openNPSSurveyDialog(), 3000);
|
||||
}
|
||||
|
||||
const { databaseAccount: account, subscriptionId, resourceGroup } = userContext;
|
||||
|
||||
let dataPlaneRbacEnabled;
|
||||
if (userContext.apiType === "SQL") {
|
||||
if (LocalStorageUtility.hasItem(StorageKey.DataPlaneRbacEnabled)) {
|
||||
const isDataPlaneRbacSetting = LocalStorageUtility.getEntryString(StorageKey.DataPlaneRbacEnabled);
|
||||
Logger.logInfo(
|
||||
`Local storage RBAC setting for ${userContext.apiType} account ${account.name} is ${isDataPlaneRbacSetting}`,
|
||||
"Explorer/configurePortal",
|
||||
);
|
||||
|
||||
let dataPlaneRbacEnabled;
|
||||
if (isDataPlaneRbacSetting === Constants.RBACOptions.setAutomaticRBACOption) {
|
||||
dataPlaneRbacEnabled = account.properties.disableLocalAuth;
|
||||
} else {
|
||||
dataPlaneRbacEnabled = isDataPlaneRbacSetting === Constants.RBACOptions.setTrueRBACOption;
|
||||
}
|
||||
|
||||
updateUserContext({ dataPlaneRbacEnabled });
|
||||
useDataPlaneRbac.setState({ dataPlaneRbacEnabled: dataPlaneRbacEnabled });
|
||||
} else {
|
||||
Logger.logInfo(
|
||||
`Local storage does not exist for ${userContext.apiType} account ${account.name} with disable local auth set to ${account.properties.disableLocalAuth} is ${dataPlaneRbacEnabled}`,
|
||||
"Explorer/configurePortal",
|
||||
);
|
||||
dataPlaneRbacEnabled = account.properties.disableLocalAuth;
|
||||
}
|
||||
} else {
|
||||
const keys: DatabaseAccountListKeysResult = await listKeys(subscriptionId, resourceGroup, account.name);
|
||||
updateUserContext({
|
||||
masterKey: keys.primaryMasterKey,
|
||||
});
|
||||
Logger.logInfo(
|
||||
`Data Plane RBAC value for ${userContext.apiType} account ${account.name} with disable local auth set to ${account.properties.disableLocalAuth} is ${dataPlaneRbacEnabled}`,
|
||||
"Explorer/configurePortal",
|
||||
);
|
||||
|
||||
if (!dataPlaneRbacEnabled) {
|
||||
Logger.logInfo(
|
||||
`Calling fetch keys for ${userContext.apiType} account ${account.name}`,
|
||||
"Explorer/configurePortal",
|
||||
);
|
||||
await fetchAndUpdateKeys(subscriptionId, resourceGroup, account.name);
|
||||
}
|
||||
|
||||
updateUserContext({ dataPlaneRbacEnabled });
|
||||
useDataPlaneRbac.setState({ dataPlaneRbacEnabled: dataPlaneRbacEnabled });
|
||||
} else if (userContext.apiType !== "Postgres" && userContext.apiType !== "VCoreMongo") {
|
||||
Logger.logInfo(
|
||||
`Calling fetch keys for ${userContext.apiType} account ${account.name}`,
|
||||
"Explorer/configurePortal",
|
||||
);
|
||||
await fetchAndUpdateKeys(subscriptionId, resourceGroup, account.name);
|
||||
}
|
||||
|
||||
explorer = new Explorer();
|
||||
resolve(explorer);
|
||||
|
||||
if (userContext.apiType === "Postgres" || userContext.apiType === "SQL" || userContext.apiType === "Mongo") {
|
||||
setTimeout(() => explorer.openNPSSurveyDialog(), 3000);
|
||||
}
|
||||
|
||||
if (openAction) {
|
||||
|
||||
@@ -51,11 +51,13 @@ export async function fetchSubscriptionsFromGraph(accessToken: string): Promise<
|
||||
do {
|
||||
const body = {
|
||||
query: subscriptionsQuery,
|
||||
...(skipToken && {
|
||||
options: {
|
||||
options: {
|
||||
$allowPartialScopes: true,
|
||||
$top: 150,
|
||||
...(skipToken && {
|
||||
$skipToken: skipToken,
|
||||
} as QueryRequestOptions,
|
||||
}),
|
||||
}),
|
||||
} as QueryRequestOptions,
|
||||
};
|
||||
|
||||
const response = await fetch(managementResourceGraphAPIURL, {
|
||||
|
||||
@@ -16,7 +16,7 @@ test("Tables CRUD", async ({ page }) => {
|
||||
|
||||
const databaseNode = explorer.treeNode("DATA/TablesDB");
|
||||
await databaseNode.expand();
|
||||
|
||||
|
||||
const tableNode = explorer.treeNode(`DATA/TablesDB/${tableId}`);
|
||||
await expect(tableNode.element).toBeAttached();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user