feat(desktop): unify frontend error notices
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
89
apps/desktop/src/lib/operator-notices.test.ts
Normal file
89
apps/desktop/src/lib/operator-notices.test.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createAccentNotice,
|
||||
createNeutralNotice,
|
||||
toBackendError,
|
||||
toBackendErrorNotice,
|
||||
} from "./operator-notices";
|
||||
|
||||
describe("operator notices", () => {
|
||||
it("creates explicit accent notices for successful actions", () => {
|
||||
expect(createAccentNotice("Saved")).toEqual({
|
||||
tone: "accent",
|
||||
message: "Saved",
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes unknown thrown values into internal backend errors", () => {
|
||||
expect(toBackendError(new Error("boom"))).toEqual({
|
||||
code: "internal",
|
||||
message: "boom",
|
||||
detail: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("maps authentication failures to a consistent operator-facing notice", () => {
|
||||
expect(
|
||||
toBackendErrorNotice(
|
||||
{
|
||||
code: "authentication_failed",
|
||||
message: "Authentication failed.",
|
||||
detail: "WRONGPASS invalid username-password pair",
|
||||
},
|
||||
{
|
||||
operation: "connection_test",
|
||||
connectionName: "redis-local-fixture",
|
||||
databaseLabel: "db0",
|
||||
},
|
||||
),
|
||||
).toEqual({
|
||||
tone: "danger",
|
||||
message:
|
||||
"Redis rejected the credentials used while testing redis-local-fixture on db0. Verify username, password, and ACL scope for the selected connection. Backend detail: Authentication failed. (WRONGPASS invalid username-password pair).",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps unsupported operations in a non-destructive neutral notice", () => {
|
||||
expect(
|
||||
toBackendErrorNotice(
|
||||
{
|
||||
code: "unsupported_operation",
|
||||
message: "Only string replacement is supported in V1.",
|
||||
detail: null,
|
||||
},
|
||||
{
|
||||
operation: "value_write",
|
||||
connectionName: "redis-local-fixture",
|
||||
databaseLabel: "db0",
|
||||
keyName: "session:42",
|
||||
},
|
||||
),
|
||||
).toEqual(
|
||||
createNeutralNotice(
|
||||
"The current desktop contract does not allow saving session:42 on redis-local-fixture / db0 yet. Stay on the visible read-only path or wait for a backend contract change before retrying. Backend detail: Only string replacement is supported in V1.",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("describes command failures with command and scope context", () => {
|
||||
expect(
|
||||
toBackendErrorNotice(
|
||||
{
|
||||
code: "command_failed",
|
||||
message: "ERR unknown command 'FOOO'",
|
||||
detail: null,
|
||||
},
|
||||
{
|
||||
operation: "command_execution",
|
||||
connectionName: "redis-local-fixture",
|
||||
databaseLabel: "db0",
|
||||
commandName: "fooo",
|
||||
},
|
||||
),
|
||||
).toEqual({
|
||||
tone: "danger",
|
||||
message:
|
||||
"Redis returned an error while running FOOO on redis-local-fixture / db0. Adjust the Redis input first; retrying the same command unchanged is unlikely to help. Backend detail: ERR unknown command 'FOOO'.",
|
||||
});
|
||||
});
|
||||
});
|
||||
205
apps/desktop/src/lib/operator-notices.ts
Normal file
205
apps/desktop/src/lib/operator-notices.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
export type NoticeTone = "neutral" | "accent" | "danger";
|
||||
|
||||
export type OperatorNotice = {
|
||||
tone: NoticeTone;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type BackendErrorCode =
|
||||
| "invalid_connection_config"
|
||||
| "unsupported_tls_mode"
|
||||
| "unsupported_operation"
|
||||
| "connection_failed"
|
||||
| "authentication_failed"
|
||||
| "command_failed"
|
||||
| "internal";
|
||||
|
||||
export type BackendError = {
|
||||
code: BackendErrorCode;
|
||||
message: string;
|
||||
detail: string | null;
|
||||
};
|
||||
|
||||
export type BackendErrorContext =
|
||||
| {
|
||||
operation: "connection_test";
|
||||
connectionName: string;
|
||||
databaseLabel: string;
|
||||
}
|
||||
| {
|
||||
operation: "key_browse";
|
||||
connectionName: string;
|
||||
databaseLabel: string;
|
||||
}
|
||||
| {
|
||||
operation: "value_read" | "value_write" | "ttl_update" | "ttl_remove" | "inspector_refresh";
|
||||
connectionName: string;
|
||||
databaseLabel: string;
|
||||
keyName: string;
|
||||
}
|
||||
| {
|
||||
operation: "command_execution";
|
||||
connectionName: string;
|
||||
databaseLabel: string;
|
||||
commandName: string;
|
||||
};
|
||||
|
||||
export function createNeutralNotice(message: string): OperatorNotice {
|
||||
return {
|
||||
tone: "neutral",
|
||||
message,
|
||||
};
|
||||
}
|
||||
|
||||
export function createAccentNotice(message: string): OperatorNotice {
|
||||
return {
|
||||
tone: "accent",
|
||||
message,
|
||||
};
|
||||
}
|
||||
|
||||
export function createDangerNotice(message: string): OperatorNotice {
|
||||
return {
|
||||
tone: "danger",
|
||||
message,
|
||||
};
|
||||
}
|
||||
|
||||
export function toBackendError(error: unknown): BackendError {
|
||||
if (
|
||||
typeof error === "object" &&
|
||||
error !== null &&
|
||||
"message" in error &&
|
||||
typeof error.message === "string"
|
||||
) {
|
||||
return {
|
||||
code: normalizeBackendErrorCode("code" in error ? error.code : undefined),
|
||||
message: error.message,
|
||||
detail:
|
||||
"detail" in error && typeof error.detail === "string"
|
||||
? error.detail
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
code: "internal",
|
||||
message: error instanceof Error ? error.message : "Unknown backend error.",
|
||||
detail: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function toBackendErrorNotice(
|
||||
error: unknown,
|
||||
context: BackendErrorContext,
|
||||
): OperatorNotice {
|
||||
const backendError = toBackendError(error);
|
||||
const tone =
|
||||
backendError.code === "invalid_connection_config" ||
|
||||
backendError.code === "unsupported_operation" ||
|
||||
backendError.code === "unsupported_tls_mode"
|
||||
? "neutral"
|
||||
: "danger";
|
||||
|
||||
const headline = buildHeadline(backendError, context);
|
||||
const hint = buildHint(backendError.code, context);
|
||||
const detail = buildTechnicalDetail(backendError);
|
||||
|
||||
return {
|
||||
tone,
|
||||
message: [headline, hint, detail].filter(Boolean).join(" "),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeBackendErrorCode(value: unknown): BackendErrorCode {
|
||||
switch (value) {
|
||||
case "invalid_connection_config":
|
||||
case "unsupported_tls_mode":
|
||||
case "unsupported_operation":
|
||||
case "connection_failed":
|
||||
case "authentication_failed":
|
||||
case "command_failed":
|
||||
case "internal":
|
||||
return value;
|
||||
default:
|
||||
return "internal";
|
||||
}
|
||||
}
|
||||
|
||||
function buildHeadline(error: BackendError, context: BackendErrorContext) {
|
||||
const target = describeContext(context);
|
||||
|
||||
switch (error.code) {
|
||||
case "invalid_connection_config":
|
||||
return `The request for ${target} was rejected before Redis was contacted.`;
|
||||
case "unsupported_tls_mode":
|
||||
return `The request for ${target} requires TLS handling that this foundation build does not support yet.`;
|
||||
case "unsupported_operation":
|
||||
return `The current desktop contract does not allow ${target} yet.`;
|
||||
case "connection_failed":
|
||||
return `Redis could not be reached while ${target}.`;
|
||||
case "authentication_failed":
|
||||
return `Redis rejected the credentials used while ${target}.`;
|
||||
case "command_failed":
|
||||
return `Redis returned an error while ${target}.`;
|
||||
case "internal":
|
||||
return `The desktop bridge failed while ${target}.`;
|
||||
default:
|
||||
return `The desktop bridge failed while ${target}.`;
|
||||
}
|
||||
}
|
||||
|
||||
function buildHint(code: BackendErrorCode, context: BackendErrorContext) {
|
||||
switch (code) {
|
||||
case "invalid_connection_config":
|
||||
return "Check host, port, database, search pattern, and key input before retrying.";
|
||||
case "unsupported_tls_mode":
|
||||
return "Keep TLS disabled in this foundation build unless product scope explicitly adds transport controls.";
|
||||
case "unsupported_operation":
|
||||
return "Stay on the visible read-only path or wait for a backend contract change before retrying.";
|
||||
case "connection_failed":
|
||||
return `Verify that ${context.connectionName} is reachable from this machine and that the selected database is available.`;
|
||||
case "authentication_failed":
|
||||
return "Verify username, password, and ACL scope for the selected connection.";
|
||||
case "command_failed":
|
||||
return "Adjust the Redis input first; retrying the same command unchanged is unlikely to help.";
|
||||
case "internal":
|
||||
return "Retry once, then capture the QA snapshot and escalate if the same failure repeats.";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function buildTechnicalDetail(error: BackendError) {
|
||||
const detail = error.detail && error.detail !== error.message ? error.detail : null;
|
||||
const summary = detail ? `${error.message} (${detail})` : error.message;
|
||||
|
||||
return `Backend detail: ${ensureTrailingPeriod(summary)}`;
|
||||
}
|
||||
|
||||
function describeContext(context: BackendErrorContext) {
|
||||
switch (context.operation) {
|
||||
case "connection_test":
|
||||
return `testing ${context.connectionName} on ${context.databaseLabel}`;
|
||||
case "key_browse":
|
||||
return `loading keys from ${context.connectionName} / ${context.databaseLabel}`;
|
||||
case "value_read":
|
||||
return `reading ${context.keyName} on ${context.connectionName} / ${context.databaseLabel}`;
|
||||
case "value_write":
|
||||
return `saving ${context.keyName} on ${context.connectionName} / ${context.databaseLabel}`;
|
||||
case "ttl_update":
|
||||
return `updating TTL for ${context.keyName} on ${context.connectionName} / ${context.databaseLabel}`;
|
||||
case "ttl_remove":
|
||||
return `removing TTL from ${context.keyName} on ${context.connectionName} / ${context.databaseLabel}`;
|
||||
case "inspector_refresh":
|
||||
return `refreshing ${context.keyName} on ${context.connectionName} / ${context.databaseLabel}`;
|
||||
case "command_execution":
|
||||
return `running ${context.commandName.toUpperCase()} on ${context.connectionName} / ${context.databaseLabel}`;
|
||||
default:
|
||||
return "handling a desktop Redis request";
|
||||
}
|
||||
}
|
||||
|
||||
function ensureTrailingPeriod(value: string) {
|
||||
return /[.!?]$/.test(value) ? value : `${value}.`;
|
||||
}
|
||||
Reference in New Issue
Block a user