feat(desktop): harden connection session lifecycle

- add a visible connection session summary for ready/testing/demo/retry states
- prevent stale live connection tests from overwriting a newer active DB selection
- include the missing frontend helpers already referenced by App.tsx so the branch builds cleanly

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Senior Frontend Engineer
2026-03-31 09:32:47 +00:00
parent 35040ef0c9
commit 4839165657
13 changed files with 1233 additions and 87 deletions

View File

@@ -84,6 +84,7 @@ pnpm run desktop:tauri:dev
- 当前前端消费 `backend_bootstrap``test_redis_connection``browse_redis_keys``read_redis_value``create_redis_value``write_redis_value``update_redis_key_ttl``execute_redis_command`;浏览器态仍保持明确的 demo fallback。
- 当前仓库还包含一个 string-only 的 `Add key` 可见入口,走 `create_redis_value` 合约;该能力已在仓库中实现,但阶段验收仍需按 `docs/redis-gui-v1-product-requirements.md``docs/qa/acceptance-matrix.md` 单独判断,不应自动算入当前基线签收。
- 当前前端还通过共享错误映射层统一消费 `redis-core` 的稳定错误码使连接测试、浏览、读取、保存、TTL 和命令执行失败都落到一致的操作提示上。
- 连接区域现在额外暴露一个可见的 session state 卡片,把 `ready / testing / demo fallback / retry needed` 边界和下一步操作提示固定在当前选中 connection / DB 语境里;如果用户在 live test 返回前切换了连接,测试结果只回写被测连接,不会偷偷改掉当前 active DB。
- `inspect_redis_key``update_redis_key_ttl` 已在桌面桥可用;当前 UI 已暴露 TTL 设置与移除控制,但删除执行仍保持未开放状态。
- 当前 phase 的 string 保存、TTL 设置和 TTL 移除在执行前都会进入 scoped review重复展示 connection / DB / key 语境与 current-vs-next 变化,并阻止 no-op 提交直接触达 live bridge。
- inspector 现在对 `hash/list/set/zset/stream` 提供结构化只读面板,不再要求操作者从原始 JSON 文本中手动解析 typed value。

View File

@@ -3,6 +3,7 @@ import {
startTransition,
useDeferredValue,
useEffect,
useRef,
useState,
type ChangeEvent,
type FormEvent,
@@ -82,6 +83,14 @@ import {
updateDemoStringValue,
} from "./lib/demo-workspace";
import { buildDatabaseSwitcherLabels } from "./lib/database-switcher";
import {
applyConnectionTestFailure,
applyConnectionTestSuccess,
buildConnectionSessionSummary,
formatConnectionCardDetailLabel,
formatConnectionStatusLabel,
markConnectionTesting,
} from "./lib/connection-session";
import {
createDemoKeyRecord,
findDuplicateKeyName,
@@ -493,7 +502,9 @@ function App() {
const [addKeyDialogOpen, setAddKeyDialogOpen] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
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 [isBrowseLoading, setIsBrowseLoading] = useState(false);
const [isInspectorLoading, setIsInspectorLoading] = useState(false);
@@ -528,6 +539,9 @@ function App() {
const activeConnection =
connections.find((connection) => connection.id === activeConnectionId) ??
connections[0];
const activeConnectionIdRef = useRef(activeConnectionId);
const activeConnectionNameRef = useRef(activeConnection.name);
const activeDbRef = useRef(activeDb);
const databaseOptions = buildDatabaseSwitcherLabels(parseDatabaseLabel(activeDb));
const searchState = resolveKeyBrowserSearchState(search);
const deferredSearchState = resolveKeyBrowserSearchState(deferredSearch);
@@ -591,6 +605,20 @@ function App() {
ttlWriteSupported,
});
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 ttlDraftError = ttlDraft.trim() ? validateTtlDraft(ttlDraft) : null;
const nextTtlMillis =
@@ -660,6 +688,12 @@ function App() {
bannerMessage: banner.message,
});
useEffect(() => {
activeConnectionIdRef.current = activeConnectionId;
activeConnectionNameRef.current = activeConnection.name;
activeDbRef.current = activeDb;
}, [activeConnectionId, activeConnection.name, activeDb]);
useEffect(() => {
if (!tauriAvailable) {
startTransition(() => {
@@ -1538,23 +1572,14 @@ function App() {
const handleTestConnection = async () => {
const connection = activeConnection;
const request = toRedisConnectionRequest(connection, activeDb);
const databaseLabel = activeDb;
const request = toRedisConnectionRequest(connection, databaseLabel);
startTransition(() => {
setIsConnectionTestRunning(true);
setConnections((current) =>
current.map((item) =>
item.id === connection.id
? {
...item,
status: "checking",
detail: "Live test in progress",
}
: item,
),
);
setConnectionTestState({ connectionId: connection.id });
setConnections((current) => markConnectionTesting(current, connection.id));
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(
`Tauri runtime is unavailable in browser dev mode, so connection testing stayed in demo fallback for ${connection.name}.`,
);
setIsConnectionTestRunning(false);
setConnectionTestState(null);
});
return;
}
@@ -1586,46 +1611,46 @@ function App() {
startTransition(() => {
setConnections((current) =>
current.map((item) =>
item.id === connection.id
? {
...item,
status: "healthy",
detail: result.round_trip_status,
target: {
...item.target,
database: result.selected_database,
username: result.authenticated_as ?? item.target.username,
},
}
: item,
),
applyConnectionTestSuccess(current, connection.id, {
roundTripStatus: result.round_trip_status,
selectedDatabase: result.selected_database,
authenticatedAs: result.authenticated_as,
}),
);
setActiveDb(formatDatabaseLabel(result.selected_database));
showAccentBanner(
`Live Redis test passed for ${connection.name} on ${formatDatabaseLabel(result.selected_database)}. Round-trip status: ${result.round_trip_status}.`,
setConnectionTestState(null);
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) {
const errorCode = formatBackendErrorCodeLabel(toBackendError(error).code);
startTransition(() => {
setConnections((current) =>
current.map((item) =>
item.id === connection.id
? {
...item,
status: "error",
detail: formatBackendErrorCodeLabel(toBackendError(error).code),
}
: item,
),
setConnections((current) => applyConnectionTestFailure(current, connection.id, errorCode));
setConnectionTestState(null);
if (activeConnectionIdRef.current === connection.id) {
showBackendErrorBanner(error, {
operation: "connection_test",
connectionName: connection.name,
databaseLabel,
});
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
icon={<Activity className="h-4 w-4" />}
label="Status"
value={formatConnectionStatus(activeConnection)}
value={formatConnectionStatusLabel(activeConnection)}
/>
<StatusPill
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="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)]">
{formatConnectionCardDetail(connection)}
{formatConnectionCardDetailLabel(connection)}
</p>
</div>
<Badge variant={active ? "accent" : "neutral"}>{connection.environment}</Badge>
@@ -1958,9 +1983,32 @@ function App() {
onClick={handleTestConnection}
disabled={isConnectionTestRunning}
>
{isConnectionTestRunning ? "Testing..." : tauriAvailable ? "Test live" : "Test demo"}
{activeConnectionTestRunning
? "Testing..."
: isConnectionTestRunning
? "Test running elsewhere"
: tauriAvailable
? "Test live"
: "Test demo"}
</Button>
</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>
@@ -2888,38 +2936,6 @@ function formatEndpoint(target: ConnectionProfile["target"]) {
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) {
return code.replace(/_/g, " ");
}

View 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.",
});
});
});

View 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.",
};
}

View 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.",
});
});
});

View File

@@ -0,0 +1,175 @@
export type SessionConnection = {
id: string;
name: string;
detail: string;
status: "healthy" | "idle" | "checking" | "error";
target: {
database: number;
username: string | null;
};
};
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.",
};
}

View 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",
]);
});
});

View 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}`;
}

View 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" },
]),
}),
);
});
});

View File

@@ -0,0 +1,336 @@
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.record || context.isLoading) {
return "awaiting live read";
}
if (context.record.data.kind === "missing") {
return "not available";
}
return context.isEditable ? "replace full string" : "inspection only";
}
function buildSurfaceTitle(keyType: string, structured: boolean) {
if (!structured && keyType === "string") {
return "String value";
}
switch (keyType) {
case "hash":
return "Hash fields";
case "list":
return "List items";
case "set":
return "Set members";
case "sorted_set":
return "Sorted set members";
case "stream":
return "Stream entries";
case "string":
return "String value";
default:
return "Value surface";
}
}
function buildStructuredDescription(kind: RedisValueData["kind"]) {
switch (kind) {
case "hash":
return "Fields and values are grouped into readable rows so operators can inspect the live hash without falling back to raw JSON.";
case "list":
return "List items stay ordered in the inspector and remain read only in the current desktop contract.";
case "set":
return "Set members are shown as a structured read-only collection to keep support work fast without implying mutation support.";
case "sorted_set":
return "Members and scores are grouped together for scanability while sorted-set mutation stays outside the current desktop scope.";
case "stream":
return "Stream entries remain inspectable in grouped rows so operators can confirm IDs and field payloads without CLI fallback.";
default:
return "This value remains readable in the inspector, but mutation support is outside the current desktop contract.";
}
}
function formatRedisKeyTypeLabel(type: string) {
switch (type) {
case "sorted_set":
return "zset";
default:
return type;
}
}
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(milliseconds: number) {
if (milliseconds < 1000) {
return `${milliseconds}ms`;
}
const totalSeconds = Math.round(milliseconds / 1000);
if (totalSeconds < 60) {
return `${totalSeconds}s`;
}
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
if (minutes < 60) {
return seconds === 0 ? `${minutes}m` : `${minutes}m ${seconds}s`;
}
const hours = Math.floor(minutes / 60);
const remainingMinutes = minutes % 60;
return remainingMinutes === 0 ? `${hours}h` : `${hours}h ${remainingMinutes}m`;
}

View File

@@ -45,6 +45,7 @@ Use a four-zone workspace:
- Connection library
- New connection form entry point
- Active connection session summary with clear ready/testing/demo/retry states
- Active DB switcher
- Safety posture and backend boundary summary
- Local smoke fixture assist for repeatable QA runs
@@ -91,6 +92,8 @@ Use a four-zone workspace:
- Tauri desktop mode now allows live connection tests, live key browsing, live value inspection, string save for supported keys, live TTL updates, and live command execution while keeping browser dev mode on explicit demo fallback.
- Non-string live values now render in structured read-only panels for `hash`, `list`, `set`, `zset`, and `stream`, so operators can scan typed payloads without reading raw JSON dumps.
- The visible shell now validates draft-connection name, host, port, and DB input before creation, and prevents duplicate profile names inside the local shell state.
- The active connection area now also exposes a dedicated session summary so operators can see whether the current scope is ready, testing, demo-only, or waiting for retry without inferring that state from scattered card copy.
- Live connection test completion now only reapplies the returned DB scope when the same connection is still active, preventing a stale test response from silently replacing the operator's newer active workspace selection.
- The DB switcher now exposes a predictable default operator range from `db0` through `db15` and keeps an out-of-range active DB visible instead of depending on a four-item hardcoded list.
- Zero-result key searches now place the inspector into an explicit empty state instead of leaving a stale key visible.
- Frontend error handling now routes stable backend error codes through a shared operator-facing notice layer so live failures render with consistent copy and banner tone across connection, browse, inspect, write, TTL, and command flows.
@@ -106,6 +109,7 @@ Use a four-zone workspace:
- The utility rail exposes a copyable QA snapshot with runtime, connection, selection, and latest command context.
- The utility rail exposes the seeded local smoke fixture path and safe smoke shortcuts without requiring manual re-entry from the runbook.
- The active connection and active DB stay visible without opening a modal.
- The active connection area shows a dedicated session summary with explicit ready, testing, demo fallback, or retry-needed wording plus a visible next-step hint.
- The DB switcher exposes `db0` through `db15` by default and still keeps the current DB visible when the active scope falls outside that range.
- Search filters the browser list and shows a no-results state.
- Search now exposes explicit mode feedback so operators can see whether the current input is being treated as a contains search or a raw Redis pattern.
@@ -113,6 +117,7 @@ Use a four-zone workspace:
- When search returns zero keys, the inspector switches to an empty state instead of showing the last selected key.
- Empty browser and inspector states now distinguish between an empty DB and a filtered no-match result instead of using one generic no-results sentence.
- The connection action distinguishes desktop live testing from browser demo fallback.
- If a live connection test finishes after the operator switches to a different connection, the tested connection card updates but the newly active DB scope remains unchanged.
- Invalid draft connections cannot be created from the visible shell.
- Live backend failures are shown through a consistent banner treatment instead of per-surface ad hoc copy.
- Non-string keys are explicitly read-only.

View File

@@ -6,7 +6,7 @@ Owner: QA Engineer
## Evidence Collected On 2026-03-31
- `cargo test -p redis-core` passes in this session with 20 tests green.
- `pnpm --filter @redis-gui/desktop test` passes in this session with 11 files and 44 tests green.
- `pnpm --filter @redis-gui/desktop test` passes in this session with 12 files and 47 tests green.
- `pnpm run desktop:build` passes and emits `apps/desktop/dist/`.
- `pnpm run desktop:dev` starts Vite on `http://127.0.0.1:1420/`, and the served HTML points to the desktop shell entrypoint.
- `pnpm run desktop:tauri:build` now completes Linux `.deb` and `.rpm` artifact generation under `target/release/bundle/`.
@@ -20,6 +20,7 @@ Owner: QA Engineer
- Browser dev mode still uses demo data for shell review, while Tauri desktop mode can now exercise live connection-test, key browse, value read/write, and command-execution bridges.
- The checked-out desktop shell now also exposes a visible string-only `Add key` flow through `create_redis_value`, but PM phase notes still treat that capability as next-phase inventory rather than a required current-baseline acceptance item.
- Browser dev mode now also keeps mock string-save and TTL changes visible for the current session, reducing shell-review drift between action banners and visible UI state without changing the live acceptance boundary.
- The active connection area now shows an explicit session summary for ready, testing, demo fallback, and retry-needed states, and live test completion no longer rewrites the current active DB if the operator already switched to another connection while the probe was running.
- The desktop DB switcher now exposes a default `db0` through `db15` range and keeps an active out-of-range DB visible in the selector.
- A repeatable local Redis fixture and smoke runbook now exist in `scripts/redis-fixture/` and `docs/qa/local-desktop-smoke.md`.
- `docs/frontend-workspace-baseline.md` and `docs/redis-gui-v1-product-requirements.md` now both exist in-repo.
@@ -32,7 +33,7 @@ Owner: QA Engineer
| macOS app smoke | Same as Linux, using signed or documented macOS artifact | Same failure paths, plus first-run permission or gatekeeper guidance if relevant | Build artifact name, install steps, screenshots, QA run log | Blocked: no validated macOS build or artifact evidence |
| Windows app smoke | Same as Linux, using installer or portable package | Same failure paths, plus Windows-specific install and uninstall validation | Build artifact name, install steps, screenshots, QA run log | Blocked: no validated Windows build or artifact evidence |
| Desktop workspace shell | Rail, browser, inspector, and command tray render together with active connection, build identity, runtime mode, a copyable QA snapshot, local smoke assist, visible inspector summary/key facts surfaces, and scoped mutation review for current-phase write actions | Empty search state clears the inspector selection, search mode feedback and clear action stay visible and predictable, read-only non-string value state, inspector status/action guidance stays aligned to live vs demo vs missing cases, destructive confirmation, no-op write attempts are blocked before execution, and browser-mode demo fallback stays explicit while mock save and TTL actions update session-local UI state | `npm run desktop:dev` or `npm run desktop:build`, `docs/frontend-workspace-baseline.md`, screenshots or copied QA snapshot text | Pass for shell-level evidence; not a substitute for Redis feature acceptance |
| Connection management | Create a validated draft profile, keep DB context visible, switch among a predictable default DB range, and run the existing live connection-test bridge from Tauri desktop mode | Invalid host, bad port, bad password, duplicate name, timeout, and unreachable server are handled with consistent operator-facing messaging | Tauri run log, screenshots, repeatable steps, Redis target | Partially blocked: the shell now validates draft name/host/port/DB locally, exposes `db0` through `db15` while preserving active out-of-range DB context, maps backend error codes into shared notices, and calls the real connection-test command, but profile persistence and full evidence capture are not complete |
| Connection management | Create a validated draft profile, keep DB context visible, switch among a predictable default DB range, run the existing live connection-test bridge from Tauri desktop mode, and keep the active session state visibly scoped as ready, testing, demo fallback, or retry needed | Invalid host, bad port, bad password, duplicate name, timeout, and unreachable server are handled with consistent operator-facing messaging, and a late-arriving test result must not silently replace a newer active connection/DB selection | Tauri run log, screenshots, repeatable steps, Redis target | Partially blocked: the shell now validates draft name/host/port/DB locally, exposes `db0` through `db15` while preserving active out-of-range DB context, maps backend error codes into shared notices, keeps session-state guidance visible, and calls the real connection-test command without reapplying stale DB scope after an operator switch, but profile persistence and full evidence capture are not complete |
| Key browsing | Browse DBs and keys, paginate or virtualize large lists, view metadata | Empty DB, deleted key during browse, permission error, and connection drop do not crash UI, and live failures reuse the shared error banner treatment | Seed dataset, result screenshots, observed limits | Partially blocked: desktop UI now calls the live browse bridge with `SCAN` pagination and metadata, but fixture-backed Tauri screenshots and measured limits are still pending |
| Search | Search by key name or pattern and return matching set within documented limits; visible shell makes it clear whether the current input is treated as a contains search or a raw Redis pattern | No matches, invalid pattern, empty DB state, and very large result sets are handled predictably, and operators can clear search back to browse state without ambiguity | Seed dataset, search queries, measured behavior | Partially blocked: desktop UI now maps search to live browse requests and exposes clearer search-state feedback, but fixture-backed evidence and measured limits are still pending |
| Value editing | Open supported value types, review pending string replacement, confirm save, and refresh persisted value; inspector keeps current state and write semantics explicit while editing | Concurrent modification, invalid payload, missing-key transitions, and permission error preserve user context, while no-op edits are blocked before execution and live failures reuse the shared operator notice layer | Before/after values, screenshots, repeatable steps | Partially blocked: string read/save/refresh is wired through the live desktop bridge, non-string types now render in structured read-only inspector panels, inspector guidance is clearer across live and fallback states, scoped review now clarifies current-vs-next value before save, and browser demo mode keeps session-local save state coherent, but fixture-backed live evidence and broader editing scope remain pending |

View File

@@ -0,0 +1,46 @@
# HAC-28 Connection Context Lifecycle
Date: 2026-03-31
Owner: Senior Frontend Engineer
Issue: HAC-28
## Goal
Harden connection-context and session-state clarity without changing backend contracts or pulling next-phase scope into `desktop-v1`.
## Scope
- keep connection status copy consistent between the header, connection cards, and the active-session summary
- make ready, checking, demo, and retry-needed states visibly distinct in the utility rail
- prevent a finished live connection test from yanking the current active DB after the operator has already switched to another connection
- keep retry guidance explicit when a live test fails
- add focused frontend tests around the connection-session helper
## Non-Goals
- no Add Key expansion
- no delete execution
- no non-string editing
- no visual refresh
- no backend contract changes
## Delivery Shape
1. Add a shared connection-session helper for:
- connection test state transitions
- consistent status and connection-card copy
- visible active-session summary copy
2. Update `apps/desktop/src/App.tsx` so:
- the utility rail shows a clear active connection session card
- the test button distinguishes active-scope testing from another in-flight connection test
- live test completion only reapplies the returned DB scope when the same connection is still active
3. Revalidate frontend tests and production build.
## Acceptance
- connection session state is visible as ready, testing, demo fallback, or retry needed
- connection-card detail and header status use the same wording model
- switching to another connection during a live test does not silently replace the current active DB when the earlier test returns
- failed live tests leave a clear retry path in visible UI copy
- `pnpm --filter @redis-gui/desktop test` passes
- `pnpm run desktop:build` passes