feat(desktop): unify frontend error notices

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Senior Frontend Engineer
2026-03-28 11:08:01 +00:00
parent 8d50243d36
commit a9524dfbf6
9 changed files with 825 additions and 96 deletions

View File

@@ -37,6 +37,14 @@ import {
} from "./components/ui/dialog";
import { Input } from "./components/ui/input";
import { Textarea } from "./components/ui/textarea";
import {
toBackendError,
type BackendErrorContext,
createAccentNotice,
createNeutralNotice,
toBackendErrorNotice,
type OperatorNotice,
} from "./lib/operator-notices";
import { cn } from "./lib/utils";
declare const __APP_VERSION__: string;
@@ -107,12 +115,6 @@ type BackendBootstrap = {
next_backend_milestone: string;
};
type BackendError = {
code: string;
message: string;
detail: string | null;
};
type RedisConnectionRequest = {
target: ConnectionProfile["target"];
password: string | null;
@@ -375,7 +377,11 @@ function App() {
const [consoleInput, setConsoleInput] = useState("GET cache:homepage");
const [consoleHistory, setConsoleHistory] = useState(initialConsoleHistory);
const [bootstrap, setBootstrap] = useState(fallbackBootstrap);
const [banner, setBanner] = useState("String edits stay local to the shell until the backend write contract is wired.");
const [banner, setBanner] = useState<OperatorNotice>(
createNeutralNotice(
"String edits stay local to the shell until the backend write contract is wired.",
),
);
const [connectionDialogOpen, setConnectionDialogOpen] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [isConnectionTestRunning, setIsConnectionTestRunning] = useState(false);
@@ -403,6 +409,10 @@ function App() {
const appVersion = __APP_VERSION__;
const buildMode = import.meta.env.DEV ? "dev" : "production";
const runtimeMode = tauriAvailable ? "live desktop bridge" : "browser demo fallback";
const showNeutralBanner = (message: string) => setBanner(createNeutralNotice(message));
const showAccentBanner = (message: string) => setBanner(createAccentNotice(message));
const showBackendErrorBanner = (error: unknown, context: BackendErrorContext) =>
setBanner(toBackendErrorNotice(error, context));
const activeConnection =
connections.find((connection) => connection.id === activeConnectionId) ??
@@ -467,7 +477,7 @@ function App() {
`latestCommand=${latestConsoleEntry?.command ?? "none"}`,
`latestCommandSource=${latestConsoleEntry?.source ?? "none"}`,
`latestCommandStatus=${latestConsoleEntry?.status ?? "none"}`,
`banner=${banner}`,
`banner=${banner.message}`,
].join("\n");
useEffect(() => {
@@ -558,7 +568,6 @@ function App() {
return;
}
const backendError = toBackendError(error);
startTransition(() => {
setLiveKeys([]);
setLiveNextCursor("0");
@@ -568,7 +577,11 @@ function App() {
setDraftValue("");
setTtlDraft("");
setIsBrowseLoading(false);
setBanner(`Live key browse failed on ${activeConnection.name} / ${activeDb}: ${formatBackendError(backendError)}.`);
showBackendErrorBanner(error, {
operation: "key_browse",
connectionName: activeConnection.name,
databaseLabel: activeDb,
});
});
});
@@ -634,13 +647,17 @@ function App() {
return;
}
const backendError = toBackendError(error);
startTransition(() => {
setSelectedValueRecord(null);
setDraftValue("");
setTtlDraft("");
setIsInspectorLoading(false);
setBanner(`Live value read failed for ${selectedKey.name} on ${activeConnection.name}: ${formatBackendError(backendError)}.`);
showBackendErrorBanner(error, {
operation: "value_read",
connectionName: activeConnection.name,
databaseLabel: activeDb,
keyName: selectedKey.name,
});
});
});
@@ -665,7 +682,9 @@ function App() {
setActiveConnectionId(connection.id);
setActiveDb(formatDatabaseLabel(connection.target.database));
setSelectedKeyId(null);
setBanner(`Switched workspace context to ${connection.name} on ${formatDatabaseLabel(connection.target.database)}.`);
showNeutralBanner(
`Switched workspace context to ${connection.name} on ${formatDatabaseLabel(connection.target.database)}.`,
);
});
};
@@ -688,13 +707,13 @@ function App() {
: connection,
),
);
setBanner(`Scoped the workspace to ${database} for ${activeConnection.name}.`);
showNeutralBanner(`Scoped the workspace to ${database} for ${activeConnection.name}.`);
});
};
const handleCreateDraftConnection = () => {
if (draftErrorList.length > 0) {
setBanner(`Connection draft is incomplete: ${draftErrorList[0]}`);
showNeutralBanner(`Connection draft is incomplete: ${draftErrorList[0]}`);
return;
}
@@ -721,19 +740,23 @@ function App() {
setActiveConnectionId(nextConnection.id);
setActiveDb(formatDatabaseLabel(nextDatabase));
setSelectedKeyId(null);
setBanner("A draft connection profile was added to the shell. Credentials remain memory-only.");
showAccentBanner(
"A draft connection profile was added to the shell. Credentials remain memory-only.",
);
setConnectionDialogOpen(false);
});
};
const handleSaveString = async () => {
if (!selectedKey) {
setBanner(`Select a key in ${activeDb} before attempting a shell-level save.`);
showNeutralBanner(`Select a key in ${activeDb} before attempting a shell-level save.`);
return;
}
if (!tauriAvailable) {
setBanner(`Saved mock string value for ${selectedKey.name} in ${activeDb}. Backend persistence is not wired yet.`);
showNeutralBanner(
`Saved mock string value for ${selectedKey.name} in ${activeDb}. Backend persistence is not wired yet.`,
);
return;
}
@@ -763,13 +786,19 @@ function App() {
: formatRedisValueData(result.data),
);
setIsSaveRunning(false);
setBanner(`Saved live string value for ${selectedKey.name} in ${activeDb} and preserved the current TTL boundary.`);
showAccentBanner(
`Saved live string value for ${selectedKey.name} in ${activeDb} and preserved the current TTL boundary.`,
);
});
} catch (error) {
const backendError = toBackendError(error);
startTransition(() => {
setIsSaveRunning(false);
setBanner(`Live string save failed for ${selectedKey.name}: ${formatBackendError(backendError)}.`);
showBackendErrorBanner(error, {
operation: "value_write",
connectionName: activeConnection.name,
databaseLabel: activeDb,
keyName: selectedKey.name,
});
});
}
};
@@ -780,23 +809,27 @@ function App() {
}
setDeleteDialogOpen(false);
setBanner(`Delete confirmation completed for ${selectedKey.name} in ${activeDb}. Mutation stays mocked until a delete contract is approved.`);
showNeutralBanner(
`Delete confirmation completed for ${selectedKey.name} in ${activeDb}. Mutation stays mocked until a delete contract is approved.`,
);
};
const handleApplyTtl = async () => {
if (!selectedKey) {
setBanner(`Select a key in ${activeDb} before attempting a TTL update.`);
showNeutralBanner(`Select a key in ${activeDb} before attempting a TTL update.`);
return;
}
const validationError = validateTtlDraft(ttlDraft);
if (validationError) {
setBanner(`TTL update is invalid: ${validationError}`);
showNeutralBanner(`TTL update is invalid: ${validationError}`);
return;
}
if (!tauriAvailable) {
setBanner(`Browser dev mode keeps TTL changes in demo fallback for ${selectedKey.name}. Use desktop runtime for a live TTL update.`);
showNeutralBanner(
`Browser dev mode keeps TTL changes in demo fallback for ${selectedKey.name}. Use desktop runtime for a live TTL update.`,
);
return;
}
@@ -824,25 +857,33 @@ function App() {
: "",
);
setIsTtlUpdateRunning(false);
setBanner(`Updated live TTL for ${selectedKey.name} to ${formatRedisKeyTtl(result.metadata.ttl)} in ${activeDb}.`);
showAccentBanner(
`Updated live TTL for ${selectedKey.name} to ${formatRedisKeyTtl(result.metadata.ttl)} in ${activeDb}.`,
);
});
} catch (error) {
const backendError = toBackendError(error);
startTransition(() => {
setIsTtlUpdateRunning(false);
setBanner(`Live TTL update failed for ${selectedKey.name}: ${formatBackendError(backendError)}.`);
showBackendErrorBanner(error, {
operation: "ttl_update",
connectionName: activeConnection.name,
databaseLabel: activeDb,
keyName: selectedKey.name,
});
});
}
};
const handlePersistTtl = async () => {
if (!selectedKey) {
setBanner(`Select a key in ${activeDb} before attempting to remove TTL.`);
showNeutralBanner(`Select a key in ${activeDb} before attempting to remove TTL.`);
return;
}
if (!tauriAvailable) {
setBanner(`Browser dev mode keeps TTL removal in demo fallback for ${selectedKey.name}. Use desktop runtime for a live TTL change.`);
showNeutralBanner(
`Browser dev mode keeps TTL removal in demo fallback for ${selectedKey.name}. Use desktop runtime for a live TTL change.`,
);
return;
}
@@ -865,25 +906,35 @@ function App() {
syncUpdatedMetadata(result.metadata);
setTtlDraft("");
setIsTtlUpdateRunning(false);
setBanner(`Removed TTL for ${selectedKey.name}; the key is now ${formatRedisKeyTtl(result.metadata.ttl)}.`);
showAccentBanner(
`Removed TTL for ${selectedKey.name}; the key is now ${formatRedisKeyTtl(result.metadata.ttl)}.`,
);
});
} catch (error) {
const backendError = toBackendError(error);
startTransition(() => {
setIsTtlUpdateRunning(false);
setBanner(`Removing TTL failed for ${selectedKey.name}: ${formatBackendError(backendError)}.`);
showBackendErrorBanner(error, {
operation: "ttl_remove",
connectionName: activeConnection.name,
databaseLabel: activeDb,
keyName: selectedKey.name,
});
});
}
};
const handleRefreshInspector = async () => {
if (!selectedKey) {
setBanner(`No key is selected in ${activeDb}. Adjust or clear the current search pattern first.`);
showNeutralBanner(
`No key is selected in ${activeDb}. Adjust or clear the current search pattern first.`,
);
return;
}
if (!tauriAvailable) {
setBanner(`Refreshed shell metadata for ${selectedKey.name} in ${activeDb}. Live value reload remains downstream of backend browse contracts.`);
showNeutralBanner(
`Refreshed shell metadata for ${selectedKey.name} in ${activeDb}. Live value reload remains downstream of backend browse contracts.`,
);
return;
}
@@ -912,13 +963,19 @@ function App() {
);
setTtlDraft(result.metadata.ttl.kind === "expires_in_millis" ? String(result.metadata.ttl.value) : "");
setIsInspectorLoading(false);
setBanner(`Refreshed live metadata and value surface for ${selectedKey.name} in ${activeDb}.`);
showAccentBanner(
`Refreshed live metadata and value surface for ${selectedKey.name} in ${activeDb}.`,
);
});
} catch (error) {
const backendError = toBackendError(error);
startTransition(() => {
setIsInspectorLoading(false);
setBanner(`Live metadata refresh failed for ${selectedKey.name}: ${formatBackendError(backendError)}.`);
showBackendErrorBanner(error, {
operation: "inspector_refresh",
connectionName: activeConnection.name,
databaseLabel: activeDb,
keyName: selectedKey.name,
});
});
}
};
@@ -926,17 +983,21 @@ function App() {
const handleCopyQaSnapshot = async () => {
if (typeof navigator === "undefined" || !navigator.clipboard?.writeText) {
setCopyFeedback("unavailable");
setBanner("Clipboard export is unavailable in this runtime. Copy the QA snapshot text manually from the side rail.");
showNeutralBanner(
"Clipboard export is unavailable in this runtime. Copy the QA snapshot text manually from the side rail.",
);
return;
}
try {
await navigator.clipboard.writeText(qaSnapshot);
setCopyFeedback("copied");
setBanner("Copied the current QA snapshot to the clipboard for smoke evidence.");
showAccentBanner("Copied the current QA snapshot to the clipboard for smoke evidence.");
} catch {
setCopyFeedback("unavailable");
setBanner("Clipboard export failed in this runtime. Copy the QA snapshot text manually from the side rail.");
showNeutralBanner(
"Clipboard export failed in this runtime. Copy the QA snapshot text manually from the side rail.",
);
}
};
@@ -981,10 +1042,13 @@ function App() {
setIsBrowseLoading(false);
});
} catch (error) {
const backendError = toBackendError(error);
startTransition(() => {
setIsBrowseLoading(false);
setBanner(`Loading more keys failed on ${activeConnection.name} / ${activeDb}: ${formatBackendError(backendError)}.`);
showBackendErrorBanner(error, {
operation: "key_browse",
connectionName: activeConnection.name,
databaseLabel: activeDb,
});
});
}
};
@@ -1006,7 +1070,9 @@ function App() {
: item,
),
);
setBanner(`Testing ${connection.name} against ${activeDb} using the current desktop contract.`);
showNeutralBanner(
`Testing ${connection.name} against ${activeDb} using the current desktop contract.`,
);
});
if (!tauriAvailable) {
@@ -1022,7 +1088,9 @@ function App() {
: item,
),
);
setBanner(`Tauri runtime is unavailable in browser dev mode, so connection testing stayed in demo fallback for ${connection.name}.`);
showNeutralBanner(
`Tauri runtime is unavailable in browser dev mode, so connection testing stayed in demo fallback for ${connection.name}.`,
);
setIsConnectionTestRunning(false);
});
return;
@@ -1051,12 +1119,12 @@ function App() {
),
);
setActiveDb(formatDatabaseLabel(result.selected_database));
setBanner(`Live Redis test passed for ${connection.name} on ${formatDatabaseLabel(result.selected_database)}. Round-trip status: ${result.round_trip_status}.`);
showAccentBanner(
`Live Redis test passed for ${connection.name} on ${formatDatabaseLabel(result.selected_database)}. Round-trip status: ${result.round_trip_status}.`,
);
setIsConnectionTestRunning(false);
});
} catch (error) {
const backendError = toBackendError(error);
startTransition(() => {
setConnections((current) =>
current.map((item) =>
@@ -1064,12 +1132,16 @@ function App() {
? {
...item,
status: "error",
detail: backendError.code,
detail: formatBackendErrorCodeLabel(toBackendError(error).code),
}
: item,
),
);
setBanner(`Live Redis test failed for ${connection.name}: ${formatBackendError(backendError)}.`);
showBackendErrorBanner(error, {
operation: "connection_test",
connectionName: connection.name,
databaseLabel: activeDb,
});
setIsConnectionTestRunning(false);
});
}
@@ -1102,7 +1174,9 @@ function App() {
},
...current.slice(0, 4),
]);
setBanner(`Blocked ${command.toUpperCase()} in ${activeDb}. Administrative commands remain outside the visible shell.`);
showNeutralBanner(
`Blocked ${command.toUpperCase()} in ${activeDb}. Administrative commands remain outside the visible shell.`,
);
});
return;
}
@@ -1122,7 +1196,9 @@ function App() {
},
...current.slice(0, 4),
]);
setBanner(`Tauri runtime is unavailable in browser dev mode, so ${command.toUpperCase()} ran against the visible demo data only.`);
showNeutralBanner(
`Tauri runtime is unavailable in browser dev mode, so ${command.toUpperCase()} ran against the visible demo data only.`,
);
setConsoleInput(trimmed);
setIsCommandRunning(false);
});
@@ -1148,24 +1224,34 @@ function App() {
},
...current.slice(0, 4),
]);
setBanner(`Live Redis command completed on ${activeConnection.name} / ${formatDatabaseLabel(result.database)}.`);
showAccentBanner(
`Live Redis command completed on ${activeConnection.name} / ${formatDatabaseLabel(result.database)}.`,
);
setConsoleInput(trimmed);
setIsCommandRunning(false);
});
} catch (error) {
const backendError = toBackendError(error);
startTransition(() => {
setConsoleHistory((current) => [
{
command: trimmed,
output: formatBackendError(backendError),
output: toBackendErrorNotice(error, {
operation: "command_execution",
connectionName: activeConnection.name,
databaseLabel: activeDb,
commandName: command,
}).message,
status: "error",
source: "live",
},
...current.slice(0, 4),
]);
setBanner(`Live Redis command failed on ${activeConnection.name} / ${activeDb}: ${backendError.message}.`);
showBackendErrorBanner(error, {
operation: "command_execution",
connectionName: activeConnection.name,
databaseLabel: activeDb,
commandName: command,
});
setConsoleInput(trimmed);
setIsCommandRunning(false);
});
@@ -1219,8 +1305,28 @@ function App() {
</div>
</header>
<div className="mb-4 rounded-[24px] border border-[var(--line-soft)] bg-[var(--surface-raised)] px-4 py-3 shadow-[var(--shadow-panel)]">
<p className="text-sm text-[var(--ink-soft)]">{banner}</p>
<div
className={cn(
"mb-4 rounded-[24px] border px-4 py-3 shadow-[var(--shadow-panel)]",
banner.tone === "danger"
? "border-[var(--danger-soft)] bg-[var(--danger-faint)]"
: banner.tone === "accent"
? "border-[var(--accent-strong)] bg-[var(--accent-faint)]"
: "border-[var(--line-soft)] bg-[var(--surface-raised)]",
)}
>
<p
className={cn(
"text-sm",
banner.tone === "danger"
? "text-[var(--danger-strong)]"
: banner.tone === "accent"
? "text-[var(--accent-strong)]"
: "text-[var(--ink-soft)]",
)}
>
{banner.message}
</p>
</div>
<main className="grid min-h-0 flex-1 gap-4 xl:grid-cols-[310px_minmax(360px,1.1fr)_minmax(420px,1fr)]">
@@ -1902,6 +2008,10 @@ function formatConnectionCardDetail(connection: ConnectionProfile) {
return connection.detail;
}
function formatBackendErrorCodeLabel(code: string) {
return code.replace(/_/g, " ");
}
function toRedisConnectionRequest(
connection: ConnectionProfile,
activeDb: string,
@@ -1984,37 +2094,6 @@ function validateConnectionDraft(
return errors;
}
function toBackendError(error: unknown): BackendError {
if (
typeof error === "object" &&
error !== null &&
"message" in error &&
typeof error.message === "string"
) {
return {
code:
"code" in error && typeof error.code === "string"
? error.code
: "internal",
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,
};
}
function formatBackendError(error: BackendError) {
return error.detail ? `${error.message} (${error.detail})` : error.message;
}
function formatRedisResponse(response: RedisResponse) {
const normalized = normalizeRedisResponse(response);
return typeof normalized === "string"