mirror of
https://github.com/Azure/cosmos-explorer.git
synced 2026-01-01 15:22:08 +00:00
Compare commits
10 Commits
users/aisa
...
users/artr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1726e4df51 | ||
|
|
bc68b4dbf9 | ||
|
|
15e35eaa82 | ||
|
|
f69cd4c495 | ||
|
|
661f6f4bfb | ||
|
|
3a703b0bd0 | ||
|
|
70c031d04b | ||
|
|
b949f60c2a | ||
|
|
deb0ebcf92 | ||
|
|
2d3048eafe |
@@ -1,6 +1,8 @@
|
||||
import * as Cosmos from "@azure/cosmos";
|
||||
import { sendCachedDataMessage } from "Common/MessageHandler";
|
||||
import { getAuthorizationTokenUsingResourceTokens } from "Common/getAuthorizationTokenUsingResourceTokens";
|
||||
import { AuthorizationToken, MessageTypes } from "Contracts/MessageTypes";
|
||||
import { checkDatabaseResourceTokensValidity } from "Platform/Fabric/FabricUtil";
|
||||
import { AuthType } from "../AuthType";
|
||||
import { PriorityLevel } from "../Common/Constants";
|
||||
import { Platform, configContext } from "../ConfigContext";
|
||||
@@ -28,12 +30,33 @@ export const tokenProvider = async (requestInfo: Cosmos.RequestInfo) => {
|
||||
}
|
||||
|
||||
if (configContext.platform === Platform.Fabric) {
|
||||
const authorizationToken = await sendCachedDataMessage<AuthorizationToken>(MessageTypes.GetAuthorizationToken, [
|
||||
requestInfo,
|
||||
]);
|
||||
console.log("Response from Fabric: ", authorizationToken);
|
||||
headers[HttpHeaders.msDate] = authorizationToken.XDate;
|
||||
return authorizationToken.PrimaryReadWriteToken;
|
||||
switch (requestInfo.resourceType) {
|
||||
case Cosmos.ResourceType.conflicts:
|
||||
case Cosmos.ResourceType.container:
|
||||
case Cosmos.ResourceType.sproc:
|
||||
case Cosmos.ResourceType.udf:
|
||||
case Cosmos.ResourceType.trigger:
|
||||
case Cosmos.ResourceType.item:
|
||||
case Cosmos.ResourceType.pkranges:
|
||||
// User resource tokens
|
||||
headers[HttpHeaders.msDate] = new Date().toUTCString();
|
||||
const resourceTokens = userContext.fabricDatabaseConnectionInfo.resourceTokens;
|
||||
checkDatabaseResourceTokensValidity(userContext.fabricDatabaseConnectionInfo.resourceTokensTimestamp);
|
||||
return getAuthorizationTokenUsingResourceTokens(resourceTokens, requestInfo.path, requestInfo.resourceId);
|
||||
|
||||
case Cosmos.ResourceType.none:
|
||||
case Cosmos.ResourceType.database:
|
||||
case Cosmos.ResourceType.offer:
|
||||
case Cosmos.ResourceType.user:
|
||||
case Cosmos.ResourceType.permission:
|
||||
// User master tokens
|
||||
const authorizationToken = await sendCachedDataMessage<AuthorizationToken>(MessageTypes.GetAuthorizationToken, [
|
||||
requestInfo,
|
||||
]);
|
||||
console.log("Response from Fabric: ", authorizationToken);
|
||||
headers[HttpHeaders.msDate] = authorizationToken.XDate;
|
||||
return decodeURIComponent(authorizationToken.PrimaryReadWriteToken);
|
||||
}
|
||||
}
|
||||
|
||||
if (userContext.masterKey) {
|
||||
|
||||
@@ -1,18 +1,57 @@
|
||||
import { ContainerResponse } from "@azure/cosmos";
|
||||
import { Queries } from "Common/Constants";
|
||||
import { Platform, configContext } from "ConfigContext";
|
||||
import { AuthType } from "../../AuthType";
|
||||
import * as DataModels from "../../Contracts/DataModels";
|
||||
import { userContext } from "../../UserContext";
|
||||
import { logConsoleProgress } from "../../Utils/NotificationConsoleUtils";
|
||||
import { listCassandraTables } from "../../Utils/arm/generatedClients/cosmos/cassandraResources";
|
||||
import { listGremlinGraphs } from "../../Utils/arm/generatedClients/cosmos/gremlinResources";
|
||||
import { listMongoDBCollections } from "../../Utils/arm/generatedClients/cosmos/mongoDBResources";
|
||||
import { listSqlContainers } from "../../Utils/arm/generatedClients/cosmos/sqlResources";
|
||||
import { listTables } from "../../Utils/arm/generatedClients/cosmos/tableResources";
|
||||
import { logConsoleProgress } from "../../Utils/NotificationConsoleUtils";
|
||||
import { client } from "../CosmosClient";
|
||||
import { handleError } from "../ErrorHandlingUtils";
|
||||
|
||||
export async function readCollections(databaseId: string): Promise<DataModels.Collection[]> {
|
||||
const clearMessage = logConsoleProgress(`Querying containers for database ${databaseId}`);
|
||||
|
||||
if (
|
||||
configContext.platform === Platform.Fabric &&
|
||||
userContext.fabricDatabaseConnectionInfo &&
|
||||
userContext.fabricDatabaseConnectionInfo.databaseId === databaseId
|
||||
) {
|
||||
const collections: DataModels.Collection[] = [];
|
||||
const promises: Promise<ContainerResponse>[] = [];
|
||||
|
||||
for (const collectionResourceId in userContext.fabricDatabaseConnectionInfo.resourceTokens) {
|
||||
// Dictionary key looks like this: dbs/SampleDB/colls/Container
|
||||
const resourceIdObj = collectionResourceId.split("/");
|
||||
const tokenDatabaseId = resourceIdObj[1];
|
||||
const tokenCollectionId = resourceIdObj[3];
|
||||
|
||||
if (tokenDatabaseId === databaseId) {
|
||||
promises.push(client().database(databaseId).container(tokenCollectionId).read());
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const responses = await Promise.all(promises);
|
||||
responses.forEach((response) => {
|
||||
collections.push(response.resource as DataModels.Collection);
|
||||
});
|
||||
|
||||
// Sort collections by id before returning
|
||||
collections.sort((a, b) => a.id.localeCompare(b.id));
|
||||
return collections;
|
||||
} catch (error) {
|
||||
handleError(error, "ReadCollections", `Error while querying containers for database ${databaseId}`);
|
||||
throw error;
|
||||
} finally {
|
||||
clearMessage();
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (
|
||||
userContext.authType === AuthType.AAD &&
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
import { Platform, configContext } from "ConfigContext";
|
||||
import { AuthType } from "../../AuthType";
|
||||
import { Offer, ReadDatabaseOfferParams } from "../../Contracts/DataModels";
|
||||
import { userContext } from "../../UserContext";
|
||||
import { logConsoleProgress } from "../../Utils/NotificationConsoleUtils";
|
||||
import { getCassandraKeyspaceThroughput } from "../../Utils/arm/generatedClients/cosmos/cassandraResources";
|
||||
import { getGremlinDatabaseThroughput } from "../../Utils/arm/generatedClients/cosmos/gremlinResources";
|
||||
import { getMongoDBDatabaseThroughput } from "../../Utils/arm/generatedClients/cosmos/mongoDBResources";
|
||||
import { getSqlDatabaseThroughput } from "../../Utils/arm/generatedClients/cosmos/sqlResources";
|
||||
import { logConsoleProgress } from "../../Utils/NotificationConsoleUtils";
|
||||
import { handleError } from "../ErrorHandlingUtils";
|
||||
import { readOfferWithSDK } from "./readOfferWithSDK";
|
||||
|
||||
export const readDatabaseOffer = async (params: ReadDatabaseOfferParams): Promise<Offer> => {
|
||||
if (configContext.platform === Platform.Fabric) {
|
||||
// TODO This works, but is very slow, because it requests the token, so we skip for now
|
||||
console.error("Skiping readDatabaseOffer for Fabric");
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const clearMessage = logConsoleProgress(`Querying offer for database ${params.databaseId}`);
|
||||
|
||||
try {
|
||||
|
||||
@@ -1,17 +1,53 @@
|
||||
import { Platform, configContext } from "ConfigContext";
|
||||
import { AuthType } from "../../AuthType";
|
||||
import * as DataModels from "../../Contracts/DataModels";
|
||||
import { userContext } from "../../UserContext";
|
||||
import { logConsoleProgress } from "../../Utils/NotificationConsoleUtils";
|
||||
import { listCassandraKeyspaces } from "../../Utils/arm/generatedClients/cosmos/cassandraResources";
|
||||
import { listGremlinDatabases } from "../../Utils/arm/generatedClients/cosmos/gremlinResources";
|
||||
import { listMongoDBDatabases } from "../../Utils/arm/generatedClients/cosmos/mongoDBResources";
|
||||
import { listSqlDatabases } from "../../Utils/arm/generatedClients/cosmos/sqlResources";
|
||||
import { logConsoleProgress } from "../../Utils/NotificationConsoleUtils";
|
||||
import { client } from "../CosmosClient";
|
||||
import { handleError } from "../ErrorHandlingUtils";
|
||||
|
||||
export async function readDatabases(): Promise<DataModels.Database[]> {
|
||||
let databases: DataModels.Database[];
|
||||
const clearMessage = logConsoleProgress(`Querying databases`);
|
||||
|
||||
if (configContext.platform === Platform.Fabric && userContext.fabricDatabaseConnectionInfo?.resourceTokens) {
|
||||
const tokensData = userContext.fabricDatabaseConnectionInfo;
|
||||
|
||||
const databaseIdsSet = new Set<string>(); // databaseId
|
||||
|
||||
for (const collectionResourceId in tokensData.resourceTokens) {
|
||||
// Dictionary key looks like this: dbs/SampleDB/colls/Container
|
||||
const resourceIdObj = collectionResourceId.split("/");
|
||||
|
||||
if (resourceIdObj.length !== 4) {
|
||||
handleError(`Resource key not recognized: ${resourceIdObj}`, "ReadDatabases", `Error while querying databases`);
|
||||
clearMessage();
|
||||
return [];
|
||||
}
|
||||
|
||||
const databaseId = resourceIdObj[1];
|
||||
|
||||
databaseIdsSet.add(databaseId);
|
||||
}
|
||||
|
||||
const databases: DataModels.Database[] = Array.from(databaseIdsSet.values())
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
.map((databaseId) => ({
|
||||
_rid: "",
|
||||
_self: "",
|
||||
_etag: "",
|
||||
_ts: 0,
|
||||
id: databaseId,
|
||||
collections: [],
|
||||
}));
|
||||
clearMessage();
|
||||
return databases;
|
||||
}
|
||||
|
||||
try {
|
||||
if (
|
||||
userContext.authType === AuthType.AAD &&
|
||||
|
||||
66
src/Common/getAuthorizationTokenUsingResourceTokens.ts
Normal file
66
src/Common/getAuthorizationTokenUsingResourceTokens.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
export function getAuthorizationTokenUsingResourceTokens(
|
||||
resourceTokens: { [resourceId: string]: string },
|
||||
path: string,
|
||||
resourceId: string,
|
||||
): string {
|
||||
// console.log(`getting token for path: "${path}" and resourceId: "${resourceId}"`);
|
||||
|
||||
if (resourceTokens && Object.keys(resourceTokens).length > 0) {
|
||||
// For database account access(through getDatabaseAccount API), path and resourceId are "",
|
||||
// so in this case we return the first token to be used for creating the auth header as the
|
||||
// service will accept any token in this case
|
||||
if (!path && !resourceId) {
|
||||
return resourceTokens[Object.keys(resourceTokens)[0]];
|
||||
}
|
||||
|
||||
// If we have exact resource token for the path use it
|
||||
if (resourceId && resourceTokens[resourceId]) {
|
||||
return resourceTokens[resourceId];
|
||||
}
|
||||
|
||||
// minimum valid path /dbs
|
||||
if (!path || path.length < 4) {
|
||||
console.error(
|
||||
`Unable to get authotization token for Path:"${path}" and resourcerId:"${resourceId}". Invalid path.`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
path = trimSlashFromLeftAndRight(path);
|
||||
const pathSegments = (path && path.split("/")) || [];
|
||||
|
||||
// Item path
|
||||
if (pathSegments.length === 6) {
|
||||
// Look for a container token matching the item path
|
||||
const containerPath = pathSegments.slice(0, 4).map(decodeURIComponent).join("/");
|
||||
if (resourceTokens[containerPath]) {
|
||||
return resourceTokens[containerPath];
|
||||
}
|
||||
}
|
||||
|
||||
// This is legacy behavior that lets someone use a resource token pointing ONLY at an ID
|
||||
// It was used when _rid was exposed by the SDK, but now that we are using user provided ids it is not needed
|
||||
// However removing it now would be a breaking change
|
||||
// if it's an incomplete path like /dbs/db1/colls/, start from the parent resource
|
||||
let index = pathSegments.length % 2 === 0 ? pathSegments.length - 1 : pathSegments.length - 2;
|
||||
for (; index > 0; index -= 2) {
|
||||
const id = decodeURI(pathSegments[index]);
|
||||
if (resourceTokens[id]) {
|
||||
return resourceTokens[id];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.error(`Unable to get authotization token for Path:"${path}" and resourcerId:"${resourceId}"`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimLeftSlashes = new RegExp("^[/]+");
|
||||
const trimRightSlashes = new RegExp("[/]+$");
|
||||
function trimSlashFromLeftAndRight(inputString: string): string {
|
||||
if (typeof inputString !== "string") {
|
||||
throw new Error("invalid input: input is not string");
|
||||
}
|
||||
|
||||
return inputString.replace(trimLeftSlashes, "").replace(trimRightSlashes, "");
|
||||
}
|
||||
@@ -64,6 +64,7 @@ let configContext: Readonly<ConfigContext> = {
|
||||
`^https:\\/\\/.*\\.fabric\\.microsoft\\.com$`,
|
||||
`^https:\\/\\/.*\\.powerbi\\.com$`,
|
||||
`^https:\\/\\/.*\\.analysis-df\\.net$`,
|
||||
`^https:\\/\\/.*\\.analysis-df\\.windows\\.net$`,
|
||||
], // Webpack injects this at build time
|
||||
gitSha: process.env.GIT_SHA,
|
||||
hostedExplorerURL: "https://cosmos.azure.com/",
|
||||
|
||||
13
src/Contracts/AzureResourceGraph.ts
Normal file
13
src/Contracts/AzureResourceGraph.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export interface QueryRequestOptions {
|
||||
$skipToken?: string;
|
||||
$top?: number;
|
||||
subscriptions: string[];
|
||||
}
|
||||
|
||||
export interface QueryResponse {
|
||||
$skipToken: string;
|
||||
count: number;
|
||||
data: any;
|
||||
resultTruncated: boolean;
|
||||
totalRecords: number;
|
||||
}
|
||||
@@ -88,13 +88,13 @@ export interface GenerateTokenResponse {
|
||||
}
|
||||
|
||||
export interface Subscription {
|
||||
uniqueDisplayName: string;
|
||||
uniqueDisplayName?: string;
|
||||
displayName: string;
|
||||
subscriptionId: string;
|
||||
tenantId: string;
|
||||
tenantId?: string;
|
||||
state: string;
|
||||
subscriptionPolicies: SubscriptionPolicies;
|
||||
authorizationSource: string;
|
||||
subscriptionPolicies?: SubscriptionPolicies;
|
||||
authorizationSource?: string;
|
||||
}
|
||||
|
||||
export interface SubscriptionPolicies {
|
||||
|
||||
@@ -9,14 +9,12 @@ export type FabricMessage =
|
||||
type: "initialize";
|
||||
message: {
|
||||
endpoint: string | undefined;
|
||||
databaseId: string | undefined;
|
||||
resourceTokens: unknown | undefined;
|
||||
resourceTokensTimestamp: number | undefined;
|
||||
error: string | undefined;
|
||||
};
|
||||
}
|
||||
| {
|
||||
type: "openTab";
|
||||
databaseName: string;
|
||||
collectionName: string | undefined;
|
||||
}
|
||||
| {
|
||||
type: "authorizationToken";
|
||||
message: {
|
||||
@@ -24,6 +22,15 @@ export type FabricMessage =
|
||||
error: string | undefined;
|
||||
data: AuthorizationToken | undefined;
|
||||
};
|
||||
}
|
||||
| {
|
||||
type: "allResourceTokens";
|
||||
message: {
|
||||
endpoint: string | undefined;
|
||||
databaseId: string | undefined;
|
||||
resourceTokens: unknown | undefined;
|
||||
resourceTokensTimestamp: number | undefined;
|
||||
};
|
||||
};
|
||||
|
||||
export type DataExploreMessage =
|
||||
@@ -40,6 +47,9 @@ export type DataExploreMessage =
|
||||
type: MessageTypes.GetAuthorizationToken;
|
||||
id: string;
|
||||
params: GetCosmosTokenMessageOptions[];
|
||||
}
|
||||
| {
|
||||
type: MessageTypes.GetAllResourceTokens;
|
||||
};
|
||||
|
||||
export type GetCosmosTokenMessageOptions = {
|
||||
@@ -55,4 +65,13 @@ export type CosmosDBTokenResponse = {
|
||||
|
||||
export type CosmosDBConnectionInfoResponse = {
|
||||
endpoint: string;
|
||||
databaseId: string;
|
||||
resourceTokens: unknown;
|
||||
};
|
||||
|
||||
export interface FabricDatabaseConnectionInfo {
|
||||
endpoint: string;
|
||||
databaseId: string;
|
||||
resourceTokens: { [resourceId: string]: string };
|
||||
resourceTokensTimestamp: number;
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ export enum MessageTypes {
|
||||
|
||||
// Data Explorer -> Fabric communication
|
||||
GetAuthorizationToken,
|
||||
GetAllResourceTokens,
|
||||
}
|
||||
|
||||
export interface AuthorizationToken {
|
||||
|
||||
4
src/Definitions/less.d.ts
vendored
Normal file
4
src/Definitions/less.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
declare module "*.less" {
|
||||
const value: string;
|
||||
export default value;
|
||||
}
|
||||
@@ -129,20 +129,22 @@ export const createCollectionContextMenuButton = (
|
||||
});
|
||||
}
|
||||
|
||||
items.push({
|
||||
iconSrc: DeleteCollectionIcon,
|
||||
onClick: () => {
|
||||
useSelectedNode.getState().setSelectedNode(selectedCollection);
|
||||
useSidePanel
|
||||
.getState()
|
||||
.openSidePanel(
|
||||
"Delete " + getCollectionName(),
|
||||
<DeleteCollectionConfirmationPane refreshDatabases={() => container.refreshAllDatabases()} />,
|
||||
);
|
||||
},
|
||||
label: `Delete ${getCollectionName()}`,
|
||||
styleClass: "deleteCollectionMenuItem",
|
||||
});
|
||||
if (configContext.platform !== Platform.Fabric) {
|
||||
items.push({
|
||||
iconSrc: DeleteCollectionIcon,
|
||||
onClick: () => {
|
||||
useSelectedNode.getState().setSelectedNode(selectedCollection);
|
||||
useSidePanel
|
||||
.getState()
|
||||
.openSidePanel(
|
||||
"Delete " + getCollectionName(),
|
||||
<DeleteCollectionConfirmationPane refreshDatabases={() => container.refreshAllDatabases()} />,
|
||||
);
|
||||
},
|
||||
label: `Delete ${getCollectionName()}`,
|
||||
styleClass: "deleteCollectionMenuItem",
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import { sendMessage } from "Common/MessageHandler";
|
||||
import { Platform, configContext } from "ConfigContext";
|
||||
import { MessageTypes } from "Contracts/ExplorerContracts";
|
||||
import { IGalleryItem } from "Juno/JunoClient";
|
||||
import { requestDatabaseResourceTokens } from "Platform/Fabric/FabricUtil";
|
||||
import { allowedNotebookServerUrls, validateEndpoint } from "Utils/EndpointValidation";
|
||||
import { useQueryCopilot } from "hooks/useQueryCopilot";
|
||||
import * as ko from "knockout";
|
||||
@@ -379,6 +380,13 @@ export default class Explorer {
|
||||
};
|
||||
|
||||
public onRefreshResourcesClick = (): void => {
|
||||
if (configContext.platform === Platform.Fabric) {
|
||||
// Requesting the tokens will trigger a refresh of the databases
|
||||
// TODO: Once the id is returned from Fabric, we can await this call and then refresh the databases here
|
||||
requestDatabaseResourceTokens();
|
||||
return;
|
||||
}
|
||||
|
||||
userContext.authType === AuthType.ResourceToken
|
||||
? this.refreshDatabaseForResourceToken()
|
||||
: this.refreshAllDatabases();
|
||||
|
||||
@@ -50,31 +50,36 @@ export function createStaticCommandBarButtons(
|
||||
return createStaticCommandBarButtonsForResourceToken(container, selectedNodeState);
|
||||
}
|
||||
|
||||
const newCollectionBtn = createNewCollectionGroup(container);
|
||||
const buttons: CommandButtonComponentProps[] = [];
|
||||
|
||||
buttons.push(newCollectionBtn);
|
||||
if (
|
||||
configContext.platform !== Platform.Fabric &&
|
||||
userContext.apiType !== "Tables" &&
|
||||
userContext.apiType !== "Cassandra"
|
||||
) {
|
||||
const addSynapseLink = createOpenSynapseLinkDialogButton(container);
|
||||
|
||||
if (addSynapseLink) {
|
||||
// Avoid starting with a divider
|
||||
const addDivider = () => {
|
||||
if (buttons.length > 0) {
|
||||
buttons.push(createDivider());
|
||||
buttons.push(addSynapseLink);
|
||||
}
|
||||
};
|
||||
|
||||
if (configContext.platform !== Platform.Fabric) {
|
||||
const newCollectionBtn = createNewCollectionGroup(container);
|
||||
buttons.push(newCollectionBtn);
|
||||
if (userContext.apiType !== "Tables" && userContext.apiType !== "Cassandra") {
|
||||
const addSynapseLink = createOpenSynapseLinkDialogButton(container);
|
||||
|
||||
if (addSynapseLink) {
|
||||
addDivider();
|
||||
buttons.push(addSynapseLink);
|
||||
}
|
||||
}
|
||||
|
||||
if (userContext.apiType !== "Tables") {
|
||||
newCollectionBtn.children = [createNewCollectionGroup(container)];
|
||||
const newDatabaseBtn = createNewDatabase(container);
|
||||
newCollectionBtn.children.push(newDatabaseBtn);
|
||||
}
|
||||
}
|
||||
|
||||
if (userContext.apiType !== "Tables" && configContext.platform !== Platform.Fabric) {
|
||||
newCollectionBtn.children = [createNewCollectionGroup(container)];
|
||||
const newDatabaseBtn = createNewDatabase(container);
|
||||
newCollectionBtn.children.push(newDatabaseBtn);
|
||||
}
|
||||
|
||||
if (useNotebook.getState().isNotebookEnabled) {
|
||||
buttons.push(createDivider());
|
||||
addDivider();
|
||||
const notebookButtons: CommandButtonComponentProps[] = [];
|
||||
|
||||
const newNotebookButton = createNewNotebookButton(container);
|
||||
@@ -128,7 +133,7 @@ export function createStaticCommandBarButtons(
|
||||
const isQuerySupported = userContext.apiType === "SQL" || userContext.apiType === "Gremlin";
|
||||
|
||||
if (isQuerySupported) {
|
||||
buttons.push(createDivider());
|
||||
addDivider();
|
||||
const newSqlQueryBtn = createNewSQLQueryButton(selectedNodeState);
|
||||
buttons.push(newSqlQueryBtn);
|
||||
}
|
||||
|
||||
@@ -32,14 +32,8 @@ export const PanelInfoErrorComponent: React.FunctionComponent<PanelInfoErrorProp
|
||||
return (
|
||||
<Stack className="panelInfoErrorContainer" horizontal verticalAlign="center">
|
||||
{icon}
|
||||
<span className="panelWarningErrorDetailsLinkContainer">
|
||||
<Text
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
aria-label={message}
|
||||
className="panelWarningErrorMessage"
|
||||
variant="small"
|
||||
>
|
||||
<span className="panelWarningErrorDetailsLinkContainer" role="alert" aria-live="assertive">
|
||||
<Text aria-label={message} className="panelWarningErrorMessage" variant="small">
|
||||
{message}
|
||||
{link && linkText && (
|
||||
<Link target="_blank" href={link}>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { mount, ReactWrapper } from "enzyme";
|
||||
import { ReactWrapper, mount } from "enzyme";
|
||||
import React from "react";
|
||||
import { RightPaneForm } from "./RightPaneForm";
|
||||
|
||||
@@ -34,6 +34,6 @@ describe("Right Pane Form", () => {
|
||||
it("should render error in header", () => {
|
||||
render(<RightPaneForm {...props} formError="file already Exist" />);
|
||||
expect(screen.getByLabelText("error")).toBeDefined();
|
||||
expect(screen.getByRole("alert").innerHTML).toEqual("file already Exist");
|
||||
expect(screen.getByRole("alert").innerHTML).toContain("file already Exist");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -390,6 +390,9 @@ export const EditTableEntityPanel: FunctionComponent<EditTableEntityPanelProps>
|
||||
</Stack>
|
||||
)}
|
||||
</div>
|
||||
<div className="panelNullWarning" style={{ padding: "20px", color: "red" }}>
|
||||
Warning: Null fields will not be displayed for editing.
|
||||
</div>
|
||||
</RightPaneForm>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -355,6 +355,17 @@ exports[`Excute Edit Table Entity Pane should render Default properly 1`] = `
|
||||
</div>
|
||||
</Stack>
|
||||
</div>
|
||||
<div
|
||||
className="panelNullWarning"
|
||||
style={
|
||||
Object {
|
||||
"color": "red",
|
||||
"padding": "20px",
|
||||
}
|
||||
}
|
||||
>
|
||||
Warning: Null fields will not be displayed for editing.
|
||||
</div>
|
||||
<PanelFooterComponent
|
||||
buttonLabel="Update"
|
||||
isButtonDisabled={false}
|
||||
|
||||
@@ -323,21 +323,19 @@ exports[`Delete Database Confirmation Pane Should call delete database 1`] = `
|
||||
</IconBase>
|
||||
</StyledIconBase>
|
||||
<span
|
||||
aria-live="assertive"
|
||||
className="panelWarningErrorDetailsLinkContainer"
|
||||
key=".0:$.1"
|
||||
role="alert"
|
||||
>
|
||||
<Text
|
||||
aria-label="Warning! The action you are about to take cannot be undone. Continuing will permanently delete this resource and all of its children resources."
|
||||
aria-live="assertive"
|
||||
className="panelWarningErrorMessage"
|
||||
role="alert"
|
||||
variant="small"
|
||||
>
|
||||
<span
|
||||
aria-label="Warning! The action you are about to take cannot be undone. Continuing will permanently delete this resource and all of its children resources."
|
||||
aria-live="assertive"
|
||||
className="panelWarningErrorMessage css-56"
|
||||
role="alert"
|
||||
>
|
||||
Warning! The action you are about to take cannot be undone. Continuing will permanently delete this resource and all of its children resources.
|
||||
</span>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export const newDbAndCollectionCommand = `use quickstartDB
|
||||
db.createCollection('sampleCollection')`;
|
||||
db.createCollection('sampleCollection')
|
||||
`;
|
||||
|
||||
export const newDbAndCollectionCommandForDisplay = `use quickstartDB // Create new database named 'quickstartDB' or switch to it if it already exists
|
||||
|
||||
@@ -16,19 +17,25 @@ export const loadDataCommand = `db.sampleCollection.insertMany([
|
||||
{title: "War and Peace", author: "Leo Tolstoy", pages: 1392},
|
||||
{title: "The Odyssey", author: "Homer", pages: 374},
|
||||
{title: "Ulysses", author: "James Joyce", pages: 730}
|
||||
])`;
|
||||
])
|
||||
`;
|
||||
|
||||
export const queriesCommand = `db.sampleCollection.find({author: "George Orwell"})
|
||||
export const findOrwellCommand = `db.sampleCollection.find({author: "George Orwell"})
|
||||
`;
|
||||
|
||||
export const findOrwellCommandForDisplay = `// Query to find all books written by "George Orwell"
|
||||
db.sampleCollection.find({author: "George Orwell"})`;
|
||||
|
||||
export const findByPagesCommand = `db.sampleCollection.find({pages: {$gt: 500}})
|
||||
`;
|
||||
|
||||
export const findByPagesCommandForDisplay = `// Query to find all books with more than 500 pages
|
||||
db.sampleCollection.find({pages: {$gt: 500}})
|
||||
`;
|
||||
|
||||
db.sampleCollection.find({}).sort({pages: 1})`;
|
||||
export const findAndSortCommand = `db.sampleCollection.find({}).sort({pages: 1})
|
||||
`;
|
||||
|
||||
export const queriesCommandForDisplay = `// Query to find all books written by "George Orwell"
|
||||
db.sampleCollection.find({author: "George Orwell"})
|
||||
|
||||
// Query to find all books with more than 500 pages
|
||||
db.sampleCollection.find({pages: {$gt: 500}})
|
||||
|
||||
// Query to find all books and sort them by the number of pages in ascending order
|
||||
db.sampleCollection.find({}).sort({pages: 1})`;
|
||||
export const findAndSortCommandForDisplay = `// Query to find all books and sort them by the number of pages in ascending order
|
||||
db.sampleCollection.find({}).sort({pages: 1})
|
||||
`;
|
||||
|
||||
@@ -9,15 +9,17 @@ import {
|
||||
Text,
|
||||
TextField,
|
||||
} from "@fluentui/react";
|
||||
import { sendMessage } from "Common/MessageHandler";
|
||||
import { MessageTypes } from "Contracts/ExplorerContracts";
|
||||
import { customPivotHeaderRenderer } from "Explorer/Quickstart/Shared/QuickstartRenderUtilities";
|
||||
import {
|
||||
findAndSortCommand,
|
||||
findAndSortCommandForDisplay,
|
||||
findByPagesCommand,
|
||||
findByPagesCommandForDisplay,
|
||||
findOrwellCommand,
|
||||
findOrwellCommandForDisplay,
|
||||
loadDataCommand,
|
||||
newDbAndCollectionCommand,
|
||||
newDbAndCollectionCommandForDisplay,
|
||||
queriesCommand,
|
||||
queriesCommandForDisplay,
|
||||
} from "Explorer/Quickstart/VCoreMongoQuickstartCommands";
|
||||
import { useTerminal } from "hooks/useTerminal";
|
||||
import React, { useState } from "react";
|
||||
@@ -63,28 +65,16 @@ export const VcoreMongoQuickstartGuide: React.FC = (): JSX.Element => {
|
||||
>
|
||||
<Stack style={{ marginTop: 20 }}>
|
||||
<Text>
|
||||
A hosted mongosh (mongo shell) is provided for this quick start. You are automatically logged in to
|
||||
mongosh, allowing you to interact with your database directly.
|
||||
This tutorial guides you to create and query distributed tables using a sample dataset.
|
||||
<br />
|
||||
<br />
|
||||
When not in the quick start guide, connecting to Azure Cosmos DB for MongoDB vCore is straightforward
|
||||
using your connection string.
|
||||
To start, input the admin password you used during the cluster creation process into the MongoDB vCore
|
||||
terminal.
|
||||
<br />
|
||||
<br />
|
||||
<Link
|
||||
aria-label="View connection string"
|
||||
href=""
|
||||
onClick={() => sendMessage({ type: MessageTypes.OpenVCoreMongoConnectionStringsBlade })}
|
||||
>
|
||||
View connection string
|
||||
</Link>
|
||||
<br />
|
||||
<br />
|
||||
This string contains placeholders for <user> and <password>. Replace them with your chosen
|
||||
username and password to establish a secure connection to your cluster. Depending on your environment,
|
||||
you may need to adjust firewall rules or configure private endpoints in the ‘Networking’
|
||||
tab of your database settings, or modify your own network's firewall settings, to successfully
|
||||
connect.
|
||||
Note: If you navigate out of the Quick start blade (MongoDB vCore Shell), the session will be
|
||||
closed and all ongoing commands might be interrupted.
|
||||
</Text>
|
||||
</Stack>
|
||||
</PivotItem>
|
||||
@@ -103,6 +93,7 @@ export const VcoreMongoQuickstartGuide: React.FC = (): JSX.Element => {
|
||||
documents are similar to the columns in a relational database table. One key advantage of MongoDB is
|
||||
that these documents within a collection can have different fields.
|
||||
<br />
|
||||
<br />
|
||||
You're now going to create a new database and a collection within that database using the Mongo
|
||||
shell. In MongoDB, creating a database or a collection is implicit. This means that databases and
|
||||
collections are created when you first reference them in a command, so no explicit creation command is
|
||||
@@ -153,14 +144,14 @@ export const VcoreMongoQuickstartGuide: React.FC = (): JSX.Element => {
|
||||
with data. In MongoDB, data is stored as documents, which are structured as field and value pairs.
|
||||
<br />
|
||||
<br />
|
||||
Let's populate your sampleCollection with data. We'll add 10 documents representing books,
|
||||
each with a title, author, and number of pages, to your sampleCollection in the quickstartDB database.
|
||||
We'll add 10 documents representing books, each with a title, author, and number of pages, to
|
||||
your sampleCollection in the quickstartDB database.
|
||||
</Text>
|
||||
<DefaultButton
|
||||
style={{ marginTop: 16, width: 200 }}
|
||||
onClick={() => useTerminal.getState().sendMessage(loadDataCommand)}
|
||||
>
|
||||
Create distributed table
|
||||
Load data
|
||||
</DefaultButton>
|
||||
<Stack horizontal style={{ marginTop: 16 }}>
|
||||
<TextField
|
||||
@@ -197,23 +188,23 @@ export const VcoreMongoQuickstartGuide: React.FC = (): JSX.Element => {
|
||||
>
|
||||
<Stack style={{ marginTop: 20 }}>
|
||||
<Text>
|
||||
Once you’ve inserted data into your sampleCollection, you can retrieve it using queries. MongoDB
|
||||
Once you've inserted data into your sampleCollection, you can retrieve it using queries. MongoDB
|
||||
queries can be as simple or as complex as you need them to be, allowing you to filter, sort, and limit
|
||||
results.
|
||||
</Text>
|
||||
<DefaultButton
|
||||
style={{ marginTop: 16, width: 110 }}
|
||||
onClick={() => useTerminal.getState().sendMessage(queriesCommand)}
|
||||
onClick={() => useTerminal.getState().sendMessage(findOrwellCommand)}
|
||||
>
|
||||
Load data
|
||||
Try query
|
||||
</DefaultButton>
|
||||
<Stack horizontal style={{ marginTop: 16 }}>
|
||||
<TextField
|
||||
id="queriesCommand"
|
||||
id="findOrwellCommand"
|
||||
multiline
|
||||
rows={5}
|
||||
rows={2}
|
||||
readOnly
|
||||
defaultValue={queriesCommandForDisplay}
|
||||
defaultValue={findOrwellCommandForDisplay}
|
||||
styles={{
|
||||
root: { width: "90%" },
|
||||
field: {
|
||||
@@ -227,13 +218,71 @@ export const VcoreMongoQuickstartGuide: React.FC = (): JSX.Element => {
|
||||
iconProps={{
|
||||
iconName: "Copy",
|
||||
}}
|
||||
onClick={() => onCopyBtnClicked("#queriesCommand")}
|
||||
onClick={() => onCopyBtnClicked("#findOrwellCommand")}
|
||||
/>
|
||||
</Stack>
|
||||
<DefaultButton
|
||||
style={{ marginTop: 32, width: 110 }}
|
||||
onClick={() => useTerminal.getState().sendMessage(findByPagesCommand)}
|
||||
>
|
||||
Try query
|
||||
</DefaultButton>
|
||||
<Stack horizontal style={{ marginTop: 16 }}>
|
||||
<TextField
|
||||
id="findByPagesCommand"
|
||||
multiline
|
||||
rows={2}
|
||||
readOnly
|
||||
defaultValue={findByPagesCommandForDisplay}
|
||||
styles={{
|
||||
root: { width: "90%" },
|
||||
field: {
|
||||
backgroundColor: "#EEEEEE",
|
||||
fontFamily:
|
||||
"Consolas,Monaco,Lucida Console,Liberation Mono,DejaVu Sans Mono,Bitstream Vera Sans Mono,Courier New",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<IconButton
|
||||
iconProps={{
|
||||
iconName: "Copy",
|
||||
}}
|
||||
onClick={() => onCopyBtnClicked("#findByPagesCommand")}
|
||||
/>
|
||||
</Stack>
|
||||
<DefaultButton
|
||||
style={{ marginTop: 32, width: 110 }}
|
||||
onClick={() => useTerminal.getState().sendMessage(findAndSortCommand)}
|
||||
>
|
||||
Try query
|
||||
</DefaultButton>
|
||||
<Stack horizontal style={{ marginTop: 16 }}>
|
||||
<TextField
|
||||
id="findAndSortCommand"
|
||||
multiline
|
||||
rows={2}
|
||||
readOnly
|
||||
defaultValue={findAndSortCommandForDisplay}
|
||||
styles={{
|
||||
root: { width: "90%" },
|
||||
field: {
|
||||
backgroundColor: "#EEEEEE",
|
||||
fontFamily:
|
||||
"Consolas,Monaco,Lucida Console,Liberation Mono,DejaVu Sans Mono,Bitstream Vera Sans Mono,Courier New",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<IconButton
|
||||
iconProps={{
|
||||
iconName: "Copy",
|
||||
}}
|
||||
onClick={() => onCopyBtnClicked("#findAndSortCommand")}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</PivotItem>
|
||||
<PivotItem
|
||||
headerText="Integrations"
|
||||
headerText="Next steps"
|
||||
onRenderItemLink={(props, defaultRenderer) =>
|
||||
customPivotHeaderRenderer(props, defaultRenderer, currentStep, 4)
|
||||
}
|
||||
@@ -242,46 +291,18 @@ export const VcoreMongoQuickstartGuide: React.FC = (): JSX.Element => {
|
||||
>
|
||||
<Stack>
|
||||
<Text>
|
||||
Cosmos DB for MongoDB vCore seamlessly integrates with Azure services. These integrations enable
|
||||
Cosmos DB for MongoDB and its partner products to directly interoperate, ensuring a smooth and unified
|
||||
experience, that just works.
|
||||
<b>Migrate existing data</b>
|
||||
<br />
|
||||
<br />
|
||||
Modernize your data seamlessly from an existing MongoDB cluster, whether it's on-premises or
|
||||
hosted in the cloud, to Azure Cosmos DB for MongoDB vCore.
|
||||
<Link
|
||||
target="_blank"
|
||||
href="https://learn.microsoft.com/azure/cosmos-db/mongodb/vcore/migration-options"
|
||||
>
|
||||
Learn more
|
||||
</Link>
|
||||
</Text>
|
||||
<Stack horizontal>
|
||||
<Stack style={{ marginTop: 20, marginRight: 20 }}>
|
||||
<Text>
|
||||
<b>First party integrations</b>
|
||||
<br />
|
||||
<br />
|
||||
<b>Azure Monitor</b>
|
||||
<br />
|
||||
Azure monitor provides comprehensive monitoring and diagnostics for Cosmos DB for Mongo DB. Learn
|
||||
more
|
||||
<br />
|
||||
<br />
|
||||
<b>Azure Networking</b>
|
||||
<br />
|
||||
Azure Networking seamlessly integrates with Azure Cosmos DB for Mongo DB for fast and secure data
|
||||
access. Learn more
|
||||
<br />
|
||||
<br />
|
||||
<b>PowerShell/CLI/ARM</b>
|
||||
<br />
|
||||
PowerShell/CLI/ARM seamlessly integrates with Azure Cosmos DB for Mongo DB for efficient
|
||||
management and automation. Learn more
|
||||
</Text>
|
||||
</Stack>
|
||||
<Stack style={{ marginTop: 20, marginLeft: 20 }}>
|
||||
<Text>
|
||||
<b>Application platforms integrations</b>
|
||||
<br />
|
||||
<br />
|
||||
<b>Vercel</b>
|
||||
<br />
|
||||
Vercel is a cloud platform for hosting static front ends and serverless functions, with instant
|
||||
deployments, automated scaling, and Next.js integration. Learn more
|
||||
</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</PivotItem>
|
||||
</Pivot>
|
||||
|
||||
@@ -147,7 +147,7 @@ export class CassandraAPIDataClient extends TableDataClient {
|
||||
let properties = "(";
|
||||
let values = "(";
|
||||
for (let property in entity) {
|
||||
if (entity[property]._ === null) {
|
||||
if (entity[property]._ === "" || entity[property]._ === undefined) {
|
||||
continue;
|
||||
}
|
||||
properties = properties.concat(`${property}, `);
|
||||
@@ -208,6 +208,9 @@ export class CassandraAPIDataClient extends TableDataClient {
|
||||
!originalDocument[property] ||
|
||||
newEntity[property]._.toString() !== originalDocument[property]._.toString()
|
||||
) {
|
||||
if (newEntity[property]._.toString() === "" || newEntity[property]._ === undefined) {
|
||||
continue;
|
||||
}
|
||||
let propertyQuerySegment = this.isStringType(newEntity[property].$)
|
||||
? `${property} = '${newEntity[property]._}',`
|
||||
: `${property} = ${newEntity[property]._},`;
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useDatabases } from "Explorer/useDatabases";
|
||||
import { useTabs } from "hooks/useTabs";
|
||||
import CosmosDBIcon from "../../../images/Azure-Cosmos-DB.svg";
|
||||
import * as ViewModels from "../../Contracts/ViewModels";
|
||||
import * as ResourceTreeContextMenuButtonFactory from "../ContextMenuButtonFactory";
|
||||
import Explorer from "../Explorer";
|
||||
import { useCommandBar } from "../Menus/CommandBar/CommandBarComponentAdapter";
|
||||
import { useSelectedNode } from "../useSelectedNode";
|
||||
@@ -22,7 +21,7 @@ export const useDatabaseTreeNodes = (container: Explorer, isNotebookEnabled: boo
|
||||
className: "databaseHeader",
|
||||
children: [],
|
||||
isSelected: () => useSelectedNode.getState().isDataNodeSelected(database.id()),
|
||||
contextMenu: ResourceTreeContextMenuButtonFactory.createDatabaseContextMenu(container, database.id()),
|
||||
contextMenu: undefined, // TODO Disable this for now as the actions don't work. ResourceTreeContextMenuButtonFactory.createDatabaseContextMenu(container, database.id()),
|
||||
onExpanded: async () => {
|
||||
useSelectedNode.getState().setSelectedNode(database);
|
||||
if (!databaseNode.children || databaseNode.children?.length === 0) {
|
||||
|
||||
@@ -7,9 +7,6 @@ import "../less/hostedexplorer.less";
|
||||
import { AuthType } from "./AuthType";
|
||||
import { DatabaseAccount } from "./Contracts/DataModels";
|
||||
import "./Explorer/Menus/NavBar/MeControlComponent.less";
|
||||
import { useAADAuth } from "./hooks/useAADAuth";
|
||||
import { useConfig } from "./hooks/useConfig";
|
||||
import { useTokenMetadata } from "./hooks/usePortalAccessToken";
|
||||
import { HostedExplorerChildFrame } from "./HostedExplorerChildFrame";
|
||||
import { AccountSwitcher } from "./Platform/Hosted/Components/AccountSwitcher";
|
||||
import { ConnectExplorer } from "./Platform/Hosted/Components/ConnectExplorer";
|
||||
@@ -20,6 +17,9 @@ import { SignInButton } from "./Platform/Hosted/Components/SignInButton";
|
||||
import "./Platform/Hosted/ConnectScreen.less";
|
||||
import { extractMasterKeyfromConnectionString } from "./Platform/Hosted/HostedUtils";
|
||||
import "./Shared/appInsights";
|
||||
import { useAADAuth } from "./hooks/useAADAuth";
|
||||
import { useConfig } from "./hooks/useConfig";
|
||||
import { useTokenMetadata } from "./hooks/usePortalAccessToken";
|
||||
|
||||
initializeIcons();
|
||||
|
||||
|
||||
40
src/Main.tsx
40
src/Main.tsx
@@ -1,13 +1,13 @@
|
||||
// CSS Dependencies
|
||||
import { initializeIcons, loadTheme } from "@fluentui/react";
|
||||
import "bootstrap/dist/css/bootstrap.css";
|
||||
import { QuickstartCarousel } from "Explorer/Quickstart/QuickstartCarousel";
|
||||
import { MongoQuickstartTutorial } from "Explorer/Quickstart/Tutorials/MongoQuickstartTutorial";
|
||||
import { SQLQuickstartTutorial } from "Explorer/Quickstart/Tutorials/SQLQuickstartTutorial";
|
||||
import { userContext } from "UserContext";
|
||||
import "bootstrap/dist/css/bootstrap.css";
|
||||
import { useCarousel } from "hooks/useCarousel";
|
||||
import React, { useState } from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
import { userContext } from "UserContext";
|
||||
import "../externals/jquery-ui.min.css";
|
||||
import "../externals/jquery-ui.min.js";
|
||||
import "../externals/jquery-ui.structure.min.css";
|
||||
@@ -16,27 +16,27 @@ import "../externals/jquery.dataTables.min.css";
|
||||
import "../externals/jquery.typeahead.min.css";
|
||||
import "../externals/jquery.typeahead.min.js";
|
||||
// Image Dependencies
|
||||
import { Platform } from "ConfigContext";
|
||||
import { QueryCopilotCarousel } from "Explorer/QueryCopilot/CopilotCarousel";
|
||||
import { QueryCopilotFeedbackModal } from "Explorer/QueryCopilot/Modal/QueryCopilotFeedbackModal";
|
||||
import { useQueryCopilot } from "hooks/useQueryCopilot";
|
||||
import "../images/CosmosDB_rgb_ui_lighttheme.ico";
|
||||
import hdeConnectImage from "../images/HdeConnectCosmosDB.svg";
|
||||
import "../images/favicon.ico";
|
||||
import "../less/TableStyles/CustomizeColumns.less";
|
||||
import "../less/TableStyles/EntityEditor.less";
|
||||
import "../less/TableStyles/fulldatatables.less";
|
||||
import "../less/TableStyles/queryBuilder.less";
|
||||
import * as StyleConstants from "./Common/StyleConstants";
|
||||
import { configContext, Platform } from "ConfigContext";
|
||||
import hdeConnectImage from "../images/HdeConnectCosmosDB.svg";
|
||||
import "../less/documentDB.less";
|
||||
import "../less/forms.less";
|
||||
import "../less/infobox.less";
|
||||
import "../less/menus.less";
|
||||
import "../less/messagebox.less";
|
||||
import "../less/resourceTree.less";
|
||||
import "../less/TableStyles/CustomizeColumns.less";
|
||||
import "../less/TableStyles/EntityEditor.less";
|
||||
import "../less/TableStyles/fulldatatables.less";
|
||||
import "../less/TableStyles/queryBuilder.less";
|
||||
import "../less/tree.less";
|
||||
import { CollapsedResourceTree } from "./Common/CollapsedResourceTree";
|
||||
import { ResourceTreeContainer } from "./Common/ResourceTreeContainer";
|
||||
import * as StyleConstants from "./Common/StyleConstants";
|
||||
import "./Explorer/Controls/Accordion/AccordionComponent.less";
|
||||
import "./Explorer/Controls/CollapsiblePanel/CollapsiblePanelComponent.less";
|
||||
import { Dialog } from "./Explorer/Controls/Dialog";
|
||||
@@ -55,11 +55,11 @@ import "./Explorer/Panes/PanelComponent.less";
|
||||
import { SidePanel } from "./Explorer/Panes/PanelContainerComponent";
|
||||
import "./Explorer/SplashScreen/SplashScreen.less";
|
||||
import { Tabs } from "./Explorer/Tabs/Tabs";
|
||||
import "./Libs/jquery";
|
||||
import "./Shared/appInsights";
|
||||
import { useConfig } from "./hooks/useConfig";
|
||||
import { useKnockoutExplorer } from "./hooks/useKnockoutExplorer";
|
||||
import "./Libs/jquery";
|
||||
import { appThemeFabric } from "./Platform/Fabric/FabricTheme";
|
||||
import "./Shared/appInsights";
|
||||
|
||||
initializeIcons();
|
||||
|
||||
@@ -72,6 +72,7 @@ const App: React.FunctionComponent = () => {
|
||||
const config = useConfig();
|
||||
if (config?.platform === Platform.Fabric) {
|
||||
loadTheme(appThemeFabric);
|
||||
import("../less/documentDBFabric.less");
|
||||
}
|
||||
StyleConstants.updateStyles();
|
||||
const explorer = useKnockoutExplorer(config?.platform);
|
||||
@@ -91,7 +92,6 @@ const App: React.FunctionComponent = () => {
|
||||
|
||||
return (
|
||||
<div className="flexContainer" aria-hidden="false">
|
||||
<LoadFabricOverrides />
|
||||
<div id="divExplorer" className="flexContainer hideOverflows">
|
||||
<div id="freeTierTeachingBubble"> </div>
|
||||
{/* Main Command Bar - Start */}
|
||||
@@ -141,20 +141,8 @@ const App: React.FunctionComponent = () => {
|
||||
);
|
||||
};
|
||||
|
||||
ReactDOM.render(<App />, document.body);
|
||||
|
||||
function LoadFabricOverrides(): JSX.Element {
|
||||
if (configContext.platform === Platform.Fabric) {
|
||||
const FabricStyle = React.lazy(() => import("./Platform/Fabric/FabricPlatform"));
|
||||
return (
|
||||
<React.Suspense fallback={<div></div>}>
|
||||
<FabricStyle />
|
||||
</React.Suspense>
|
||||
);
|
||||
} else {
|
||||
return <></>;
|
||||
}
|
||||
}
|
||||
const mainElement = document.getElementById("Main");
|
||||
ReactDOM.render(<App />, mainElement);
|
||||
|
||||
function LoadingExplorer(): JSX.Element {
|
||||
return (
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
import React from "react";
|
||||
import "../../../less/documentDBFabric.less";
|
||||
// This is a dummy export, allowing us to conditionally import documentDBFabric.less
|
||||
// by lazy-importing this in Main.tsx (see LoadFabricOverrides() there)
|
||||
export default function InitFabric() {
|
||||
return <></>;
|
||||
}
|
||||
53
src/Platform/Fabric/FabricUtil.ts
Normal file
53
src/Platform/Fabric/FabricUtil.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { sendCachedDataMessage } from "Common/MessageHandler";
|
||||
import { FabricDatabaseConnectionInfo } from "Contracts/FabricContract";
|
||||
import { MessageTypes } from "Contracts/MessageTypes";
|
||||
import Explorer from "Explorer/Explorer";
|
||||
import { updateUserContext } from "UserContext";
|
||||
|
||||
const TOKEN_VALIDITY_MS = (3600 - 600) * 1000; // 1 hour minus 10 minutes to be safe
|
||||
let timeoutId: NodeJS.Timeout;
|
||||
|
||||
// Prevents multiple parallel requests
|
||||
let isRequestPending = false;
|
||||
|
||||
export const requestDatabaseResourceTokens = (): void => {
|
||||
if (isRequestPending) {
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO Make Fabric return the message id so we can handle this promise
|
||||
isRequestPending = true;
|
||||
sendCachedDataMessage<FabricDatabaseConnectionInfo>(MessageTypes.GetAllResourceTokens, []);
|
||||
};
|
||||
|
||||
export const handleRequestDatabaseResourceTokensResponse = (
|
||||
explorer: Explorer,
|
||||
fabricDatabaseConnectionInfo: FabricDatabaseConnectionInfo,
|
||||
): void => {
|
||||
isRequestPending = false;
|
||||
updateUserContext({ fabricDatabaseConnectionInfo });
|
||||
scheduleRefreshDatabaseResourceToken();
|
||||
explorer.refreshAllDatabases();
|
||||
};
|
||||
|
||||
/**
|
||||
* Check token validity and schedule a refresh if necessary
|
||||
* @param tokenTimestamp
|
||||
* @returns
|
||||
*/
|
||||
export const scheduleRefreshDatabaseResourceToken = (): void => {
|
||||
if (timeoutId !== undefined) {
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = undefined;
|
||||
}
|
||||
|
||||
timeoutId = setTimeout(() => {
|
||||
requestDatabaseResourceTokens();
|
||||
}, TOKEN_VALIDITY_MS);
|
||||
};
|
||||
|
||||
export const checkDatabaseResourceTokensValidity = (tokenTimestamp: number): void => {
|
||||
if (tokenTimestamp + TOKEN_VALIDITY_MS < Date.now()) {
|
||||
requestDatabaseResourceTokens();
|
||||
}
|
||||
};
|
||||
@@ -30,6 +30,18 @@ export const fetchEncryptedToken = async (connectionString: string): Promise<str
|
||||
return decodeURIComponent(result.readWrite || result.read);
|
||||
};
|
||||
|
||||
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 response = await fetch(url, { headers, method: "POST" });
|
||||
if (!response.ok) {
|
||||
throw response;
|
||||
}
|
||||
|
||||
return (await response.text()) === "True";
|
||||
};
|
||||
|
||||
export const ConnectExplorer: React.FunctionComponent<Props> = ({
|
||||
setEncryptedToken,
|
||||
login,
|
||||
@@ -38,6 +50,7 @@ export const ConnectExplorer: React.FunctionComponent<Props> = ({
|
||||
setConnectionString,
|
||||
}: Props) => {
|
||||
const [isFormVisible, { setTrue: showForm }] = useBoolean(false);
|
||||
const [errorMessage, setErrorMessage] = React.useState("");
|
||||
const enableConnectionStringLogin = !userContext.features.disableConnectionStringLogin;
|
||||
|
||||
return (
|
||||
@@ -53,6 +66,12 @@ export const ConnectExplorer: React.FunctionComponent<Props> = ({
|
||||
id="connectWithConnectionString"
|
||||
onSubmit={async (event) => {
|
||||
event.preventDefault();
|
||||
setErrorMessage("");
|
||||
|
||||
if (await isAccountRestrictedForConnectionStringLogin(connectionString)) {
|
||||
setErrorMessage("This account has been blocked from connection-string login.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (isResourceTokenConnectionString(connectionString)) {
|
||||
setAuthType(AuthType.ResourceToken);
|
||||
@@ -76,10 +95,12 @@ export const ConnectExplorer: React.FunctionComponent<Props> = ({
|
||||
setConnectionString(event.target.value);
|
||||
}}
|
||||
/>
|
||||
<span className="errorDetailsInfoTooltip" style={{ display: "none" }}>
|
||||
<img className="errorImg" src={ErrorImage} alt="Error notification" />
|
||||
<span className="errorDetails"></span>
|
||||
</span>
|
||||
{errorMessage.length > 0 && (
|
||||
<span className="errorDetailsInfoTooltip">
|
||||
<img className="errorImg" src={ErrorImage} alt="Error notification" />
|
||||
<span className="errorDetails">{errorMessage}</span>
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="connectExplorerContent">
|
||||
<input className="filterbtnstyle" type="submit" value="Connect" />
|
||||
|
||||
@@ -14,6 +14,7 @@ export type Features = {
|
||||
readonly enableTtl: boolean;
|
||||
readonly executeSproc: boolean;
|
||||
readonly enableAadDataPlane: boolean;
|
||||
readonly enableResourceGraph: boolean;
|
||||
readonly enableKoResourceTree: boolean;
|
||||
readonly hostedDataExplorer: boolean;
|
||||
readonly junoEndpoint?: string;
|
||||
@@ -73,6 +74,7 @@ export function extractFeatures(given = new URLSearchParams(window.location.sear
|
||||
canExceedMaximumValue: "true" === get("canexceedmaximumvalue"),
|
||||
cosmosdb: "true" === get("cosmosdb"),
|
||||
enableAadDataPlane: "true" === get("enableaaddataplane"),
|
||||
enableResourceGraph: "true" === get("enableresourcegraph"),
|
||||
enableChangeFeedPolicy: "true" === get("enablechangefeedpolicy"),
|
||||
enableFixedCollectionWithSharedThroughput: "true" === get("enablefixedcollectionwithsharedthroughput"),
|
||||
enableKOPanel: "true" === get("enablekopanel"),
|
||||
|
||||
@@ -10,9 +10,13 @@ import { userContext } from "UserContext";
|
||||
export class JupyterLabAppFactory {
|
||||
private isShellStarted: boolean | undefined;
|
||||
private checkShellStarted: ((content: string | undefined) => void) | undefined;
|
||||
private onShellExited: () => void;
|
||||
private onShellExited: (restartShell: boolean) => void;
|
||||
private restartShell: boolean;
|
||||
|
||||
private isShellExited(content: string | undefined) {
|
||||
if (userContext.apiType === "VCoreMongo" && content?.includes("MongoServerError: Invalid key")) {
|
||||
this.restartShell = true;
|
||||
}
|
||||
return content?.includes("cosmosuser@");
|
||||
}
|
||||
|
||||
@@ -32,10 +36,11 @@ export class JupyterLabAppFactory {
|
||||
this.isShellStarted = content?.includes("Enter password");
|
||||
}
|
||||
|
||||
constructor(closeTab: () => void) {
|
||||
constructor(closeTab: (restartShell: boolean) => void) {
|
||||
this.onShellExited = closeTab;
|
||||
this.isShellStarted = false;
|
||||
this.checkShellStarted = undefined;
|
||||
this.restartShell = false;
|
||||
|
||||
switch (userContext.apiType) {
|
||||
case "Mongo":
|
||||
@@ -69,7 +74,7 @@ export class JupyterLabAppFactory {
|
||||
if (!this.isShellStarted) {
|
||||
this.checkShellStarted(content);
|
||||
} else if (this.isShellExited(content)) {
|
||||
this.onShellExited();
|
||||
this.onShellExited(this.restartShell);
|
||||
}
|
||||
}
|
||||
}, this);
|
||||
|
||||
@@ -11,6 +11,8 @@ import { JupyterLabAppFactory } from "./JupyterLabAppFactory";
|
||||
import { TerminalProps } from "./TerminalProps";
|
||||
import "./index.css";
|
||||
|
||||
let session: ITerminalConnection | undefined;
|
||||
|
||||
const createServerSettings = (props: TerminalProps): ServerConnection.ISettings => {
|
||||
let body: BodyInit | undefined;
|
||||
let headers: HeadersInit | undefined;
|
||||
@@ -49,7 +51,7 @@ const createServerSettings = (props: TerminalProps): ServerConnection.ISettings
|
||||
return ServerConnection.makeSettings(options);
|
||||
};
|
||||
|
||||
const initTerminal = async (props: TerminalProps): Promise<ITerminalConnection | undefined> => {
|
||||
const initTerminal = async (props: TerminalProps): Promise<void> => {
|
||||
// Initialize userContext (only properties which are needed by TelemetryProcessor)
|
||||
updateUserContext({
|
||||
subscriptionId: props.subscriptionId,
|
||||
@@ -59,28 +61,37 @@ const initTerminal = async (props: TerminalProps): Promise<ITerminalConnection |
|
||||
});
|
||||
|
||||
const serverSettings = createServerSettings(props);
|
||||
|
||||
createTerminalApp(props, serverSettings);
|
||||
};
|
||||
|
||||
const createTerminalApp = async (props: TerminalProps, serverSettings: ServerConnection.ISettings) => {
|
||||
const data = { baseUrl: serverSettings.baseUrl };
|
||||
const startTime = TelemetryProcessor.traceStart(Action.OpenTerminal, data);
|
||||
|
||||
try {
|
||||
const session = await new JupyterLabAppFactory(() => closeTab(props.tabId)).createTerminalApp(serverSettings);
|
||||
session = await new JupyterLabAppFactory((restartShell: boolean) =>
|
||||
closeTab(props, serverSettings, restartShell),
|
||||
).createTerminalApp(serverSettings);
|
||||
TelemetryProcessor.traceSuccess(Action.OpenTerminal, data, startTime);
|
||||
return session;
|
||||
} catch (error) {
|
||||
TelemetryProcessor.traceFailure(Action.OpenTerminal, data, startTime);
|
||||
return undefined;
|
||||
session = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const closeTab = (tabId: string): void => {
|
||||
window.parent.postMessage(
|
||||
{ type: MessageTypes.CloseTab, data: { tabId: tabId }, signature: "pcIframe" },
|
||||
window.document.referrer,
|
||||
);
|
||||
const closeTab = (props: TerminalProps, serverSettings: ServerConnection.ISettings, restartShell: boolean): void => {
|
||||
if (restartShell) {
|
||||
createTerminalApp(props, serverSettings);
|
||||
} else {
|
||||
window.parent.postMessage(
|
||||
{ type: MessageTypes.CloseTab, data: { tabId: props.tabId }, signature: "pcIframe" },
|
||||
window.document.referrer,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const main = async (): Promise<void> => {
|
||||
let session: ITerminalConnection | undefined;
|
||||
postRobot.on(
|
||||
"props",
|
||||
{
|
||||
@@ -91,7 +102,7 @@ const main = async (): Promise<void> => {
|
||||
// Typescript definition for event is wrong. So read props by casting to <any>
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const props = (event as any).data as TerminalProps;
|
||||
session = await initTerminal(props);
|
||||
await initTerminal(props);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { FabricDatabaseConnectionInfo } from "Contracts/FabricContract";
|
||||
import { ParsedResourceTokenConnectionString } from "Platform/Hosted/Helpers/ResourceTokenUtils";
|
||||
import { Action } from "Shared/Telemetry/TelemetryConstants";
|
||||
import { traceOpen } from "Shared/Telemetry/TelemetryProcessor";
|
||||
@@ -47,6 +48,7 @@ export interface VCoreMongoConnectionParams {
|
||||
}
|
||||
|
||||
interface UserContext {
|
||||
readonly fabricDatabaseConnectionInfo?: FabricDatabaseConnectionInfo;
|
||||
readonly authType?: AuthType;
|
||||
readonly masterKey?: string;
|
||||
readonly subscriptionId?: string;
|
||||
|
||||
@@ -8,5 +8,7 @@
|
||||
<link rel="shortcut icon" href="/favicon.ico" type="image/x-icon" />
|
||||
</head>
|
||||
|
||||
<body></body>
|
||||
<body>
|
||||
<div id="Main" style="height: 100%"></div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { HttpHeaders } from "Common/Constants";
|
||||
import { QueryRequestOptions, QueryResponse } from "Contracts/AzureResourceGraph";
|
||||
import useSWR from "swr";
|
||||
import { configContext } from "../ConfigContext";
|
||||
import { DatabaseAccount } from "../Contracts/DataModels";
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
interface AccountListResult {
|
||||
nextLink: string;
|
||||
@@ -30,10 +33,56 @@ export async function fetchDatabaseAccounts(subscriptionId: string, accessToken:
|
||||
return accounts.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
export async function fetchDatabaseAccountsFromGraph(
|
||||
subscriptionId: string,
|
||||
accessToken: string,
|
||||
): Promise<DatabaseAccount[]> {
|
||||
const headers = new Headers();
|
||||
const bearer = `Bearer ${accessToken}`;
|
||||
|
||||
headers.append("Authorization", bearer);
|
||||
headers.append(HttpHeaders.contentType, "application/json");
|
||||
const databaseAccountsQuery = "resources | where type =~ 'microsoft.documentdb/databaseaccounts'";
|
||||
const apiVersion = "2021-03-01";
|
||||
const managementResourceGraphAPIURL = `${configContext.ARM_ENDPOINT}providers/Microsoft.ResourceGraph/resources?api-version=${apiVersion}`;
|
||||
|
||||
const databaseAccounts: DatabaseAccount[] = [];
|
||||
let skipToken: string;
|
||||
do {
|
||||
const body = {
|
||||
query: databaseAccountsQuery,
|
||||
subscriptions: [subscriptionId],
|
||||
...(skipToken && {
|
||||
options: {
|
||||
$skipToken: skipToken,
|
||||
} as QueryRequestOptions,
|
||||
}),
|
||||
};
|
||||
|
||||
const response = await fetch(managementResourceGraphAPIURL, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await response.text());
|
||||
}
|
||||
|
||||
const queryResponse: QueryResponse = (await response.json()) as QueryResponse;
|
||||
skipToken = queryResponse.$skipToken;
|
||||
queryResponse.data?.map((databaseAccount: any) => {
|
||||
databaseAccounts.push(databaseAccount as DatabaseAccount);
|
||||
});
|
||||
} while (skipToken);
|
||||
|
||||
return databaseAccounts.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
export function useDatabaseAccounts(subscriptionId: string, armToken: string): DatabaseAccount[] | undefined {
|
||||
const { data } = useSWR(
|
||||
() => (armToken && subscriptionId ? ["databaseAccounts", subscriptionId, armToken] : undefined),
|
||||
(_, subscriptionId, armToken) => fetchDatabaseAccounts(subscriptionId, armToken),
|
||||
(_, subscriptionId, armToken) => fetchDatabaseAccountsFromGraph(subscriptionId, armToken),
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { createUri } from "Common/UrlUtility";
|
||||
import { FabricMessage } from "Contracts/FabricContract";
|
||||
import { FabricDatabaseConnectionInfo, FabricMessage } from "Contracts/FabricContract";
|
||||
import Explorer from "Explorer/Explorer";
|
||||
import { useSelectedNode } from "Explorer/useSelectedNode";
|
||||
import {
|
||||
handleRequestDatabaseResourceTokensResponse,
|
||||
scheduleRefreshDatabaseResourceToken,
|
||||
} from "Platform/Fabric/FabricUtil";
|
||||
import { getNetworkSettingsWarningMessage } from "Utils/NetworkUtility";
|
||||
import { ReactTabKind, useTabs } from "hooks/useTabs";
|
||||
import { useEffect, useState } from "react";
|
||||
@@ -98,60 +102,39 @@ async function configureFabric(): Promise<Explorer> {
|
||||
}
|
||||
|
||||
const data: FabricMessage = event.data?.data;
|
||||
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (data.type) {
|
||||
case "initialize": {
|
||||
explorer = await configureWithFabric(data.message.endpoint);
|
||||
const fabricDatabaseConnectionInfo: FabricDatabaseConnectionInfo = {
|
||||
endpoint: data.message.endpoint,
|
||||
databaseId: data.message.databaseId,
|
||||
resourceTokens: data.message.resourceTokens as { [resourceId: string]: string },
|
||||
resourceTokensTimestamp: data.message.resourceTokensTimestamp,
|
||||
};
|
||||
explorer = await createExplorerFabric(fabricDatabaseConnectionInfo);
|
||||
resolve(explorer);
|
||||
|
||||
explorer.refreshAllDatabases().then(() => {
|
||||
openFirstContainer(explorer, fabricDatabaseConnectionInfo.databaseId);
|
||||
});
|
||||
scheduleRefreshDatabaseResourceToken();
|
||||
break;
|
||||
}
|
||||
case "newContainer":
|
||||
explorer.onNewCollectionClicked();
|
||||
break;
|
||||
case "openTab": {
|
||||
// Expand database first
|
||||
const databaseName = sessionStorage.getItem("openDatabaseName") ?? data.databaseName;
|
||||
const database = useDatabases.getState().databases.find((db) => db.id() === databaseName);
|
||||
if (database) {
|
||||
await database.expandDatabase();
|
||||
useDatabases.getState().updateDatabase(database);
|
||||
useSelectedNode.getState().setSelectedNode(database);
|
||||
|
||||
let collectionResourceId = data.collectionName;
|
||||
if (collectionResourceId === undefined) {
|
||||
// Pick first collection if collectionName not specified in message
|
||||
collectionResourceId = database.collections()[0]?.id();
|
||||
}
|
||||
|
||||
if (collectionResourceId !== undefined) {
|
||||
// Expand collection
|
||||
const collection = database.collections().find((coll) => coll.id() === collectionResourceId);
|
||||
collection.expandCollection();
|
||||
useSelectedNode.getState().setSelectedNode(collection);
|
||||
|
||||
handleOpenAction(
|
||||
{
|
||||
actionType: ActionType.OpenCollectionTab,
|
||||
databaseResourceId: databaseName,
|
||||
collectionResourceId: data.collectionName,
|
||||
tabKind: TabKind.SQLDocuments,
|
||||
} as DataExplorerAction,
|
||||
useDatabases.getState().databases,
|
||||
explorer,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case "authorizationToken": {
|
||||
handleCachedDataMessage(data);
|
||||
break;
|
||||
}
|
||||
case "allResourceTokens": {
|
||||
// TODO call handleCachedDataMessage when Fabric echoes message id back
|
||||
handleRequestDatabaseResourceTokensResponse(explorer, data.message as FabricDatabaseConnectionInfo);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
console.error(`Unknown Fabric message type: ${JSON.stringify(data)}`);
|
||||
break;
|
||||
@@ -164,6 +147,41 @@ async function configureFabric(): Promise<Explorer> {
|
||||
});
|
||||
}
|
||||
|
||||
const openFirstContainer = async (explorer: Explorer, databaseName: string, collectionName?: string) => {
|
||||
// Expand database first
|
||||
databaseName = sessionStorage.getItem("openDatabaseName") ?? databaseName;
|
||||
const database = useDatabases.getState().databases.find((db) => db.id() === databaseName);
|
||||
if (database) {
|
||||
await database.expandDatabase();
|
||||
useDatabases.getState().updateDatabase(database);
|
||||
useSelectedNode.getState().setSelectedNode(database);
|
||||
|
||||
let collectionResourceId = collectionName;
|
||||
if (collectionResourceId === undefined) {
|
||||
// Pick first collection if collectionName not specified in message
|
||||
collectionResourceId = database.collections()[0]?.id();
|
||||
}
|
||||
|
||||
if (collectionResourceId !== undefined) {
|
||||
// Expand collection
|
||||
const collection = database.collections().find((coll) => coll.id() === collectionResourceId);
|
||||
collection.expandCollection();
|
||||
useSelectedNode.getState().setSelectedNode(collection);
|
||||
|
||||
handleOpenAction(
|
||||
{
|
||||
actionType: ActionType.OpenCollectionTab,
|
||||
databaseResourceId: databaseName,
|
||||
collectionResourceId: collectionName,
|
||||
tabKind: TabKind.SQLDocuments,
|
||||
} as DataExplorerAction,
|
||||
useDatabases.getState().databases,
|
||||
explorer,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
async function configureHosted(): Promise<Explorer> {
|
||||
const win = window as unknown as HostedExplorerChildFrame;
|
||||
let explorer: Explorer;
|
||||
@@ -301,8 +319,9 @@ function configureHostedWithResourceToken(config: ResourceToken): Explorer {
|
||||
return explorer;
|
||||
}
|
||||
|
||||
function configureWithFabric(documentEndpoint: string): Explorer {
|
||||
function createExplorerFabric(fabricDatabaseConnectionInfo: FabricDatabaseConnectionInfo): Explorer {
|
||||
updateUserContext({
|
||||
fabricDatabaseConnectionInfo,
|
||||
authType: AuthType.ConnectionString,
|
||||
databaseAccount: {
|
||||
id: "",
|
||||
@@ -311,12 +330,11 @@ function configureWithFabric(documentEndpoint: string): Explorer {
|
||||
name: "Mounted",
|
||||
kind: AccountKind.Default,
|
||||
properties: {
|
||||
documentEndpoint,
|
||||
documentEndpoint: fabricDatabaseConnectionInfo.endpoint,
|
||||
},
|
||||
},
|
||||
});
|
||||
const explorer = new Explorer();
|
||||
setTimeout(() => explorer.refreshAllDatabases(), 0);
|
||||
return explorer;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { HttpHeaders } from "Common/Constants";
|
||||
import { QueryRequestOptions, QueryResponse } from "Contracts/AzureResourceGraph";
|
||||
import useSWR from "swr";
|
||||
import { configContext } from "../ConfigContext";
|
||||
import { Subscription } from "../Contracts/DataModels";
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
interface SubscriptionListResult {
|
||||
nextLink: string;
|
||||
@@ -32,10 +35,58 @@ export async function fetchSubscriptions(accessToken: string): Promise<Subscript
|
||||
return subscriptions.sort((a, b) => a.displayName.localeCompare(b.displayName));
|
||||
}
|
||||
|
||||
export async function fetchSubscriptionsFromGraph(accessToken: string): Promise<Subscription[]> {
|
||||
const headers = new Headers();
|
||||
const bearer = `Bearer ${accessToken}`;
|
||||
|
||||
headers.append("Authorization", bearer);
|
||||
headers.append(HttpHeaders.contentType, "application/json");
|
||||
const subscriptionsQuery =
|
||||
"resources | where type == 'microsoft.documentdb/databaseaccounts' | join kind=inner ( resourcecontainers | where type == 'microsoft.resources/subscriptions' | project subscriptionId, subscriptionName = name, subscriptionState = tostring(parse_json(properties).state) ) on subscriptionId | summarize by subscriptionId, subscriptionName, subscriptionState";
|
||||
const apiVersion = "2021-03-01";
|
||||
const managementResourceGraphAPIURL = `${configContext.ARM_ENDPOINT}providers/Microsoft.ResourceGraph/resources?api-version=${apiVersion}`;
|
||||
|
||||
const subscriptions: Subscription[] = [];
|
||||
let skipToken: string;
|
||||
do {
|
||||
const body = {
|
||||
query: subscriptionsQuery,
|
||||
...(skipToken && {
|
||||
options: {
|
||||
$skipToken: skipToken,
|
||||
} as QueryRequestOptions,
|
||||
}),
|
||||
};
|
||||
|
||||
const response = await fetch(managementResourceGraphAPIURL, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await response.text());
|
||||
}
|
||||
|
||||
const queryResponse: QueryResponse = (await response.json()) as QueryResponse;
|
||||
skipToken = queryResponse.$skipToken;
|
||||
|
||||
queryResponse.data?.map((subscription: any) => {
|
||||
subscriptions.push({
|
||||
displayName: subscription.subscriptionName,
|
||||
subscriptionId: subscription.subscriptionId,
|
||||
state: subscription.subscriptionState,
|
||||
} as Subscription);
|
||||
});
|
||||
} while (skipToken);
|
||||
|
||||
return subscriptions.sort((a, b) => a.displayName.localeCompare(b.displayName));
|
||||
}
|
||||
|
||||
export function useSubscriptions(armToken: string): Subscription[] | undefined {
|
||||
const { data } = useSWR(
|
||||
() => (armToken ? ["subscriptions", armToken] : undefined),
|
||||
(_, armToken) => fetchSubscriptions(armToken),
|
||||
(_, armToken) => fetchSubscriptionsFromGraph(armToken),
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user