feat(desktop): stabilize connection workspace state
Absorb the local workspace drift for connection session handling, database switching, inspector readability helpers, and the bounded add-key preview flow. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -3,6 +3,7 @@ import {
|
|||||||
startTransition,
|
startTransition,
|
||||||
useDeferredValue,
|
useDeferredValue,
|
||||||
useEffect,
|
useEffect,
|
||||||
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
type ChangeEvent,
|
type ChangeEvent,
|
||||||
type FormEvent,
|
type FormEvent,
|
||||||
@@ -82,6 +83,14 @@ import {
|
|||||||
updateDemoStringValue,
|
updateDemoStringValue,
|
||||||
} from "./lib/demo-workspace";
|
} from "./lib/demo-workspace";
|
||||||
import { buildDatabaseSwitcherLabels } from "./lib/database-switcher";
|
import { buildDatabaseSwitcherLabels } from "./lib/database-switcher";
|
||||||
|
import {
|
||||||
|
applyConnectionTestFailure,
|
||||||
|
applyConnectionTestSuccess,
|
||||||
|
buildConnectionSessionSummary,
|
||||||
|
formatConnectionCardDetailLabel,
|
||||||
|
formatConnectionStatusLabel,
|
||||||
|
markConnectionTesting,
|
||||||
|
} from "./lib/connection-session";
|
||||||
import {
|
import {
|
||||||
createDemoKeyRecord,
|
createDemoKeyRecord,
|
||||||
findDuplicateKeyName,
|
findDuplicateKeyName,
|
||||||
@@ -493,7 +502,9 @@ function App() {
|
|||||||
const [addKeyDialogOpen, setAddKeyDialogOpen] = useState(false);
|
const [addKeyDialogOpen, setAddKeyDialogOpen] = useState(false);
|
||||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
const [pendingMutation, setPendingMutation] = useState<PendingMutationGuard | null>(null);
|
const [pendingMutation, setPendingMutation] = useState<PendingMutationGuard | null>(null);
|
||||||
const [isConnectionTestRunning, setIsConnectionTestRunning] = useState(false);
|
const [connectionTestState, setConnectionTestState] = useState<{ connectionId: string } | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
const [isCommandRunning, setIsCommandRunning] = useState(false);
|
const [isCommandRunning, setIsCommandRunning] = useState(false);
|
||||||
const [isBrowseLoading, setIsBrowseLoading] = useState(false);
|
const [isBrowseLoading, setIsBrowseLoading] = useState(false);
|
||||||
const [isInspectorLoading, setIsInspectorLoading] = useState(false);
|
const [isInspectorLoading, setIsInspectorLoading] = useState(false);
|
||||||
@@ -528,6 +539,9 @@ function App() {
|
|||||||
const activeConnection =
|
const activeConnection =
|
||||||
connections.find((connection) => connection.id === activeConnectionId) ??
|
connections.find((connection) => connection.id === activeConnectionId) ??
|
||||||
connections[0];
|
connections[0];
|
||||||
|
const activeConnectionIdRef = useRef(activeConnectionId);
|
||||||
|
const activeConnectionNameRef = useRef(activeConnection.name);
|
||||||
|
const activeDbRef = useRef(activeDb);
|
||||||
const databaseOptions = buildDatabaseSwitcherLabels(parseDatabaseLabel(activeDb));
|
const databaseOptions = buildDatabaseSwitcherLabels(parseDatabaseLabel(activeDb));
|
||||||
const searchState = resolveKeyBrowserSearchState(search);
|
const searchState = resolveKeyBrowserSearchState(search);
|
||||||
const deferredSearchState = resolveKeyBrowserSearchState(deferredSearch);
|
const deferredSearchState = resolveKeyBrowserSearchState(deferredSearch);
|
||||||
@@ -591,6 +605,20 @@ function App() {
|
|||||||
ttlWriteSupported,
|
ttlWriteSupported,
|
||||||
});
|
});
|
||||||
const localSmokeFixtureActive = isLocalSmokeFixtureConnection(activeConnection);
|
const localSmokeFixtureActive = isLocalSmokeFixtureConnection(activeConnection);
|
||||||
|
const isConnectionTestRunning = connectionTestState !== null;
|
||||||
|
const activeConnectionTestRunning = connectionTestState?.connectionId === activeConnection.id;
|
||||||
|
const otherTestingConnectionName =
|
||||||
|
connectionTestState !== null && connectionTestState.connectionId !== activeConnection.id
|
||||||
|
? connections.find((connection) => connection.id === connectionTestState.connectionId)?.name ??
|
||||||
|
"another connection"
|
||||||
|
: null;
|
||||||
|
const connectionSessionSummary = buildConnectionSessionSummary({
|
||||||
|
connection: activeConnection,
|
||||||
|
databaseLabel: activeDb,
|
||||||
|
tauriAvailable,
|
||||||
|
activeTestRunning: activeConnectionTestRunning,
|
||||||
|
otherTestingConnectionName,
|
||||||
|
});
|
||||||
const localSmokeFilterActive = search.trim() === localSmokeFixtureSearchText;
|
const localSmokeFilterActive = search.trim() === localSmokeFixtureSearchText;
|
||||||
const ttlDraftError = ttlDraft.trim() ? validateTtlDraft(ttlDraft) : null;
|
const ttlDraftError = ttlDraft.trim() ? validateTtlDraft(ttlDraft) : null;
|
||||||
const nextTtlMillis =
|
const nextTtlMillis =
|
||||||
@@ -660,6 +688,12 @@ function App() {
|
|||||||
bannerMessage: banner.message,
|
bannerMessage: banner.message,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
activeConnectionIdRef.current = activeConnectionId;
|
||||||
|
activeConnectionNameRef.current = activeConnection.name;
|
||||||
|
activeDbRef.current = activeDb;
|
||||||
|
}, [activeConnectionId, activeConnection.name, activeDb]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!tauriAvailable) {
|
if (!tauriAvailable) {
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
@@ -1538,23 +1572,14 @@ function App() {
|
|||||||
|
|
||||||
const handleTestConnection = async () => {
|
const handleTestConnection = async () => {
|
||||||
const connection = activeConnection;
|
const connection = activeConnection;
|
||||||
const request = toRedisConnectionRequest(connection, activeDb);
|
const databaseLabel = activeDb;
|
||||||
|
const request = toRedisConnectionRequest(connection, databaseLabel);
|
||||||
|
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
setIsConnectionTestRunning(true);
|
setConnectionTestState({ connectionId: connection.id });
|
||||||
setConnections((current) =>
|
setConnections((current) => markConnectionTesting(current, connection.id));
|
||||||
current.map((item) =>
|
|
||||||
item.id === connection.id
|
|
||||||
? {
|
|
||||||
...item,
|
|
||||||
status: "checking",
|
|
||||||
detail: "Live test in progress",
|
|
||||||
}
|
|
||||||
: item,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
showNeutralBanner(
|
showNeutralBanner(
|
||||||
`Testing ${connection.name} against ${activeDb} using the current desktop contract.`,
|
`Testing ${connection.name} against ${databaseLabel} using the current desktop contract.`,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1574,7 +1599,7 @@ function App() {
|
|||||||
showNeutralBanner(
|
showNeutralBanner(
|
||||||
`Tauri runtime is unavailable in browser dev mode, so connection testing stayed in demo fallback for ${connection.name}.`,
|
`Tauri runtime is unavailable in browser dev mode, so connection testing stayed in demo fallback for ${connection.name}.`,
|
||||||
);
|
);
|
||||||
setIsConnectionTestRunning(false);
|
setConnectionTestState(null);
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1586,46 +1611,46 @@ function App() {
|
|||||||
|
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
setConnections((current) =>
|
setConnections((current) =>
|
||||||
current.map((item) =>
|
applyConnectionTestSuccess(current, connection.id, {
|
||||||
item.id === connection.id
|
roundTripStatus: result.round_trip_status,
|
||||||
? {
|
selectedDatabase: result.selected_database,
|
||||||
...item,
|
authenticatedAs: result.authenticated_as,
|
||||||
status: "healthy",
|
}),
|
||||||
detail: result.round_trip_status,
|
|
||||||
target: {
|
|
||||||
...item.target,
|
|
||||||
database: result.selected_database,
|
|
||||||
username: result.authenticated_as ?? item.target.username,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: item,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
setActiveDb(formatDatabaseLabel(result.selected_database));
|
setConnectionTestState(null);
|
||||||
showAccentBanner(
|
|
||||||
`Live Redis test passed for ${connection.name} on ${formatDatabaseLabel(result.selected_database)}. Round-trip status: ${result.round_trip_status}.`,
|
if (activeConnectionIdRef.current === connection.id) {
|
||||||
|
setActiveDb(formatDatabaseLabel(result.selected_database));
|
||||||
|
setSelectedKeyId(null);
|
||||||
|
showAccentBanner(
|
||||||
|
`Live Redis test passed for ${connection.name} on ${formatDatabaseLabel(result.selected_database)}. Round-trip status: ${result.round_trip_status}.`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
showNeutralBanner(
|
||||||
|
`Live Redis test passed for ${connection.name}; current workspace stayed on ${activeConnectionNameRef.current} / ${activeDbRef.current}.`,
|
||||||
);
|
);
|
||||||
setIsConnectionTestRunning(false);
|
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
const errorCode = formatBackendErrorCodeLabel(toBackendError(error).code);
|
||||||
|
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
setConnections((current) =>
|
setConnections((current) => applyConnectionTestFailure(current, connection.id, errorCode));
|
||||||
current.map((item) =>
|
setConnectionTestState(null);
|
||||||
item.id === connection.id
|
|
||||||
? {
|
if (activeConnectionIdRef.current === connection.id) {
|
||||||
...item,
|
showBackendErrorBanner(error, {
|
||||||
status: "error",
|
operation: "connection_test",
|
||||||
detail: formatBackendErrorCodeLabel(toBackendError(error).code),
|
connectionName: connection.name,
|
||||||
}
|
databaseLabel,
|
||||||
: item,
|
});
|
||||||
),
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
showNeutralBanner(
|
||||||
|
`Live Redis test failed for ${connection.name}. The current workspace stayed on ${activeConnectionNameRef.current} / ${activeDbRef.current}; reopen ${connection.name} to retry after reviewing its card status.`,
|
||||||
);
|
);
|
||||||
showBackendErrorBanner(error, {
|
|
||||||
operation: "connection_test",
|
|
||||||
connectionName: connection.name,
|
|
||||||
databaseLabel: activeDb,
|
|
||||||
});
|
|
||||||
setIsConnectionTestRunning(false);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -1778,7 +1803,7 @@ function App() {
|
|||||||
<StatusPill
|
<StatusPill
|
||||||
icon={<Activity className="h-4 w-4" />}
|
icon={<Activity className="h-4 w-4" />}
|
||||||
label="Status"
|
label="Status"
|
||||||
value={formatConnectionStatus(activeConnection)}
|
value={formatConnectionStatusLabel(activeConnection)}
|
||||||
/>
|
/>
|
||||||
<StatusPill
|
<StatusPill
|
||||||
icon={<PanelLeft className="h-4 w-4" />}
|
icon={<PanelLeft className="h-4 w-4" />}
|
||||||
@@ -1838,7 +1863,7 @@ function App() {
|
|||||||
<p className="text-sm font-medium text-[var(--ink-0)]">{connection.name}</p>
|
<p className="text-sm font-medium text-[var(--ink-0)]">{connection.name}</p>
|
||||||
<p className="mt-1 text-sm text-[var(--ink-soft)]">{formatEndpoint(connection.target)}</p>
|
<p className="mt-1 text-sm text-[var(--ink-soft)]">{formatEndpoint(connection.target)}</p>
|
||||||
<p className="mt-2 font-mono text-xs uppercase tracking-[0.14em] text-[var(--ink-muted)]">
|
<p className="mt-2 font-mono text-xs uppercase tracking-[0.14em] text-[var(--ink-muted)]">
|
||||||
{formatConnectionCardDetail(connection)}
|
{formatConnectionCardDetailLabel(connection)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant={active ? "accent" : "neutral"}>{connection.environment}</Badge>
|
<Badge variant={active ? "accent" : "neutral"}>{connection.environment}</Badge>
|
||||||
@@ -1958,9 +1983,32 @@ function App() {
|
|||||||
onClick={handleTestConnection}
|
onClick={handleTestConnection}
|
||||||
disabled={isConnectionTestRunning}
|
disabled={isConnectionTestRunning}
|
||||||
>
|
>
|
||||||
{isConnectionTestRunning ? "Testing..." : tauriAvailable ? "Test live" : "Test demo"}
|
{activeConnectionTestRunning
|
||||||
|
? "Testing..."
|
||||||
|
: isConnectionTestRunning
|
||||||
|
? "Test running elsewhere"
|
||||||
|
: tauriAvailable
|
||||||
|
? "Test live"
|
||||||
|
: "Test demo"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 rounded-[22px] border border-[var(--line-soft)] bg-[var(--surface-base)] px-4 py-3">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-[var(--ink-0)]">Connection session</p>
|
||||||
|
<p className="mt-1 text-sm text-[var(--ink-soft)]">
|
||||||
|
{connectionSessionSummary.summary}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge variant={connectionSessionSummary.badgeVariant}>
|
||||||
|
{connectionSessionSummary.badgeLabel}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 text-sm text-[var(--ink-soft)]">
|
||||||
|
{connectionSessionSummary.hint}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
@@ -2106,6 +2154,7 @@ function App() {
|
|||||||
? `${filteredKeys.length}${liveHasMore ? "+" : ""} visible in ${activeDb}`
|
? `${filteredKeys.length}${liveHasMore ? "+" : ""} visible in ${activeDb}`
|
||||||
: `${filteredKeys.length} of ${demoKeys.length} keys in ${activeDb}`}
|
: `${filteredKeys.length} of ${demoKeys.length} keys in ${activeDb}`}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
<Badge variant="accent">Preview</Badge>
|
||||||
<Dialog open={addKeyDialogOpen} onOpenChange={setAddKeyDialogOpen}>
|
<Dialog open={addKeyDialogOpen} onOpenChange={setAddKeyDialogOpen}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button size="sm">
|
<Button size="sm">
|
||||||
@@ -2115,12 +2164,15 @@ function App() {
|
|||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<p className="font-mono text-xs uppercase tracking-[0.22em] text-[var(--ink-muted)]">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
Add key
|
<p className="font-mono text-xs uppercase tracking-[0.22em] text-[var(--ink-muted)]">
|
||||||
</p>
|
Add key
|
||||||
|
</p>
|
||||||
|
<Badge variant="accent">Preview</Badge>
|
||||||
|
</div>
|
||||||
<DialogTitle>Create one bounded string key</DialogTitle>
|
<DialogTitle>Create one bounded string key</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
The create flow stays scoped to {activeConnection.name} / {activeDb}, only supports string values, and never overwrites an existing key name.
|
The create flow stays scoped to {activeConnection.name} / {activeDb}, only supports string values, never overwrites an existing key name, and remains separate from the current desktop-v1 acceptance baseline.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
@@ -2888,38 +2940,6 @@ function formatEndpoint(target: ConnectionProfile["target"]) {
|
|||||||
return `${target.host}:${target.port}`;
|
return `${target.host}:${target.port}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatConnectionStatus(connection: ConnectionProfile) {
|
|
||||||
if (connection.status === "healthy") {
|
|
||||||
return `Healthy · ${connection.detail}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (connection.status === "checking") {
|
|
||||||
return "Testing live connection";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (connection.status === "error") {
|
|
||||||
return `Error · ${connection.detail}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return connection.detail;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatConnectionCardDetail(connection: ConnectionProfile) {
|
|
||||||
if (connection.status === "checking") {
|
|
||||||
return "live test in progress";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (connection.status === "healthy") {
|
|
||||||
return `checked ${connection.detail}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (connection.status === "error") {
|
|
||||||
return `failed ${connection.detail}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return connection.detail;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatBackendErrorCodeLabel(code: string) {
|
function formatBackendErrorCodeLabel(code: string) {
|
||||||
return code.replace(/_/g, " ");
|
return code.replace(/_/g, " ");
|
||||||
}
|
}
|
||||||
|
|||||||
62
apps/desktop/src/lib/add-key-draft.test.ts
Normal file
62
apps/desktop/src/lib/add-key-draft.test.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
createDemoKeyRecord,
|
||||||
|
findDuplicateKeyName,
|
||||||
|
parseOptionalTtlMillis,
|
||||||
|
validateAddKeyDraft,
|
||||||
|
} from "./add-key-draft";
|
||||||
|
|
||||||
|
describe("add key draft helpers", () => {
|
||||||
|
it("validates required key name and optional TTL shape", () => {
|
||||||
|
expect(
|
||||||
|
validateAddKeyDraft({
|
||||||
|
name: " ",
|
||||||
|
value: "hello world",
|
||||||
|
ttlMillis: "0",
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
name: "Redis key name must not be empty.",
|
||||||
|
ttlMillis: "TTL must be greater than zero when provided.",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
validateAddKeyDraft({
|
||||||
|
name: "draft:key",
|
||||||
|
value: "",
|
||||||
|
ttlMillis: "soon",
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
ttlMillis: "TTL must be an integer number of milliseconds when provided.",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses optional TTL input and preserves blank as persistent", () => {
|
||||||
|
expect(parseOptionalTtlMillis("")).toBeNull();
|
||||||
|
expect(parseOptionalTtlMillis(" 60000 ")).toBe(60000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects duplicate key names with Redis-style case-sensitive matching", () => {
|
||||||
|
expect(findDuplicateKeyName([{ name: "draft:key" }], "draft:key")).toBe(true);
|
||||||
|
expect(findDuplicateKeyName([{ name: "draft:key" }], "Draft:Key")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds a demo string key record with optional TTL formatting", () => {
|
||||||
|
expect(
|
||||||
|
createDemoKeyRecord({
|
||||||
|
name: " draft:key ",
|
||||||
|
value: "hello world",
|
||||||
|
ttlMillis: "60000",
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
id: "draft:key",
|
||||||
|
name: "draft:key",
|
||||||
|
type: "string",
|
||||||
|
ttl: "1m",
|
||||||
|
preview: "hello world",
|
||||||
|
editable: true,
|
||||||
|
value: "hello world",
|
||||||
|
summary:
|
||||||
|
"String-only Add Key flow stays aligned to the bounded create contract and keeps the new key visible in the current workspace scope.",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
67
apps/desktop/src/lib/add-key-draft.ts
Normal file
67
apps/desktop/src/lib/add-key-draft.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import {
|
||||||
|
buildDemoStringPreview,
|
||||||
|
formatDemoDurationFromMillis,
|
||||||
|
type DemoWorkspaceKey,
|
||||||
|
} from "./demo-workspace";
|
||||||
|
|
||||||
|
export type AddKeyDraft = {
|
||||||
|
name: string;
|
||||||
|
value: string;
|
||||||
|
ttlMillis: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AddKeyDraftErrors = Partial<Record<"name" | "ttlMillis", string>>;
|
||||||
|
|
||||||
|
export function validateAddKeyDraft(draft: AddKeyDraft): AddKeyDraftErrors {
|
||||||
|
const errors: AddKeyDraftErrors = {};
|
||||||
|
|
||||||
|
if (!draft.name.trim()) {
|
||||||
|
errors.name = "Redis key name must not be empty.";
|
||||||
|
}
|
||||||
|
|
||||||
|
const ttlValue = draft.ttlMillis.trim();
|
||||||
|
if (ttlValue) {
|
||||||
|
if (!/^\d+$/.test(ttlValue)) {
|
||||||
|
errors.ttlMillis = "TTL must be an integer number of milliseconds when provided.";
|
||||||
|
} else if (Number.parseInt(ttlValue, 10) <= 0) {
|
||||||
|
errors.ttlMillis = "TTL must be greater than zero when provided.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseOptionalTtlMillis(value: string) {
|
||||||
|
const normalized = value.trim();
|
||||||
|
if (!normalized) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = Number.parseInt(normalized, 10);
|
||||||
|
return Number.isFinite(parsed) ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findDuplicateKeyName<T extends { name: string }>(
|
||||||
|
keys: T[],
|
||||||
|
keyName: string,
|
||||||
|
) {
|
||||||
|
const normalized = keyName.trim();
|
||||||
|
return keys.some((key) => key.name === normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDemoKeyRecord(draft: AddKeyDraft): DemoWorkspaceKey {
|
||||||
|
const keyName = draft.name.trim();
|
||||||
|
const ttlMillis = parseOptionalTtlMillis(draft.ttlMillis);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: keyName,
|
||||||
|
name: keyName,
|
||||||
|
type: "string",
|
||||||
|
ttl: ttlMillis === null ? "persistent" : formatDemoDurationFromMillis(ttlMillis),
|
||||||
|
preview: buildDemoStringPreview(draft.value),
|
||||||
|
editable: true,
|
||||||
|
value: draft.value,
|
||||||
|
summary:
|
||||||
|
"String-only Add Key flow stays aligned to the bounded create contract and keeps the new key visible in the current workspace scope.",
|
||||||
|
};
|
||||||
|
}
|
||||||
151
apps/desktop/src/lib/connection-session.test.ts
Normal file
151
apps/desktop/src/lib/connection-session.test.ts
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
applyConnectionTestFailure,
|
||||||
|
applyConnectionTestSuccess,
|
||||||
|
buildConnectionSessionSummary,
|
||||||
|
formatConnectionCardDetailLabel,
|
||||||
|
formatConnectionStatusLabel,
|
||||||
|
markConnectionTesting,
|
||||||
|
} from "./connection-session";
|
||||||
|
|
||||||
|
const baseConnection = {
|
||||||
|
id: "redis-local",
|
||||||
|
name: "redis-local",
|
||||||
|
detail: "Draft profile",
|
||||||
|
status: "idle" as const,
|
||||||
|
target: {
|
||||||
|
database: 0,
|
||||||
|
username: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("connection session helpers", () => {
|
||||||
|
it("keeps connection test transitions in one place", () => {
|
||||||
|
const checking = markConnectionTesting([baseConnection], baseConnection.id);
|
||||||
|
expect(checking[0]).toMatchObject({
|
||||||
|
status: "checking",
|
||||||
|
detail: "Live test in progress",
|
||||||
|
});
|
||||||
|
|
||||||
|
const healthy = applyConnectionTestSuccess(checking, baseConnection.id, {
|
||||||
|
roundTripStatus: "PONG in 11ms",
|
||||||
|
selectedDatabase: 4,
|
||||||
|
authenticatedAs: "readonly",
|
||||||
|
});
|
||||||
|
expect(healthy[0]).toMatchObject({
|
||||||
|
status: "healthy",
|
||||||
|
detail: "PONG in 11ms",
|
||||||
|
target: {
|
||||||
|
database: 4,
|
||||||
|
username: "readonly",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const failed = applyConnectionTestFailure(healthy, baseConnection.id, "authentication_failed");
|
||||||
|
expect(failed[0]).toMatchObject({
|
||||||
|
status: "error",
|
||||||
|
detail: "authentication_failed",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats consistent status and card copy", () => {
|
||||||
|
expect(formatConnectionStatusLabel(baseConnection)).toBe("Idle · Draft profile");
|
||||||
|
expect(
|
||||||
|
formatConnectionStatusLabel({
|
||||||
|
...baseConnection,
|
||||||
|
status: "healthy",
|
||||||
|
detail: "PONG in 11ms",
|
||||||
|
}),
|
||||||
|
).toBe("Ready · PONG in 11ms");
|
||||||
|
expect(
|
||||||
|
formatConnectionCardDetailLabel({
|
||||||
|
...baseConnection,
|
||||||
|
status: "error",
|
||||||
|
detail: "authentication_failed",
|
||||||
|
}),
|
||||||
|
).toBe("retry authentication_failed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds session summaries for demo, testing, ready, and retry states", () => {
|
||||||
|
expect(
|
||||||
|
buildConnectionSessionSummary({
|
||||||
|
connection: baseConnection,
|
||||||
|
databaseLabel: "db0",
|
||||||
|
tauriAvailable: false,
|
||||||
|
activeTestRunning: false,
|
||||||
|
otherTestingConnectionName: null,
|
||||||
|
}),
|
||||||
|
).toMatchObject({
|
||||||
|
badgeLabel: "Demo fallback",
|
||||||
|
badgeVariant: "neutral",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
buildConnectionSessionSummary({
|
||||||
|
connection: baseConnection,
|
||||||
|
databaseLabel: "db0",
|
||||||
|
tauriAvailable: true,
|
||||||
|
activeTestRunning: true,
|
||||||
|
otherTestingConnectionName: null,
|
||||||
|
}),
|
||||||
|
).toMatchObject({
|
||||||
|
badgeLabel: "Testing",
|
||||||
|
badgeVariant: "accent",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
buildConnectionSessionSummary({
|
||||||
|
connection: {
|
||||||
|
...baseConnection,
|
||||||
|
status: "healthy",
|
||||||
|
detail: "PONG in 11ms",
|
||||||
|
},
|
||||||
|
databaseLabel: "db4",
|
||||||
|
tauriAvailable: true,
|
||||||
|
activeTestRunning: false,
|
||||||
|
otherTestingConnectionName: null,
|
||||||
|
}),
|
||||||
|
).toMatchObject({
|
||||||
|
badgeLabel: "Ready",
|
||||||
|
badgeVariant: "accent",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
buildConnectionSessionSummary({
|
||||||
|
connection: {
|
||||||
|
...baseConnection,
|
||||||
|
status: "error",
|
||||||
|
detail: "authentication_failed",
|
||||||
|
},
|
||||||
|
databaseLabel: "db0",
|
||||||
|
tauriAvailable: true,
|
||||||
|
activeTestRunning: false,
|
||||||
|
otherTestingConnectionName: null,
|
||||||
|
}),
|
||||||
|
).toMatchObject({
|
||||||
|
badgeLabel: "Retry needed",
|
||||||
|
badgeVariant: "danger",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the active session copy clear when another connection is testing", () => {
|
||||||
|
expect(
|
||||||
|
buildConnectionSessionSummary({
|
||||||
|
connection: {
|
||||||
|
...baseConnection,
|
||||||
|
status: "healthy",
|
||||||
|
detail: "PONG in 11ms",
|
||||||
|
},
|
||||||
|
databaseLabel: "db0",
|
||||||
|
tauriAvailable: true,
|
||||||
|
activeTestRunning: false,
|
||||||
|
otherTestingConnectionName: "redis-staging",
|
||||||
|
}),
|
||||||
|
).toMatchObject({
|
||||||
|
badgeLabel: "Ready",
|
||||||
|
badgeVariant: "neutral",
|
||||||
|
summary:
|
||||||
|
"redis-local remains selected on db0 while redis-staging finishes a separate live test.",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
179
apps/desktop/src/lib/connection-session.ts
Normal file
179
apps/desktop/src/lib/connection-session.ts
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
export type SessionConnection = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
detail: string;
|
||||||
|
status: "healthy" | "idle" | "checking" | "error";
|
||||||
|
target: {
|
||||||
|
database: number;
|
||||||
|
username: string | null;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ConnectionTestState = {
|
||||||
|
connectionId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ConnectionSessionSummary = {
|
||||||
|
badgeLabel: string;
|
||||||
|
badgeVariant: "neutral" | "accent" | "danger";
|
||||||
|
summary: string;
|
||||||
|
hint: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ConnectionTestSuccessResult = {
|
||||||
|
roundTripStatus: string;
|
||||||
|
selectedDatabase: number;
|
||||||
|
authenticatedAs: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function formatConnectionStatusLabel(connection: SessionConnection) {
|
||||||
|
if (connection.status === "healthy") {
|
||||||
|
return `Ready · ${connection.detail}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (connection.status === "checking") {
|
||||||
|
return "Testing · Live contract probe";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (connection.status === "error") {
|
||||||
|
return `Attention · ${connection.detail}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `Idle · ${connection.detail}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatConnectionCardDetailLabel(connection: SessionConnection) {
|
||||||
|
if (connection.status === "checking") {
|
||||||
|
return "live test in progress";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (connection.status === "healthy") {
|
||||||
|
return `ready ${connection.detail}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (connection.status === "error") {
|
||||||
|
return `retry ${connection.detail}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return connection.detail;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markConnectionTesting<T extends SessionConnection>(
|
||||||
|
connections: T[],
|
||||||
|
connectionId: string,
|
||||||
|
) {
|
||||||
|
return connections.map((connection) =>
|
||||||
|
connection.id === connectionId
|
||||||
|
? {
|
||||||
|
...connection,
|
||||||
|
status: "checking" as const,
|
||||||
|
detail: "Live test in progress",
|
||||||
|
}
|
||||||
|
: connection,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyConnectionTestSuccess<T extends SessionConnection>(
|
||||||
|
connections: T[],
|
||||||
|
connectionId: string,
|
||||||
|
result: ConnectionTestSuccessResult,
|
||||||
|
) {
|
||||||
|
return connections.map((connection) =>
|
||||||
|
connection.id === connectionId
|
||||||
|
? {
|
||||||
|
...connection,
|
||||||
|
status: "healthy" as const,
|
||||||
|
detail: result.roundTripStatus,
|
||||||
|
target: {
|
||||||
|
...connection.target,
|
||||||
|
database: result.selectedDatabase,
|
||||||
|
username: result.authenticatedAs ?? connection.target.username,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: connection,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyConnectionTestFailure<T extends SessionConnection>(
|
||||||
|
connections: T[],
|
||||||
|
connectionId: string,
|
||||||
|
detail: string,
|
||||||
|
) {
|
||||||
|
return connections.map((connection) =>
|
||||||
|
connection.id === connectionId
|
||||||
|
? {
|
||||||
|
...connection,
|
||||||
|
status: "error" as const,
|
||||||
|
detail,
|
||||||
|
}
|
||||||
|
: connection,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildConnectionSessionSummary(args: {
|
||||||
|
connection: SessionConnection;
|
||||||
|
databaseLabel: string;
|
||||||
|
tauriAvailable: boolean;
|
||||||
|
activeTestRunning: boolean;
|
||||||
|
otherTestingConnectionName: string | null;
|
||||||
|
}): ConnectionSessionSummary {
|
||||||
|
const {
|
||||||
|
connection,
|
||||||
|
databaseLabel,
|
||||||
|
tauriAvailable,
|
||||||
|
activeTestRunning,
|
||||||
|
otherTestingConnectionName,
|
||||||
|
} = args;
|
||||||
|
|
||||||
|
if (!tauriAvailable) {
|
||||||
|
return {
|
||||||
|
badgeLabel: "Demo fallback",
|
||||||
|
badgeVariant: "neutral",
|
||||||
|
summary: `${connection.name} stays scoped to ${databaseLabel} in browser mode without contacting Redis.`,
|
||||||
|
hint: "Use Test demo to verify shell flow only, then switch to the Tauri desktop runtime for a live connection retry.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeTestRunning) {
|
||||||
|
return {
|
||||||
|
badgeLabel: "Testing",
|
||||||
|
badgeVariant: "accent",
|
||||||
|
summary: `Running a live connection probe for ${connection.name} on ${databaseLabel}.`,
|
||||||
|
hint: "Keep this scope selected if you want the tested database and retry messaging to update in place.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (otherTestingConnectionName) {
|
||||||
|
return {
|
||||||
|
badgeLabel: "Ready",
|
||||||
|
badgeVariant: "neutral",
|
||||||
|
summary: `${connection.name} remains selected on ${databaseLabel} while ${otherTestingConnectionName} finishes a separate live test.`,
|
||||||
|
hint: "The current workspace stays stable. Switch back to the testing connection after the probe completes if you need to review its result.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (connection.status === "healthy") {
|
||||||
|
return {
|
||||||
|
badgeLabel: "Ready",
|
||||||
|
badgeVariant: "accent",
|
||||||
|
summary: `${connection.name} last passed a live connection check on ${databaseLabel}.`,
|
||||||
|
hint: "Re-run Test live after changing host, DB, credentials, or after recovering from a Redis outage.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (connection.status === "error") {
|
||||||
|
return {
|
||||||
|
badgeLabel: "Retry needed",
|
||||||
|
badgeVariant: "danger",
|
||||||
|
summary: `${connection.name} last failed a live connection check on ${databaseLabel}.`,
|
||||||
|
hint: "Review the banner and connection card detail, adjust the profile if needed, then retry Test live from this same scope.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
badgeLabel: "Ready to test",
|
||||||
|
badgeVariant: "neutral",
|
||||||
|
summary: `${connection.name} is selected on ${databaseLabel} but has not completed a live check in the current session.`,
|
||||||
|
hint: "Run Test live before treating browse, inspect, or command results as current Redis evidence.",
|
||||||
|
};
|
||||||
|
}
|
||||||
74
apps/desktop/src/lib/database-switcher.test.ts
Normal file
74
apps/desktop/src/lib/database-switcher.test.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { buildDatabaseSwitcherLabels } from "./database-switcher";
|
||||||
|
|
||||||
|
describe("database switcher helper", () => {
|
||||||
|
it("returns the default operator range from db0 through db15", () => {
|
||||||
|
expect(buildDatabaseSwitcherLabels(0)).toEqual([
|
||||||
|
"db0",
|
||||||
|
"db1",
|
||||||
|
"db2",
|
||||||
|
"db3",
|
||||||
|
"db4",
|
||||||
|
"db5",
|
||||||
|
"db6",
|
||||||
|
"db7",
|
||||||
|
"db8",
|
||||||
|
"db9",
|
||||||
|
"db10",
|
||||||
|
"db11",
|
||||||
|
"db12",
|
||||||
|
"db13",
|
||||||
|
"db14",
|
||||||
|
"db15",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps an active out-of-range database visible and numerically ordered", () => {
|
||||||
|
expect(buildDatabaseSwitcherLabels(23)).toEqual([
|
||||||
|
"db0",
|
||||||
|
"db1",
|
||||||
|
"db2",
|
||||||
|
"db3",
|
||||||
|
"db4",
|
||||||
|
"db5",
|
||||||
|
"db6",
|
||||||
|
"db7",
|
||||||
|
"db8",
|
||||||
|
"db9",
|
||||||
|
"db10",
|
||||||
|
"db11",
|
||||||
|
"db12",
|
||||||
|
"db13",
|
||||||
|
"db14",
|
||||||
|
"db15",
|
||||||
|
"db23",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes negative or fractional inputs back to safe labels", () => {
|
||||||
|
expect(buildDatabaseSwitcherLabels(-3)).toEqual([
|
||||||
|
"db0",
|
||||||
|
"db1",
|
||||||
|
"db2",
|
||||||
|
"db3",
|
||||||
|
"db4",
|
||||||
|
"db5",
|
||||||
|
"db6",
|
||||||
|
"db7",
|
||||||
|
"db8",
|
||||||
|
"db9",
|
||||||
|
"db10",
|
||||||
|
"db11",
|
||||||
|
"db12",
|
||||||
|
"db13",
|
||||||
|
"db14",
|
||||||
|
"db15",
|
||||||
|
]);
|
||||||
|
expect(buildDatabaseSwitcherLabels(4.8, 2)).toEqual([
|
||||||
|
"db0",
|
||||||
|
"db1",
|
||||||
|
"db2",
|
||||||
|
"db4",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
29
apps/desktop/src/lib/database-switcher.ts
Normal file
29
apps/desktop/src/lib/database-switcher.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
export function buildDatabaseSwitcherLabels(
|
||||||
|
activeDatabase: number,
|
||||||
|
maxDefaultDatabase = 15,
|
||||||
|
) {
|
||||||
|
const normalizedDefaultMax = Math.max(0, Math.floor(maxDefaultDatabase));
|
||||||
|
const normalizedActive = Math.max(0, Math.floor(activeDatabase));
|
||||||
|
const labels = new Set<string>();
|
||||||
|
|
||||||
|
for (let database = 0; database <= normalizedDefaultMax; database += 1) {
|
||||||
|
labels.add(formatDatabaseLabel(database));
|
||||||
|
}
|
||||||
|
|
||||||
|
labels.add(formatDatabaseLabel(normalizedActive));
|
||||||
|
|
||||||
|
return [...labels].sort(compareDatabaseLabels);
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareDatabaseLabels(left: string, right: string) {
|
||||||
|
return parseDatabaseLabel(left) - parseDatabaseLabel(right);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDatabaseLabel(value: string) {
|
||||||
|
const parsed = Number.parseInt(value.replace(/^db/i, ""), 10);
|
||||||
|
return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDatabaseLabel(database: number) {
|
||||||
|
return `db${database}`;
|
||||||
|
}
|
||||||
183
apps/desktop/src/lib/inspector-readability.test.ts
Normal file
183
apps/desktop/src/lib/inspector-readability.test.ts
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { buildInspectorReadabilitySummary } from "./inspector-readability";
|
||||||
|
|
||||||
|
describe("inspector readability summary", () => {
|
||||||
|
it("guides the operator when no key is selected", () => {
|
||||||
|
expect(
|
||||||
|
buildInspectorReadabilitySummary({
|
||||||
|
databaseLabel: "db4",
|
||||||
|
runtimeMode: "live",
|
||||||
|
selectedKey: null,
|
||||||
|
record: null,
|
||||||
|
isLoading: false,
|
||||||
|
isEditable: false,
|
||||||
|
ttlWriteSupported: false,
|
||||||
|
}),
|
||||||
|
).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
status: "awaiting selection",
|
||||||
|
surfaceTitle: "No key selected",
|
||||||
|
actionTitle: "Next step",
|
||||||
|
facts: expect.arrayContaining([
|
||||||
|
{ label: "Scope", value: "db4" },
|
||||||
|
{ label: "Type", value: "unknown" },
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("describes editable live strings with explicit write semantics", () => {
|
||||||
|
expect(
|
||||||
|
buildInspectorReadabilitySummary({
|
||||||
|
databaseLabel: "db0",
|
||||||
|
runtimeMode: "live",
|
||||||
|
selectedKey: {
|
||||||
|
name: "cache:homepage",
|
||||||
|
type: "string",
|
||||||
|
ttl: "48s",
|
||||||
|
},
|
||||||
|
record: {
|
||||||
|
metadata: {
|
||||||
|
key_type: "string",
|
||||||
|
ttl: { kind: "expires_in_millis", value: 48_000 },
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
kind: "string",
|
||||||
|
value: { kind: "string", value: "payload" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
isLoading: false,
|
||||||
|
isEditable: true,
|
||||||
|
ttlWriteSupported: true,
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
status: "editable string",
|
||||||
|
surfaceTitle: "String value",
|
||||||
|
surfaceDescription:
|
||||||
|
"The full live string payload is loaded in the inspector. Saving replaces the entire string value and preserves the active TTL boundary.",
|
||||||
|
actionTitle: "Operator path",
|
||||||
|
actionDescription:
|
||||||
|
"Review the full payload, make bounded edits, and use Save string value when you are ready to replace the current value.",
|
||||||
|
facts: [
|
||||||
|
{ label: "Scope", value: "db0" },
|
||||||
|
{ label: "Type", value: "string" },
|
||||||
|
{ label: "TTL state", value: "48s" },
|
||||||
|
{ label: "Value shape", value: "string payload" },
|
||||||
|
{ label: "Write path", value: "replace full string" },
|
||||||
|
{ label: "TTL control", value: "available" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("describes structured live values as readable inspection-only surfaces", () => {
|
||||||
|
expect(
|
||||||
|
buildInspectorReadabilitySummary({
|
||||||
|
databaseLabel: "db0",
|
||||||
|
runtimeMode: "live",
|
||||||
|
selectedKey: {
|
||||||
|
name: "session:42",
|
||||||
|
type: "hash",
|
||||||
|
ttl: "13m",
|
||||||
|
},
|
||||||
|
record: {
|
||||||
|
metadata: {
|
||||||
|
key_type: "hash",
|
||||||
|
ttl: { kind: "expires_in_millis", value: 780_000 },
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
kind: "hash",
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
field: { kind: "string", value: "region" },
|
||||||
|
value: { kind: "string", value: "eu-west-1" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: { kind: "string", value: "role" },
|
||||||
|
value: { kind: "string", value: "editor" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
isLoading: false,
|
||||||
|
isEditable: false,
|
||||||
|
ttlWriteSupported: true,
|
||||||
|
}),
|
||||||
|
).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
status: "structured read only",
|
||||||
|
surfaceTitle: "Hash fields",
|
||||||
|
facts: [
|
||||||
|
{ label: "Scope", value: "db0" },
|
||||||
|
{ label: "Type", value: "hash" },
|
||||||
|
{ label: "TTL state", value: "13m" },
|
||||||
|
{ label: "Value shape", value: "2 fields" },
|
||||||
|
{ label: "Write path", value: "inspection only" },
|
||||||
|
{ label: "TTL control", value: "available" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps demo mode explicit so shell review is not confused with live evidence", () => {
|
||||||
|
expect(
|
||||||
|
buildInspectorReadabilitySummary({
|
||||||
|
databaseLabel: "db0",
|
||||||
|
runtimeMode: "demo",
|
||||||
|
selectedKey: {
|
||||||
|
name: "cache:homepage",
|
||||||
|
type: "string",
|
||||||
|
ttl: "48s",
|
||||||
|
},
|
||||||
|
record: null,
|
||||||
|
isLoading: false,
|
||||||
|
isEditable: true,
|
||||||
|
ttlWriteSupported: true,
|
||||||
|
}),
|
||||||
|
).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
status: "demo editable",
|
||||||
|
surfaceTitle: "String value",
|
||||||
|
facts: expect.arrayContaining([
|
||||||
|
{ label: "Write path", value: "session-local string save" },
|
||||||
|
{ label: "TTL control", value: "available" },
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("makes stale browse-to-read gaps explicit when the key disappears", () => {
|
||||||
|
expect(
|
||||||
|
buildInspectorReadabilitySummary({
|
||||||
|
databaseLabel: "db2",
|
||||||
|
runtimeMode: "live",
|
||||||
|
selectedKey: {
|
||||||
|
name: "jobs:failed",
|
||||||
|
type: "list",
|
||||||
|
ttl: "persistent",
|
||||||
|
},
|
||||||
|
record: {
|
||||||
|
metadata: {
|
||||||
|
key_type: "list",
|
||||||
|
ttl: { kind: "missing" },
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
kind: "missing",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
isLoading: false,
|
||||||
|
isEditable: false,
|
||||||
|
ttlWriteSupported: false,
|
||||||
|
}),
|
||||||
|
).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
status: "missing key",
|
||||||
|
surfaceTitle: "Key disappeared after browse",
|
||||||
|
facts: expect.arrayContaining([
|
||||||
|
{ label: "Scope", value: "db2" },
|
||||||
|
{ label: "TTL state", value: "missing" },
|
||||||
|
{ label: "Write path", value: "not available" },
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
340
apps/desktop/src/lib/inspector-readability.ts
Normal file
340
apps/desktop/src/lib/inspector-readability.ts
Normal file
@@ -0,0 +1,340 @@
|
|||||||
|
type RedisKeyTtl =
|
||||||
|
| { kind: "persistent" }
|
||||||
|
| { kind: "missing" }
|
||||||
|
| { kind: "expires_in_millis"; value: number };
|
||||||
|
|
||||||
|
type RedisValueContent =
|
||||||
|
| { kind: "string"; value: string }
|
||||||
|
| { kind: "binary"; bytes: number[] };
|
||||||
|
|
||||||
|
type RedisFieldValueEntry = {
|
||||||
|
field: RedisValueContent;
|
||||||
|
value: RedisValueContent;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RedisSortedSetEntry = {
|
||||||
|
member: RedisValueContent;
|
||||||
|
score: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RedisStreamEntry = {
|
||||||
|
id: string;
|
||||||
|
fields: RedisFieldValueEntry[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type RedisValueData =
|
||||||
|
| { kind: "missing" }
|
||||||
|
| { kind: "string"; value: RedisValueContent }
|
||||||
|
| { kind: "hash"; entries: RedisFieldValueEntry[] }
|
||||||
|
| { kind: "list"; items: RedisValueContent[] }
|
||||||
|
| { kind: "set"; members: RedisValueContent[] }
|
||||||
|
| { kind: "sorted_set"; entries: RedisSortedSetEntry[] }
|
||||||
|
| { kind: "stream"; entries: RedisStreamEntry[] };
|
||||||
|
|
||||||
|
type RedisValueRecord = {
|
||||||
|
metadata: {
|
||||||
|
key_type: string;
|
||||||
|
ttl: RedisKeyTtl;
|
||||||
|
};
|
||||||
|
data: RedisValueData;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SelectedInspectorKey = {
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
ttl: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type InspectorFact = {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type InspectorReadabilitySummary = {
|
||||||
|
status: string;
|
||||||
|
surfaceTitle: string;
|
||||||
|
surfaceDescription: string;
|
||||||
|
actionTitle: string;
|
||||||
|
actionDescription: string;
|
||||||
|
facts: InspectorFact[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type InspectorReadabilityContext = {
|
||||||
|
databaseLabel: string;
|
||||||
|
runtimeMode: "live" | "demo";
|
||||||
|
selectedKey: SelectedInspectorKey | null;
|
||||||
|
record: RedisValueRecord | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
isEditable: boolean;
|
||||||
|
ttlWriteSupported: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function buildInspectorReadabilitySummary(
|
||||||
|
context: InspectorReadabilityContext,
|
||||||
|
): InspectorReadabilitySummary {
|
||||||
|
const keyType = resolveKeyType(context);
|
||||||
|
const ttlState = resolveTtlState(context);
|
||||||
|
const valueShape = describeValueShape(context);
|
||||||
|
const writePath = describeWritePath(context);
|
||||||
|
const ttlPath = context.selectedKey
|
||||||
|
? context.ttlWriteSupported
|
||||||
|
? "available"
|
||||||
|
: "not available"
|
||||||
|
: "awaiting selection";
|
||||||
|
const facts: InspectorFact[] = [
|
||||||
|
{ label: "Scope", value: context.databaseLabel },
|
||||||
|
{ label: "Type", value: formatRedisKeyTypeLabel(keyType) },
|
||||||
|
{ label: "TTL state", value: ttlState },
|
||||||
|
{ label: "Value shape", value: valueShape },
|
||||||
|
{ label: "Write path", value: writePath },
|
||||||
|
{ label: "TTL control", value: ttlPath },
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!context.selectedKey) {
|
||||||
|
return {
|
||||||
|
status: "awaiting selection",
|
||||||
|
surfaceTitle: "No key selected",
|
||||||
|
surfaceDescription:
|
||||||
|
"Pick a key from the browser to load its metadata, value shape, and TTL state into the inspector.",
|
||||||
|
actionTitle: "Next step",
|
||||||
|
actionDescription:
|
||||||
|
"If the browser is empty, clear or adjust the current search so the inspector has a live target again.",
|
||||||
|
facts,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (context.runtimeMode === "demo") {
|
||||||
|
return {
|
||||||
|
status: context.isEditable ? "demo editable" : "demo read only",
|
||||||
|
surfaceTitle: buildSurfaceTitle(context.selectedKey.type, false),
|
||||||
|
surfaceDescription: context.isEditable
|
||||||
|
? "Browser dev mode is showing a session-local string payload. Layout review is safe here, but no live Redis mutation occurs."
|
||||||
|
: `${formatRedisKeyTypeLabel(context.selectedKey.type)} payloads stay on the demo inspection path in browser mode so the UI can be reviewed without pretending a live backend exists.`,
|
||||||
|
actionTitle: "Operator path",
|
||||||
|
actionDescription: context.isEditable
|
||||||
|
? "Use Save string value only for session-local shell review. Switch to the desktop runtime before treating the result as Redis evidence."
|
||||||
|
: "Review the current structure here, then use the desktop runtime when you need live Redis data or verification.",
|
||||||
|
facts,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (context.isLoading) {
|
||||||
|
return {
|
||||||
|
status: "loading",
|
||||||
|
surfaceTitle: `Loading ${formatRedisKeyTypeLabel(keyType)} value`,
|
||||||
|
surfaceDescription:
|
||||||
|
"The desktop bridge is reading the current key so the inspector can show live metadata, value shape, and write capability together.",
|
||||||
|
actionTitle: "Operator path",
|
||||||
|
actionDescription:
|
||||||
|
"Wait for the read to finish before editing or relying on the TTL state shown in the inspector.",
|
||||||
|
facts,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!context.record) {
|
||||||
|
return {
|
||||||
|
status: "metadata only",
|
||||||
|
surfaceTitle: `${formatRedisKeyTypeLabel(keyType)} metadata ready`,
|
||||||
|
surfaceDescription:
|
||||||
|
"The browser still has metadata for the selected key, but the live value surface is not currently loaded in the inspector.",
|
||||||
|
actionTitle: "Operator path",
|
||||||
|
actionDescription:
|
||||||
|
"Use Refresh metadata to retry the live read, or return to the browser if the key may have changed upstream.",
|
||||||
|
facts,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (context.record.data.kind === "missing") {
|
||||||
|
return {
|
||||||
|
status: "missing key",
|
||||||
|
surfaceTitle: "Key disappeared after browse",
|
||||||
|
surfaceDescription:
|
||||||
|
"The key was visible during browse but no longer existed when the inspector requested the live value.",
|
||||||
|
actionTitle: "Operator path",
|
||||||
|
actionDescription:
|
||||||
|
"Refresh metadata or reload the browser list to clear the stale selection before taking further action.",
|
||||||
|
facts,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (context.isEditable) {
|
||||||
|
return {
|
||||||
|
status: "editable string",
|
||||||
|
surfaceTitle: "String value",
|
||||||
|
surfaceDescription:
|
||||||
|
"The full live string payload is loaded in the inspector. Saving replaces the entire string value and preserves the active TTL boundary.",
|
||||||
|
actionTitle: "Operator path",
|
||||||
|
actionDescription:
|
||||||
|
"Review the full payload, make bounded edits, and use Save string value when you are ready to replace the current value.",
|
||||||
|
facts,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: "structured read only",
|
||||||
|
surfaceTitle: buildSurfaceTitle(context.record.data.kind, true),
|
||||||
|
surfaceDescription: buildStructuredDescription(context.record.data.kind),
|
||||||
|
actionTitle: "Operator path",
|
||||||
|
actionDescription:
|
||||||
|
"Use the structured panels to scan the live payload quickly. Mutations for this key type remain outside the current desktop contract.",
|
||||||
|
facts,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveKeyType(context: InspectorReadabilityContext) {
|
||||||
|
return context.record?.metadata.key_type ?? context.selectedKey?.type ?? "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveTtlState(context: InspectorReadabilityContext) {
|
||||||
|
if (context.record) {
|
||||||
|
return formatRedisKeyTtl(context.record.metadata.ttl);
|
||||||
|
}
|
||||||
|
|
||||||
|
return context.selectedKey?.ttl ?? "awaiting selection";
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeValueShape(context: InspectorReadabilityContext) {
|
||||||
|
if (!context.selectedKey) {
|
||||||
|
return "no active key";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (context.runtimeMode === "demo") {
|
||||||
|
return context.isEditable
|
||||||
|
? "session-local string payload"
|
||||||
|
: `${formatRedisKeyTypeLabel(context.selectedKey.type)} demo preview`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (context.isLoading) {
|
||||||
|
return "live value request in flight";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!context.record) {
|
||||||
|
return "metadata loaded, value unavailable";
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (context.record.data.kind) {
|
||||||
|
case "missing":
|
||||||
|
return "key missing";
|
||||||
|
case "string":
|
||||||
|
return "string payload";
|
||||||
|
case "hash":
|
||||||
|
return `${context.record.data.entries.length} field${context.record.data.entries.length === 1 ? "" : "s"}`;
|
||||||
|
case "list":
|
||||||
|
return `${context.record.data.items.length} item${context.record.data.items.length === 1 ? "" : "s"}`;
|
||||||
|
case "set":
|
||||||
|
return `${context.record.data.members.length} member${context.record.data.members.length === 1 ? "" : "s"}`;
|
||||||
|
case "sorted_set":
|
||||||
|
return `${context.record.data.entries.length} scored member${context.record.data.entries.length === 1 ? "" : "s"}`;
|
||||||
|
case "stream":
|
||||||
|
return `${context.record.data.entries.length} entr${context.record.data.entries.length === 1 ? "y" : "ies"}`;
|
||||||
|
default:
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeWritePath(context: InspectorReadabilityContext) {
|
||||||
|
if (!context.selectedKey) {
|
||||||
|
return "awaiting selection";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (context.runtimeMode === "demo") {
|
||||||
|
return context.isEditable ? "session-local string save" : "inspection only";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (context.isLoading) {
|
||||||
|
return "pending live capability";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!context.record) {
|
||||||
|
return "value unavailable";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (context.record.data.kind === "missing") {
|
||||||
|
return "not available";
|
||||||
|
}
|
||||||
|
|
||||||
|
return context.isEditable ? "replace full string" : "inspection only";
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSurfaceTitle(kind: string, liveStructured: boolean) {
|
||||||
|
switch (kind) {
|
||||||
|
case "hash":
|
||||||
|
return "Hash fields";
|
||||||
|
case "list":
|
||||||
|
return "List items";
|
||||||
|
case "set":
|
||||||
|
return liveStructured ? "Set members" : "Set preview";
|
||||||
|
case "sorted_set":
|
||||||
|
case "zset":
|
||||||
|
return "Sorted set members";
|
||||||
|
case "stream":
|
||||||
|
return "Stream entries";
|
||||||
|
case "string":
|
||||||
|
return "String value";
|
||||||
|
default:
|
||||||
|
return "Value surface";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildStructuredDescription(kind: string) {
|
||||||
|
switch (kind) {
|
||||||
|
case "hash":
|
||||||
|
return "Live HGETALL output is rendered as field/value rows so operators do not need to parse raw JSON during inspection.";
|
||||||
|
case "list":
|
||||||
|
return "Live LRANGE output is rendered in list order so operators can scan indexed items without leaving the inspector.";
|
||||||
|
case "set":
|
||||||
|
return "Live SMEMBERS output is rendered as a structured member list so the inspector stays readable even when the payload is dense.";
|
||||||
|
case "sorted_set":
|
||||||
|
return "Live ZRANGE output is rendered as member and score pairs so ordering semantics stay visible in the inspector.";
|
||||||
|
case "stream":
|
||||||
|
return "Live XRANGE output is rendered as entry rows with inline field payloads so stream structure is visible at a glance.";
|
||||||
|
default:
|
||||||
|
return "The live value is available on a structured read-only path in the current desktop contract.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRedisKeyTtl(ttl: RedisKeyTtl) {
|
||||||
|
switch (ttl.kind) {
|
||||||
|
case "persistent":
|
||||||
|
return "persistent";
|
||||||
|
case "missing":
|
||||||
|
return "missing";
|
||||||
|
case "expires_in_millis":
|
||||||
|
return formatDurationFromMillis(ttl.value);
|
||||||
|
default:
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDurationFromMillis(value: number) {
|
||||||
|
if (value < 1_000) {
|
||||||
|
return `${value}ms`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalSeconds = Math.floor(value / 1_000);
|
||||||
|
if (totalSeconds < 60) {
|
||||||
|
return `${totalSeconds}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalMinutes = Math.floor(totalSeconds / 60);
|
||||||
|
if (totalMinutes < 60) {
|
||||||
|
return `${totalMinutes}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalHours = Math.floor(totalMinutes / 60);
|
||||||
|
if (totalHours < 24) {
|
||||||
|
return `${totalHours}h`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalDays = Math.floor(totalHours / 24);
|
||||||
|
return `${totalDays}d`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRedisKeyTypeLabel(value: string) {
|
||||||
|
switch (value) {
|
||||||
|
case "zset":
|
||||||
|
return "sorted set";
|
||||||
|
default:
|
||||||
|
return value || "unknown";
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user