Compare commits
2 Commits
cmp-22-db-
...
cmp-30-ins
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
969744733f | ||
|
|
bf6f8bc0bf |
@@ -83,5 +83,6 @@ pnpm run desktop:tauri:dev
|
||||
- 当前前端还通过共享错误映射层统一消费 `redis-core` 的稳定错误码,使连接测试、浏览、读取、保存、TTL 和命令执行失败都落到一致的操作提示上。
|
||||
- `inspect_redis_key` 与 `update_redis_key_ttl` 已在桌面桥可用;当前 UI 已暴露 TTL 设置与移除控制,但删除执行仍保持未开放状态。
|
||||
- inspector 现在对 `hash/list/set/zset/stream` 提供结构化只读面板,不再要求操作者从原始 JSON 文本中手动解析 typed value。
|
||||
- inspector 现在还会明确显示当前 inspect 状态、value surface 语义、下一步操作路径和 key facts,降低 string、structured、missing 和 demo fallback 场景下的误读成本。
|
||||
- 连接测试、key browse、value inspector 和命令托盘在 Tauri 桌面运行时走真实后端桥;浏览器态 `pnpm run desktop:dev` 会明确回退到 demo 数据,不伪装成真实 Redis 行为。
|
||||
- 具体 UI/UX 边界与 QA 验收入口见 `docs/frontend-workspace-baseline.md` 和 `docs/qa/acceptance-matrix.md`。
|
||||
|
||||
@@ -57,6 +57,10 @@ import {
|
||||
buildStructuredInspectorSections,
|
||||
type StructuredInspectorSection,
|
||||
} from "./lib/structured-inspector";
|
||||
import {
|
||||
buildInspectorReadabilitySummary,
|
||||
type InspectorFact,
|
||||
} from "./lib/inspector-readability";
|
||||
import {
|
||||
appendWorkspaceKeyRecords,
|
||||
filterWorkspaceKeys,
|
||||
@@ -64,13 +68,18 @@ import {
|
||||
resolveSelectedWorkspaceKey,
|
||||
resolveSelectedWorkspaceKeyId,
|
||||
} from "./lib/key-workspace-state";
|
||||
import {
|
||||
buildInspectorEmptyStateMessage,
|
||||
buildKeyBrowserEmptyStateMessage,
|
||||
buildKeyBrowserStatusMessage,
|
||||
resolveKeyBrowserSearchState,
|
||||
} from "./lib/key-browser-search";
|
||||
import {
|
||||
parseDemoTtlToSeconds,
|
||||
persistDemoKeyTtl,
|
||||
updateDemoKeyTtl,
|
||||
updateDemoStringValue,
|
||||
} from "./lib/demo-workspace";
|
||||
import { buildDatabaseSwitcherLabels } from "./lib/database-switcher";
|
||||
import { cn } from "./lib/utils";
|
||||
|
||||
declare const __APP_VERSION__: string;
|
||||
@@ -358,6 +367,8 @@ const initialDemoKeys: KeyRecord[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const databases = ["db0", "db1", "db2", "db5"];
|
||||
|
||||
const fallbackBootstrap: BackendBootstrap = {
|
||||
app_name: "Redis GUI Foundation",
|
||||
surface: "desktop",
|
||||
@@ -395,7 +406,7 @@ function App() {
|
||||
const [connections, setConnections] = useState(initialConnections);
|
||||
const [demoKeys, setDemoKeys] = useState(initialDemoKeys);
|
||||
const [activeConnectionId, setActiveConnectionId] = useState(initialConnections[0].id);
|
||||
const [activeDb, setActiveDb] = useState("db0");
|
||||
const [activeDb, setActiveDb] = useState(databases[0]);
|
||||
const [search, setSearch] = useState("");
|
||||
const [selectedKeyId, setSelectedKeyId] = useState<string | null>(initialDemoKeys[1].id);
|
||||
const [draftValue, setDraftValue] = useState(initialDemoKeys[1].value);
|
||||
@@ -442,9 +453,10 @@ function App() {
|
||||
const activeConnection =
|
||||
connections.find((connection) => connection.id === activeConnectionId) ??
|
||||
connections[0];
|
||||
const databaseOptions = buildDatabaseSwitcherLabels(parseDatabaseLabel(activeDb));
|
||||
|
||||
const filteredDemoKeys = filterWorkspaceKeys(demoKeys, deferredSearch);
|
||||
const searchState = resolveKeyBrowserSearchState(search);
|
||||
const deferredSearchState = resolveKeyBrowserSearchState(deferredSearch);
|
||||
const filteredDemoKeys = filterWorkspaceKeys(demoKeys, deferredSearchState.trimmedQuery);
|
||||
const filteredKeys = tauriAvailable ? liveKeys : filteredDemoKeys;
|
||||
const draftErrors = validateConnectionDraft(connectionDraft, connections);
|
||||
const draftErrorList = Object.values(draftErrors);
|
||||
@@ -467,14 +479,25 @@ function App() {
|
||||
!isInspectorLoading &&
|
||||
structuredInspectorSections.length > 0 &&
|
||||
!isEditable;
|
||||
|
||||
const supportNote = tauriAvailable
|
||||
? buildLiveSupportNote(selectedValueRecord, selectedKey)
|
||||
: (selectedKey?.summary ??
|
||||
`Search currently returns zero keys in ${activeDb}. This is an intentional empty state, not a backend failure.`);
|
||||
const ttlWriteSupported = tauriAvailable
|
||||
? selectedValueRecord?.ttl_write_supported === true
|
||||
: selectedKey !== null;
|
||||
const inspectorReadability = buildInspectorReadabilitySummary({
|
||||
databaseLabel: activeDb,
|
||||
runtimeMode: tauriAvailable ? "live" : "demo",
|
||||
selectedKey:
|
||||
selectedKey === null
|
||||
? null
|
||||
: {
|
||||
name: selectedKey.name,
|
||||
type: selectedKey.type,
|
||||
ttl: selectedKey.ttl,
|
||||
},
|
||||
record: selectedValueRecord,
|
||||
isLoading: isInspectorLoading,
|
||||
isEditable,
|
||||
ttlWriteSupported,
|
||||
});
|
||||
const localSmokeFixtureActive = isLocalSmokeFixtureConnection(activeConnection);
|
||||
const localSmokeFilterActive = search.trim() === localSmokeFixtureSearchText;
|
||||
const ttlDraftError = ttlDraft.trim() ? validateTtlDraft(ttlDraft) : null;
|
||||
@@ -492,6 +515,22 @@ function App() {
|
||||
? selectedValueRecord?.metadata.ttl.kind !== "persistent"
|
||||
: selectedKey?.ttl !== "persistent");
|
||||
const latestConsoleEntry = consoleHistory[0] ?? null;
|
||||
const keyBrowserStatusMessage = buildKeyBrowserStatusMessage({
|
||||
databaseLabel: activeDb,
|
||||
visibleCount: filteredKeys.length,
|
||||
hasMore: tauriAvailable && liveHasMore,
|
||||
isLoading: isBrowseLoading,
|
||||
search: deferredSearchState,
|
||||
});
|
||||
const keyBrowserEmptyStateMessage = buildKeyBrowserEmptyStateMessage({
|
||||
databaseLabel: activeDb,
|
||||
isLoading: isBrowseLoading,
|
||||
search: deferredSearchState,
|
||||
});
|
||||
const inspectorEmptyStateMessage = buildInspectorEmptyStateMessage(
|
||||
activeDb,
|
||||
deferredSearchState,
|
||||
);
|
||||
const qaSnapshot = buildQaSnapshot({
|
||||
appName: bootstrap.app_name,
|
||||
appVersion,
|
||||
@@ -564,7 +603,7 @@ function App() {
|
||||
const request: RedisKeyBrowseRequest = {
|
||||
connection: toRedisConnectionRequest(activeConnection, activeDb),
|
||||
cursor: "0",
|
||||
pattern: toRedisSearchPattern(deferredSearch),
|
||||
pattern: deferredSearchState.requestPattern,
|
||||
page_size: browsePageSize,
|
||||
};
|
||||
|
||||
@@ -618,7 +657,7 @@ function App() {
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [activeConnectionId, activeDb, deferredSearch, tauriAvailable]);
|
||||
}, [activeConnectionId, activeDb, deferredSearchState.requestPattern, tauriAvailable]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!tauriAvailable) {
|
||||
@@ -1129,7 +1168,7 @@ function App() {
|
||||
request: {
|
||||
connection: toRedisConnectionRequest(activeConnection, activeDb),
|
||||
cursor: liveNextCursor,
|
||||
pattern: toRedisSearchPattern(deferredSearch),
|
||||
pattern: deferredSearchState.requestPattern,
|
||||
page_size: browsePageSize,
|
||||
} satisfies RedisKeyBrowseRequest,
|
||||
});
|
||||
@@ -1583,7 +1622,7 @@ function App() {
|
||||
<section>
|
||||
<SectionEyebrow icon={<Database className="h-4 w-4" />} label="Database switcher" />
|
||||
<div className="mt-3 grid gap-2">
|
||||
{databaseOptions.map((database) => {
|
||||
{databases.map((database) => {
|
||||
const active = database === activeDb;
|
||||
|
||||
return (
|
||||
@@ -1732,7 +1771,25 @@ function App() {
|
||||
className="w-full bg-transparent text-sm text-[var(--ink-0)] outline-none placeholder:text-[var(--ink-muted)]"
|
||||
placeholder="Search key name or pattern"
|
||||
/>
|
||||
{searchState.mode === "all" ? null : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setSearch("")}
|
||||
className="shrink-0 px-2 py-1 text-xs"
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</label>
|
||||
<div className="flex items-center justify-between gap-3 text-xs text-[var(--ink-muted)]">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="neutral" className="normal-case tracking-normal">
|
||||
{deferredSearchState.mode === "all" ? "all keys" : deferredSearchState.mode === "pattern" ? "pattern" : "contains"}
|
||||
</Badge>
|
||||
<p>{keyBrowserStatusMessage}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 min-h-0 flex-1 space-y-2 overflow-auto pr-1">
|
||||
@@ -1770,9 +1827,7 @@ function App() {
|
||||
|
||||
{filteredKeys.length === 0 ? (
|
||||
<div className="rounded-[24px] border border-dashed border-[var(--line-strong)] bg-[var(--surface-base)] px-4 py-8 text-center text-sm text-[var(--ink-soft)]">
|
||||
{isBrowseLoading
|
||||
? `Loading keys from ${activeDb}...`
|
||||
: `No keys match the current pattern in ${activeDb}.`}
|
||||
{keyBrowserEmptyStateMessage}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -1803,6 +1858,7 @@ function App() {
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="neutral">{selectedKey.type}</Badge>
|
||||
<Badge variant="neutral">TTL {selectedKey.ttl}</Badge>
|
||||
<Badge variant="accent">{inspectorReadability.status}</Badge>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -1812,16 +1868,19 @@ function App() {
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-sm font-medium text-[var(--ink-0)]">Value surface</p>
|
||||
<span className="font-mono text-xs text-[var(--ink-muted)]">
|
||||
{selectedKey
|
||||
? isInspectorLoading
|
||||
? "loading"
|
||||
: isEditable
|
||||
? "editable"
|
||||
: "read only"
|
||||
: "awaiting selection"}
|
||||
{inspectorReadability.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 rounded-[20px] border border-[var(--line-soft)] bg-[var(--surface-muted)] px-4 py-3">
|
||||
<p className="text-sm font-medium text-[var(--ink-0)]">
|
||||
{inspectorReadability.surfaceTitle}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-[var(--ink-soft)]">
|
||||
{inspectorReadability.surfaceDescription}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{selectedKey ? (
|
||||
showStructuredInspector ? (
|
||||
<StructuredInspectorPanels sections={structuredInspectorSections} />
|
||||
@@ -1839,7 +1898,7 @@ function App() {
|
||||
)
|
||||
) : (
|
||||
<div className="mt-3 flex h-64 items-center justify-center rounded-[22px] border border-dashed border-[var(--line-strong)] bg-[var(--surface-muted)] px-6 text-center text-sm text-[var(--ink-soft)]">
|
||||
No key matches the current pattern in {activeDb}. Clear or adjust the search to restore an inspector target.
|
||||
{inspectorEmptyStateMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1899,17 +1958,29 @@ function App() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 rounded-[20px] border border-dashed border-[var(--line-soft)] bg-[var(--surface-muted)] px-4 py-3">
|
||||
<p className="font-mono text-[11px] uppercase tracking-[0.18em] text-[var(--ink-muted)]">
|
||||
{inspectorReadability.actionTitle}
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-[var(--ink-soft)]">
|
||||
{inspectorReadability.actionDescription}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<InfoCard
|
||||
title="Key metadata"
|
||||
<InspectorSummaryCard
|
||||
title="Inspect summary"
|
||||
icon={<CircleAlert className="h-4 w-4" />}
|
||||
surfaceDescription={inspectorReadability.surfaceDescription}
|
||||
actionTitle={inspectorReadability.actionTitle}
|
||||
actionDescription={inspectorReadability.actionDescription}
|
||||
/>
|
||||
<InspectorFactsCard
|
||||
title="Key facts"
|
||||
icon={<TimerReset className="h-4 w-4" />}
|
||||
lines={[
|
||||
`Active DB: ${activeDb}`,
|
||||
`TTL: ${selectedKey?.ttl ?? "no active key"}`,
|
||||
`Editor support: ${selectedKey ? (isEditable ? "string write enabled" : "inspection only") : "awaiting matching selection"}`,
|
||||
]}
|
||||
facts={inspectorReadability.facts}
|
||||
/>
|
||||
<div className="rounded-[24px] border border-[var(--line-soft)] bg-[var(--surface-base)] p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -1974,11 +2045,6 @@ function App() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<InfoCard
|
||||
title="Support note"
|
||||
icon={<CircleAlert className="h-4 w-4" />}
|
||||
lines={[supportNote]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2110,6 +2176,59 @@ function InfoCard({
|
||||
);
|
||||
}
|
||||
|
||||
function InspectorSummaryCard({
|
||||
title,
|
||||
icon,
|
||||
surfaceDescription,
|
||||
actionTitle,
|
||||
actionDescription,
|
||||
}: {
|
||||
title: string;
|
||||
icon: ReactElement;
|
||||
surfaceDescription: string;
|
||||
actionTitle: string;
|
||||
actionDescription: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-[24px] border border-[var(--line-soft)] bg-[var(--surface-base)] p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[var(--accent-strong)]">{icon}</span>
|
||||
<p className="text-sm font-medium text-[var(--ink-0)]">{title}</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 space-y-4">
|
||||
<InfoLine label="Current path" value={surfaceDescription} />
|
||||
<InfoLine label={actionTitle} value={actionDescription} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InspectorFactsCard({
|
||||
title,
|
||||
icon,
|
||||
facts,
|
||||
}: {
|
||||
title: string;
|
||||
icon: ReactElement;
|
||||
facts: InspectorFact[];
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-[24px] border border-[var(--line-soft)] bg-[var(--surface-base)] p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[var(--accent-strong)]">{icon}</span>
|
||||
<p className="text-sm font-medium text-[var(--ink-0)]">{title}</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid gap-3 sm:grid-cols-2">
|
||||
{facts.map((fact) => (
|
||||
<InfoLine key={fact.label} label={fact.label} value={fact.value} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StructuredInspectorPanels({
|
||||
sections,
|
||||
}: {
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,29 +0,0 @@
|
||||
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";
|
||||
}
|
||||
}
|
||||
83
apps/desktop/src/lib/key-browser-search.test.ts
Normal file
83
apps/desktop/src/lib/key-browser-search.test.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildInspectorEmptyStateMessage,
|
||||
buildKeyBrowserEmptyStateMessage,
|
||||
buildKeyBrowserStatusMessage,
|
||||
resolveKeyBrowserSearchState,
|
||||
} from "./key-browser-search";
|
||||
|
||||
describe("key browser search helpers", () => {
|
||||
it("classifies blank, contains, and wildcard search input", () => {
|
||||
expect(resolveKeyBrowserSearchState(" ")).toEqual({
|
||||
trimmedQuery: "",
|
||||
requestPattern: null,
|
||||
mode: "all",
|
||||
});
|
||||
|
||||
expect(resolveKeyBrowserSearchState(" smoke ")).toEqual({
|
||||
trimmedQuery: "smoke",
|
||||
requestPattern: "*smoke*",
|
||||
mode: "contains",
|
||||
});
|
||||
|
||||
expect(resolveKeyBrowserSearchState("smoke:*")).toEqual({
|
||||
trimmedQuery: "smoke:*",
|
||||
requestPattern: "smoke:*",
|
||||
mode: "pattern",
|
||||
});
|
||||
});
|
||||
|
||||
it("builds a stable status message for visible counts and load state", () => {
|
||||
expect(
|
||||
buildKeyBrowserStatusMessage({
|
||||
databaseLabel: "db0",
|
||||
visibleCount: 7,
|
||||
hasMore: true,
|
||||
isLoading: false,
|
||||
search: resolveKeyBrowserSearchState("smoke"),
|
||||
}),
|
||||
).toBe('7+ keys visible for contains search "smoke" in db0.');
|
||||
|
||||
expect(
|
||||
buildKeyBrowserStatusMessage({
|
||||
databaseLabel: "db3",
|
||||
visibleCount: 0,
|
||||
hasMore: false,
|
||||
isLoading: true,
|
||||
search: resolveKeyBrowserSearchState("smoke:*"),
|
||||
}),
|
||||
).toBe('Searching db3 for "smoke:*".');
|
||||
});
|
||||
|
||||
it("builds browser empty-state copy for blank and filtered results", () => {
|
||||
expect(
|
||||
buildKeyBrowserEmptyStateMessage({
|
||||
databaseLabel: "db0",
|
||||
isLoading: false,
|
||||
search: resolveKeyBrowserSearchState(""),
|
||||
}),
|
||||
).toBe("No keys are visible in db0 yet.");
|
||||
|
||||
expect(
|
||||
buildKeyBrowserEmptyStateMessage({
|
||||
databaseLabel: "db2",
|
||||
isLoading: false,
|
||||
search: resolveKeyBrowserSearchState("audit:*"),
|
||||
}),
|
||||
).toBe('No keys match "audit:*" in db2.');
|
||||
});
|
||||
|
||||
it("keeps inspector empty-state guidance aligned with browser search state", () => {
|
||||
expect(
|
||||
buildInspectorEmptyStateMessage("db1", resolveKeyBrowserSearchState("")),
|
||||
).toBe(
|
||||
"No keys are visible in db1 yet. Browse another database or refresh after data arrives.",
|
||||
);
|
||||
|
||||
expect(
|
||||
buildInspectorEmptyStateMessage("db4", resolveKeyBrowserSearchState("jobs")),
|
||||
).toBe(
|
||||
'No keys match "jobs" in db4. Clear or adjust the search to restore an inspector target.',
|
||||
);
|
||||
});
|
||||
});
|
||||
100
apps/desktop/src/lib/key-browser-search.ts
Normal file
100
apps/desktop/src/lib/key-browser-search.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
export type KeyBrowserSearchMode = "all" | "contains" | "pattern";
|
||||
|
||||
export type KeyBrowserSearchState = {
|
||||
trimmedQuery: string;
|
||||
requestPattern: string | null;
|
||||
mode: KeyBrowserSearchMode;
|
||||
};
|
||||
|
||||
type KeyBrowserFeedbackContext = {
|
||||
databaseLabel: string;
|
||||
visibleCount: number;
|
||||
hasMore: boolean;
|
||||
isLoading: boolean;
|
||||
search: KeyBrowserSearchState;
|
||||
};
|
||||
|
||||
export function resolveKeyBrowserSearchState(value: string): KeyBrowserSearchState {
|
||||
const trimmedQuery = value.trim();
|
||||
if (!trimmedQuery) {
|
||||
return {
|
||||
trimmedQuery: "",
|
||||
requestPattern: null,
|
||||
mode: "all",
|
||||
};
|
||||
}
|
||||
|
||||
if (/[*?\[\]]/.test(trimmedQuery)) {
|
||||
return {
|
||||
trimmedQuery,
|
||||
requestPattern: trimmedQuery,
|
||||
mode: "pattern",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
trimmedQuery,
|
||||
requestPattern: `*${trimmedQuery}*`,
|
||||
mode: "contains",
|
||||
};
|
||||
}
|
||||
|
||||
export function buildKeyBrowserStatusMessage({
|
||||
databaseLabel,
|
||||
visibleCount,
|
||||
hasMore,
|
||||
isLoading,
|
||||
search,
|
||||
}: KeyBrowserFeedbackContext) {
|
||||
if (isLoading) {
|
||||
if (search.mode === "all") {
|
||||
return `Loading keys from ${databaseLabel}.`;
|
||||
}
|
||||
|
||||
return `Searching ${databaseLabel} for ${formatSearchLabel(search)}.`;
|
||||
}
|
||||
|
||||
const visibleLabel = `${visibleCount}${hasMore ? "+" : ""}`;
|
||||
|
||||
if (search.mode === "all") {
|
||||
return `${visibleLabel} keys visible in ${databaseLabel}.`;
|
||||
}
|
||||
|
||||
const searchLabel =
|
||||
search.mode === "pattern"
|
||||
? `pattern ${formatSearchLabel(search)}`
|
||||
: `contains search ${formatSearchLabel(search)}`;
|
||||
|
||||
return `${visibleLabel} keys visible for ${searchLabel} in ${databaseLabel}.`;
|
||||
}
|
||||
|
||||
export function buildKeyBrowserEmptyStateMessage({
|
||||
databaseLabel,
|
||||
isLoading,
|
||||
search,
|
||||
}: Omit<KeyBrowserFeedbackContext, "visibleCount" | "hasMore">) {
|
||||
if (isLoading) {
|
||||
return `Loading keys from ${databaseLabel}...`;
|
||||
}
|
||||
|
||||
if (search.mode === "all") {
|
||||
return `No keys are visible in ${databaseLabel} yet.`;
|
||||
}
|
||||
|
||||
return `No keys match ${formatSearchLabel(search)} in ${databaseLabel}.`;
|
||||
}
|
||||
|
||||
export function buildInspectorEmptyStateMessage(
|
||||
databaseLabel: string,
|
||||
search: KeyBrowserSearchState,
|
||||
) {
|
||||
if (search.mode === "all") {
|
||||
return `No keys are visible in ${databaseLabel} yet. Browse another database or refresh after data arrives.`;
|
||||
}
|
||||
|
||||
return `No keys match ${formatSearchLabel(search)} in ${databaseLabel}. Clear or adjust the search to restore an inspector target.`;
|
||||
}
|
||||
|
||||
function formatSearchLabel(search: KeyBrowserSearchState) {
|
||||
return `"${search.trimmedQuery}"`;
|
||||
}
|
||||
@@ -91,6 +91,7 @@ Use a four-zone workspace:
|
||||
- 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.
|
||||
- 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.
|
||||
- The inspector now also renders a dedicated readability layer with explicit inspect status, value-surface semantics, next-step guidance, and key facts so string, structured, missing, and demo-fallback states are easier to interpret.
|
||||
|
||||
## QA Acceptance For This Milestone
|
||||
|
||||
@@ -105,6 +106,7 @@ Use a four-zone workspace:
|
||||
- Live backend failures are shown through a consistent banner treatment instead of per-surface ad hoc copy.
|
||||
- Non-string keys are explicitly read-only.
|
||||
- Non-string keys render as structured typed panels instead of raw JSON text blobs.
|
||||
- The inspector keeps current status, value semantics, action guidance, and key facts visible without requiring operators to infer meaning from badges or raw payload shape alone.
|
||||
- String keys show a save affordance near the value surface.
|
||||
- TTL controls stay beside the inspector metadata and expose both set and remove flows.
|
||||
- Delete requires a second confirmation step that includes key name and DB context.
|
||||
|
||||
@@ -6,6 +6,7 @@ Owner: QA Engineer
|
||||
## Evidence Collected On 2026-03-27
|
||||
|
||||
- A fresh `cargo test -p redis-core` rerun passes in this session with 16 tests green.
|
||||
- `pnpm --filter @redis-gui/desktop test` passes in this session with 8 files and 30 tests green.
|
||||
- `pnpm install --lockfile-only` passes at repository root after adding `pnpm-workspace.yaml`.
|
||||
- `pnpm run desktop:build` passes and emits `apps/desktop/dist/`.
|
||||
- `pnpm run desktop:tauri:build` now completes Linux `.deb` and `.rpm` artifact generation under `target/release/bundle/`, then fails later in the AppImage stage with `io: Connection reset by peer (os error 104)`.
|
||||
@@ -20,11 +21,11 @@ Owner: QA Engineer
|
||||
| Linux app smoke | App installs or launches from a documented command, opens main window, connects to test Redis, exits cleanly | Missing Redis, bad host, bad password, unsupported DB or network loss surface actionable error and app stays stable | Launch steps, versioned artifact or dev command, screenshots, QA run log | Partially blocked: documented fixture and smoke path now exist, but a full Linux launch-and-connect evidence set is still pending |
|
||||
| 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, and a copyable QA snapshot visible; command history labels live vs demo results | Empty search state clears the inspector selection, read-only non-string value state, destructive confirmation, and browser-mode demo fallback stay stable and explicit | `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 |
|
||||
| Desktop workspace shell | Rail, browser, inspector, and command tray render together with active connection, build identity, runtime mode, and a copyable QA snapshot visible; command history labels live vs demo results; inspector summary and key facts stay visible for the active key | Empty search state clears the inspector selection, read-only non-string value state, inspector status/action guidance stays aligned to live vs demo vs missing cases, destructive confirmation, and browser-mode demo fallback stay stable and explicit | `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, 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, maps backend error codes into shared notices, and calls the real connection-test command, 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 | No matches, invalid pattern, and very large result sets are handled predictably | Seed dataset, search queries, measured behavior | Partially blocked: desktop UI now maps search to live browse requests, but fixture-backed evidence and measured limits are still pending |
|
||||
| Value editing | Open supported value types, edit, save, and refresh persisted value | Concurrent modification, invalid payload, and permission error preserve user context and surface failure through the shared operator notice layer | Before/after values, screenshots, repeatable steps | Partially blocked: string read/save/refresh is wired through the live desktop bridge, and non-string types now render in structured read-only inspector panels, but fixture-backed evidence and broader editing scope remain pending |
|
||||
| Value editing | Open supported value types, edit, 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 and surface failure through 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, and the inspector guidance is clearer across live and fallback states, but fixture-backed evidence and broader editing scope remain pending |
|
||||
| TTL operations | View TTL, set TTL, remove TTL, and verify expiration behavior | Invalid TTL, already expired key, and permission error handled safely with consistent banner feedback | Seed dataset, timestamps, verification steps | Partially blocked: desktop UI now exposes live TTL set and remove controls through the existing contract, but seeded-fixture validation evidence is still missing |
|
||||
| Command console | Execute safe Redis commands in Tauri desktop mode and show result or error output with explicit live/demo labeling | Invalid command, long-running command, and blocked dangerous command are handled predictably, and backend failures reuse the shared operator notice mapping | Command transcript, screenshots, allow or deny rules | Partially blocked: the desktop UI now calls the real command execution bridge, but it is not yet validated against a shared live Redis fixture and dangerous mutations still stay blocked at shell level |
|
||||
| Dangerous operation confirmation | Delete, flush, overwrite, or destructive commands require explicit confirmation | Cancel path does not mutate data; confirm path mutates only intended scope | Confirmation UX screenshots, before/after data | Blocked: feature not implemented |
|
||||
|
||||
@@ -73,7 +73,7 @@ Seeded keys:
|
||||
4. In the desktop app, connect to host `127.0.0.1`, port `6380`, database `0`, no password, TLS disabled.
|
||||
5. Verify the desktop window opens, app version plus runtime mode are visible in the header, and the connection test succeeds.
|
||||
6. Verify the command console can run a safe read, such as `PING` or `GET smoke:string`.
|
||||
7. Verify the seeded keys appear with expected types and TTL state in the browser and inspector surfaces.
|
||||
7. Verify the seeded keys appear with expected types and TTL state in the browser and inspector surfaces, and confirm the inspector summary plus key facts stay aligned to the selected key type and current runtime.
|
||||
8. Change TTL on `smoke:string:ttl`, remove TTL from the same key, and confirm the updated TTL state is reflected in the inspector.
|
||||
9. Capture screenshots or terminal output for build, launch, connection success, key browse, TTL update, and command execution. If screenshots are unavailable, copy the in-app QA snapshot text into the smoke log.
|
||||
10. Run `./scripts/redis-fixture/stop.sh` after the session.
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
# HAC-22 DB Switcher Hardening
|
||||
|
||||
Date: 2026-03-31 UTC
|
||||
Owner: Senior Frontend Engineer
|
||||
Issue: HAC-22
|
||||
Scope: `desktop-v1` user-visible frontend hardening only
|
||||
|
||||
## Context
|
||||
|
||||
- `apps/desktop` already ships a visible DB switcher, but the current UI only renders four fixed choices: `db0`, `db1`, `db2`, and `db5`.
|
||||
- Current product scope already includes one active standalone Redis connection, explicit DB context, and DB-scoped browse / inspect / command flows.
|
||||
- The hardcoded four-item list is weaker than the current product boundary and makes shell-level verification less representative for operators who need to move across common DB indexes.
|
||||
|
||||
## Decision
|
||||
|
||||
- Do not expand product scope or alter backend contracts.
|
||||
- Keep the existing rail placement and interaction model for the DB switcher.
|
||||
- Widen the visible DB switcher to a predictable default operator range and ensure the currently active DB remains selectable even when it falls outside the default range.
|
||||
|
||||
## Implemented Slice
|
||||
|
||||
1. Added a shared frontend helper that returns DB labels for the switcher.
|
||||
2. Defaulted the visible range to `db0` through `db15`.
|
||||
3. Preserved the currently active DB when it falls outside that range and kept the options numerically ordered.
|
||||
4. Covered the helper with focused Vitest cases.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- The visible DB switcher no longer depends on a four-item hardcoded list.
|
||||
- Operators can select common DB indexes from `db0` through `db15` without editing connection drafts.
|
||||
- An active DB outside the default range still appears in the switcher and stays selectable.
|
||||
- `pnpm --filter @redis-gui/desktop test` and `pnpm run desktop:build` remain green.
|
||||
47
plans/2026-03-31-hac-29-browse-search-baseline.md
Normal file
47
plans/2026-03-31-hac-29-browse-search-baseline.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# HAC-29 Browse And Search Baseline Stabilization
|
||||
|
||||
Date: 2026-03-31 UTC
|
||||
Owner: Senior Frontend Engineer
|
||||
Issue: HAC-29
|
||||
Scope: `desktop-v1` browse/search hardening only
|
||||
|
||||
## Context
|
||||
|
||||
- `apps/desktop` already supports live browse pagination and DB-scoped search, but the visible search surface still leaves a few operator-facing gaps:
|
||||
- no explicit feedback about whether input is treated as substring matching or raw Redis pattern matching
|
||||
- no bounded clear action from the browser header
|
||||
- one generic empty-state sentence was doing too much work across loading, empty DB, and filtered no-match cases
|
||||
- Current phase must stay inside browse/search hardening and must not expand into Add Key, delete execution, non-string editing, or visual refresh work.
|
||||
|
||||
## Decision
|
||||
|
||||
- Keep the existing workspace layout and backend contracts unchanged.
|
||||
- Extract search-state interpretation into a shared frontend helper so visible UI copy and backend browse requests use the same source of truth.
|
||||
- Improve only the key-browser and inspector feedback layer:
|
||||
- explicit search mode label (`all keys`, `contains`, `pattern`)
|
||||
- concise result-status message under the search input
|
||||
- clear action for returning to normal browse state
|
||||
- distinct empty-state copy for loading, empty DB, and no-match search results
|
||||
|
||||
## Implemented Slice
|
||||
|
||||
1. Added `apps/desktop/src/lib/key-browser-search.ts` plus focused tests for:
|
||||
- blank input vs contains search vs raw wildcard-pattern classification
|
||||
- backend request-pattern generation
|
||||
- browser status and empty-state copy
|
||||
- inspector empty-state copy aligned to the same search state
|
||||
2. Updated `apps/desktop/src/App.tsx` to:
|
||||
- consume the shared search helper for live browse requests
|
||||
- render a visible search-mode badge and status line under the browser search field
|
||||
- expose a bounded clear action when search input is active
|
||||
- keep browser and inspector empty-state feedback aligned
|
||||
3. Updated `docs/frontend-workspace-baseline.md` so the shared acceptance language now includes the search-mode feedback, clear action, and differentiated empty states.
|
||||
|
||||
## Verification
|
||||
|
||||
- `pnpm --filter @redis-gui/desktop test` -> pass (9 files / 33 tests)
|
||||
- `pnpm run desktop:build` -> pass
|
||||
|
||||
## Remaining Risk
|
||||
|
||||
- This environment still cannot launch a visible Tauri window, so final GUI-level browse/search smoke evidence remains a QA follow-up even though the frontend code and build path are green.
|
||||
46
plans/2026-03-31-hac-30-inspector-readability.md
Normal file
46
plans/2026-03-31-hac-30-inspector-readability.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# HAC-30 Inspector Readability
|
||||
|
||||
Date: 2026-03-31
|
||||
Owner: Senior Frontend Engineer
|
||||
Issue: HAC-30
|
||||
|
||||
## Goal
|
||||
|
||||
Improve inspector readability and operator understanding without changing backend contracts or pulling next-phase scope into the current desktop-v1 slice.
|
||||
|
||||
## Scope
|
||||
|
||||
- clarify current inspector state for live, demo, loading, and missing-key paths
|
||||
- make value-surface semantics explicit for string and structured types
|
||||
- keep next-step operator guidance visible near the inspector surface
|
||||
- expose key facts in a stable summary card instead of relying on scattered badges and support copy
|
||||
- add focused frontend tests for the readability helper
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- no Add Key expansion
|
||||
- no delete execution contract
|
||||
- no non-string edit support
|
||||
- no visual-theme refresh
|
||||
- no bilingual UI pass
|
||||
|
||||
## Delivery Shape
|
||||
|
||||
1. Add a shared inspector-readability helper that maps selected key, live record, runtime mode, and edit capability into:
|
||||
- inspect status
|
||||
- value-surface title and description
|
||||
- next-step operator guidance
|
||||
- key facts
|
||||
2. Use that helper in `apps/desktop/src/App.tsx` to:
|
||||
- show an explicit inspector status badge
|
||||
- add a semantic summary block above the value surface
|
||||
- replace the previous generic support note with summary and facts cards
|
||||
3. Revalidate frontend tests and production build.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- inspector makes live string, structured read-only, demo fallback, loading, and missing-key paths visibly distinct
|
||||
- operators can identify scope, type, TTL state, value shape, and write path without inferring them from raw payload text
|
||||
- no backend contract change is required
|
||||
- `pnpm --filter @redis-gui/desktop test` passes
|
||||
- `pnpm run desktop:build` passes
|
||||
Reference in New Issue
Block a user