Replace RU limit banner by clarifying the error when RU limit is exceeded (#1966)

* allow DE to provide clearer error messages for certain conditions

* allow rendeering a "help" link for an error

* use TableCellLayout where possible

* remove RU Threshold banner, now that we have a clearer error

* refmt

* fix QueryError test

* change "RU Threshold" to "RU Limit"
This commit is contained in:
Ashley Stanton-Nurse 2024-09-12 11:45:10 -07:00 committed by GitHub
parent fdbbbd7378
commit 2c7e788358
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 201 additions and 69 deletions

View File

@ -0,0 +1,94 @@
import QueryError, { QueryErrorLocation, QueryErrorSeverity } from "Common/QueryError";
describe("QueryError.tryParse", () => {
const testErrorLocationResolver = ({ start, end }: { start: number; end: number }) =>
new QueryErrorLocation(
{ offset: start, lineNumber: start, column: start },
{ offset: end, lineNumber: end, column: end },
);
it("handles a string error", () => {
const error = "error";
const result = QueryError.tryParse(error, testErrorLocationResolver);
expect(result).toEqual([new QueryError("error", QueryErrorSeverity.Error)]);
});
it("handles an error object", () => {
const error = {
message: "error",
severity: "Warning",
location: { start: 0, end: 1 },
code: "code",
};
const result = QueryError.tryParse(error, testErrorLocationResolver);
expect(result).toEqual([
new QueryError(
"error",
QueryErrorSeverity.Warning,
"code",
new QueryErrorLocation({ offset: 0, lineNumber: 0, column: 0 }, { offset: 1, lineNumber: 1, column: 1 }),
),
]);
});
it("handles a JSON message without syntax errors", () => {
const innerError = {
code: "BadRequest",
message: "Your query is bad, and you should feel bad",
};
const message = JSON.stringify(innerError);
const outerError = {
code: "BadRequest",
message,
};
const result = QueryError.tryParse(outerError, testErrorLocationResolver);
expect(result).toEqual([
new QueryError("Your query is bad, and you should feel bad", QueryErrorSeverity.Error, "BadRequest"),
]);
});
// Imitate the value coming from the backend, which has the syntax errors serialized as JSON in the message.
it("handles single-nested error", () => {
const errors = [
{
message: "error1",
severity: "Warning",
location: { start: 0, end: 1 },
code: "code1",
},
{
message: "error2",
severity: "Error",
location: { start: 2, end: 3 },
code: "code2",
},
];
const innerError = {
code: "BadRequest",
message: "Your query is bad, and you should feel bad",
errors,
};
const message = JSON.stringify(innerError);
const outerError = {
code: "BadRequest",
message,
};
const result = QueryError.tryParse(outerError, testErrorLocationResolver);
expect(result).toEqual([
new QueryError(
"error1",
QueryErrorSeverity.Warning,
"code1",
new QueryErrorLocation({ offset: 0, lineNumber: 0, column: 0 }, { offset: 1, lineNumber: 1, column: 1 }),
),
new QueryError(
"error2",
QueryErrorSeverity.Error,
"code2",
new QueryErrorLocation({ offset: 2, lineNumber: 2, column: 2 }, { offset: 3, lineNumber: 3, column: 3 }),
),
]);
});
});

View File

@ -1,5 +1,5 @@
import { getErrorMessage } from "Common/ErrorHandlingUtils";
import { monaco } from "Explorer/LazyMonaco";
import { getRUThreshold, ruThresholdEnabled } from "Shared/StorageUtility";
export enum QueryErrorSeverity {
Error = "Error",
@ -97,13 +97,44 @@ export const createMonacoMarkersForQueryErrors = (errors: QueryError[]) => {
.filter((marker) => !!marker);
};
export interface ErrorEnrichment {
title?: string;
message: string;
learnMoreUrl?: string;
}
const REPLACEMENT_MESSAGES: Record<string, (original: string) => string> = {
OPERATION_RU_LIMIT_EXCEEDED: (original) => {
if (ruThresholdEnabled()) {
const threshold = getRUThreshold();
return `Query exceeded the Request Unit (RU) limit of ${threshold} RUs. You can change this limit in Data Explorer settings.`;
}
return original;
},
};
const HELP_LINKS: Record<string, string> = {
OPERATION_RU_LIMIT_EXCEEDED:
"https://learn.microsoft.com/en-us/azure/cosmos-db/data-explorer#configure-request-unit-threshold",
};
export default class QueryError {
message: string;
helpLink?: string;
constructor(
public message: string,
message: string,
public severity: QueryErrorSeverity,
public code?: string,
public location?: QueryErrorLocation,
) {}
helpLink?: string,
) {
// Automatically replace the message with a more Data Explorer-specific message if we have for this error code.
this.message = REPLACEMENT_MESSAGES[code] ? REPLACEMENT_MESSAGES[code](message) : message;
// Automatically set the help link if we have one for this error code.
this.helpLink = helpLink ?? HELP_LINKS[code];
}
getMonacoSeverity(): monaco.MarkerSeverity {
// It's very difficult to use the monaco.MarkerSeverity enum from here, so we'll just use the numbers directly.
@ -135,7 +166,7 @@ export default class QueryError {
return errors;
}
const errorMessage = getErrorMessage(error as string | Error);
const errorMessage = error as string;
// Map some well known messages to richer errors
const knownError = knownErrors[errorMessage];
@ -160,7 +191,9 @@ export default class QueryError {
}
const severity =
"severity" in error && typeof error.severity === "string" ? (error.severity as QueryErrorSeverity) : undefined;
"severity" in error && typeof error.severity === "string"
? (error.severity as QueryErrorSeverity)
: QueryErrorSeverity.Error;
const location =
"location" in error && typeof error.location === "object"
? locationResolver(error.location as { start: number; end: number })
@ -173,16 +206,15 @@ export default class QueryError {
error: unknown,
locationResolver: (location: { start: number; end: number }) => QueryErrorLocation,
): QueryError[] | null {
if (typeof error === "object" && "message" in error) {
error = error.message;
}
if (typeof error !== "string") {
let message: string | undefined;
if (typeof error === "object" && "message" in error && typeof error.message === "string") {
message = error.message;
} else {
// Unsupported error format.
return null;
}
// Assign to a new variable because of a TypeScript flow typing quirk, see below.
let message = error;
if (message.startsWith("Message: ")) {
// Reassigning this to 'error' restores the original type of 'error', which is 'unknown'.
// So we use a separate variable to avoid this.
@ -196,13 +228,16 @@ export default class QueryError {
try {
parsed = JSON.parse(message);
} catch (e) {
// Not a query error.
return null;
// The message doesn't contain a nested error.
return [QueryError.read(error, locationResolver)];
}
if (typeof parsed === "object" && "errors" in parsed && Array.isArray(parsed.errors)) {
if (typeof parsed === "object") {
if ("errors" in parsed && Array.isArray(parsed.errors)) {
return parsed.errors.map((e) => QueryError.read(e, locationResolver)).filter((e) => e !== null);
}
return [QueryError.read(parsed, locationResolver)];
}
return null;
}
}

View File

@ -608,16 +608,16 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
<AccordionItem value="4">
<AccordionHeader>
<div className={styles.header}>RU Threshold</div>
<div className={styles.header}>RU Limit</div>
</AccordionHeader>
<AccordionPanel>
<div className={styles.settingsSectionContainer}>
<div className={styles.settingsSectionDescription}>
If a query exceeds a configured RU threshold, the query will be aborted.
If a query exceeds a configured RU limit, the query will be aborted.
</div>
<Toggle
styles={toggleStyles}
label="Enable RU threshold"
label="Enable RU limit"
onChange={handleOnRUThresholdToggleChange}
defaultChecked={ruThresholdEnabled}
/>
@ -625,7 +625,7 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
{ruThresholdEnabled && (
<div className={styles.settingsSectionContainer}>
<SpinButton
label="RU Threshold (RU)"
label="RU Limit (RU)"
labelPosition={Position.top}
defaultValue={(ruThreshold || DefaultRUThreshold).toString()}
min={1}

View File

@ -154,7 +154,7 @@ exports[`Settings Pane should render Default properly 1`] = `
<div
className="___15c001r_0000000 fq02s40"
>
RU Threshold
RU Limit
</div>
</AccordionHeader>
<AccordionPanel>
@ -164,11 +164,11 @@ exports[`Settings Pane should render Default properly 1`] = `
<div
className="___10gar1i_0000000 f1fow5ox f1ugzwwg"
>
If a query exceeds a configured RU threshold, the query will be aborted.
If a query exceeds a configured RU limit, the query will be aborted.
</div>
<StyledToggleBase
defaultChecked={true}
label="Enable RU threshold"
label="Enable RU limit"
onChange={[Function]}
styles={
{
@ -193,7 +193,7 @@ exports[`Settings Pane should render Default properly 1`] = `
decrementButtonAriaLabel="Decrease value by 1000"
defaultValue="5000"
incrementButtonAriaLabel="Increase value by 1000"
label="RU Threshold (RU)"
label="RU Limit (RU)"
labelPosition={0}
min={1}
onChange={[Function]}

View File

@ -12,7 +12,7 @@ import {
createTableColumn,
tokens,
} from "@fluentui/react-components";
import { ErrorCircleFilled, MoreHorizontalRegular, WarningFilled } from "@fluentui/react-icons";
import { ErrorCircleFilled, MoreHorizontalRegular, QuestionRegular, WarningFilled } from "@fluentui/react-icons";
import QueryError, { QueryErrorSeverity, compareSeverity } from "Common/QueryError";
import { useQueryTabStyles } from "Explorer/Tabs/QueryTab/Styles";
import { useNotificationConsole } from "hooks/useNotificationConsole";
@ -34,25 +34,32 @@ export const ErrorList: React.FC<{ errors: QueryError[] }> = ({ errors }) => {
createTableColumn<QueryError>({
columnId: "code",
compare: (item1, item2) => item1.code.localeCompare(item2.code),
renderHeaderCell: () => null,
renderCell: (item) => item.code,
renderHeaderCell: () => "Code",
renderCell: (item) => <TableCellLayout truncate>{item.code}</TableCellLayout>,
}),
createTableColumn<QueryError>({
columnId: "severity",
compare: (item1, item2) => compareSeverity(item1.severity, item2.severity),
renderHeaderCell: () => null,
renderCell: (item) => <TableCellLayout media={severityIcons[item.severity]}>{item.severity}</TableCellLayout>,
renderHeaderCell: () => "Severity",
renderCell: (item) => (
<TableCellLayout truncate media={severityIcons[item.severity]}>
{item.severity}
</TableCellLayout>
),
}),
createTableColumn<QueryError>({
columnId: "location",
compare: (item1, item2) => item1.location?.start?.offset - item2.location?.start?.offset,
renderHeaderCell: () => "Location",
renderCell: (item) =>
item.location
renderCell: (item) => (
<TableCellLayout truncate>
{item.location
? item.location.start.lineNumber
? `Line ${item.location.start.lineNumber}`
: "<unknown>"
: "<no location>",
: "<no location>"}
</TableCellLayout>
),
}),
createTableColumn<QueryError>({
columnId: "message",
@ -60,8 +67,20 @@ export const ErrorList: React.FC<{ errors: QueryError[] }> = ({ errors }) => {
renderHeaderCell: () => "Message",
renderCell: (item) => (
<div className={styles.errorListMessageCell}>
<div className={styles.errorListMessage}>{item.message}</div>
<div>
<div className={styles.errorListMessage} title={item.message}>
{item.message}
</div>
<div className={styles.errorListMessageActions}>
{item.helpLink && (
<Button
as="a"
aria-label="Help"
appearance="subtle"
icon={<QuestionRegular />}
href={item.helpLink}
target="_blank"
/>
)}
<Button
aria-label="Details"
appearance="subtle"
@ -76,9 +95,9 @@ export const ErrorList: React.FC<{ errors: QueryError[] }> = ({ errors }) => {
const columnSizingOptions: TableColumnSizingOptions = {
code: {
minWidth: 75,
idealWidth: 75,
defaultWidth: 75,
minWidth: 90,
idealWidth: 90,
defaultWidth: 90,
},
severity: {
minWidth: 100,

View File

@ -72,6 +72,11 @@ export const useQueryTabStyles = makeStyles({
metricsGridButtons: {
...cosmosShorthands.borderTop(),
},
errorListTableCell: {
textOverflow: "ellipsis",
whiteSpace: "nowrap",
overflow: "hidden",
},
errorListMessageCell: {
display: "flex",
flexDirection: "row",
@ -80,5 +85,12 @@ export const useQueryTabStyles = makeStyles({
},
errorListMessage: {
flexGrow: 1,
textOverflow: "ellipsis",
whiteSpace: "nowrap",
overflow: "hidden",
},
errorListMessageActions: {
display: "flex",
flexDirection: "row",
},
});

View File

@ -1,7 +1,7 @@
import { IMessageBarStyles, Link, MessageBar, MessageBarButton, MessageBarType } from "@fluentui/react";
import { IMessageBarStyles, MessageBar, MessageBarButton, MessageBarType } from "@fluentui/react";
import { CassandraProxyEndpoints, MongoProxyEndpoints } from "Common/Constants";
import { sendMessage } from "Common/MessageHandler";
import { Platform, configContext, updateConfigContext } from "ConfigContext";
import { configContext, updateConfigContext } from "ConfigContext";
import { IpRule } from "Contracts/DataModels";
import { MessageTypes } from "Contracts/ExplorerContracts";
import { CollectionTabKind } from "Contracts/ViewModels";
@ -16,7 +16,6 @@ import { VcoreMongoConnectTab } from "Explorer/Tabs/VCoreMongoConnectTab";
import { VcoreMongoQuickstartTab } from "Explorer/Tabs/VCoreMongoQuickstartTab";
import { LayoutConstants } from "Explorer/Theme/ThemeUtil";
import { KeyboardAction, KeyboardActionGroup, useKeyboardActionGroup } from "KeyboardShortcuts";
import { hasRUThresholdBeenConfigured } from "Shared/StorageUtility";
import { userContext } from "UserContext";
import { CassandraProxyOutboundIPs, MongoProxyOutboundIPs, PortalBackendIPs } from "Utils/EndpointUtils";
import { useTeachingBubble } from "hooks/useTeachingBubble";
@ -37,9 +36,6 @@ interface TabsProps {
export const Tabs = ({ explorer }: TabsProps): JSX.Element => {
const { openedTabs, openedReactTabs, activeTab, activeReactTab, networkSettingsWarning } = useTabs();
const [showRUThresholdMessageBar, setShowRUThresholdMessageBar] = useState<boolean>(
userContext.apiType === "SQL" && configContext.platform !== Platform.Fabric && !hasRUThresholdBeenConfigured(),
);
const [
showMongoAndCassandraProxiesNetworkSettingsWarningState,
setShowMongoAndCassandraProxiesNetworkSettingsWarningState,
@ -87,30 +83,6 @@ export const Tabs = ({ explorer }: TabsProps): JSX.Element => {
{networkSettingsWarning}
</MessageBar>
)}
{showRUThresholdMessageBar && (
<MessageBar
messageBarType={MessageBarType.info}
onDismiss={() => {
setShowRUThresholdMessageBar(false);
}}
styles={{
...defaultMessageBarStyles,
innerText: {
fontWeight: "bold",
},
}}
dismissButtonAriaLabel="Close info"
>
{`Data Explorer has a 5,000 RU default limit. To adjust the limit, go to the Settings page and find "RU Threshold".`}
<Link
className="underlinedLink"
href="https://review.learn.microsoft.com/en-us/azure/cosmos-db/data-explorer?branch=main#configure-request-unit-threshold"
target="_blank"
>
Learn More
</Link>
</MessageBar>
)}
{showMongoAndCassandraProxiesNetworkSettingsWarningState && (
<MessageBar
messageBarType={MessageBarType.warning}