Compare commits
3 Commits
cmp-17-fro
...
cmp-22-db-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
04da063abf | ||
|
|
291f744e0f | ||
|
|
7c1abf2fd1 |
@@ -82,5 +82,6 @@ pnpm run desktop:tauri:dev
|
|||||||
- 当前前端消费 `backend_bootstrap`、`test_redis_connection`、`browse_redis_keys`、`read_redis_value`、`write_redis_value` 和 `execute_redis_command`;浏览器态仍保持明确的 demo fallback。
|
- 当前前端消费 `backend_bootstrap`、`test_redis_connection`、`browse_redis_keys`、`read_redis_value`、`write_redis_value` 和 `execute_redis_command`;浏览器态仍保持明确的 demo fallback。
|
||||||
- 当前前端还通过共享错误映射层统一消费 `redis-core` 的稳定错误码,使连接测试、浏览、读取、保存、TTL 和命令执行失败都落到一致的操作提示上。
|
- 当前前端还通过共享错误映射层统一消费 `redis-core` 的稳定错误码,使连接测试、浏览、读取、保存、TTL 和命令执行失败都落到一致的操作提示上。
|
||||||
- `inspect_redis_key` 与 `update_redis_key_ttl` 已在桌面桥可用;当前 UI 已暴露 TTL 设置与移除控制,但删除执行仍保持未开放状态。
|
- `inspect_redis_key` 与 `update_redis_key_ttl` 已在桌面桥可用;当前 UI 已暴露 TTL 设置与移除控制,但删除执行仍保持未开放状态。
|
||||||
|
- inspector 现在对 `hash/list/set/zset/stream` 提供结构化只读面板,不再要求操作者从原始 JSON 文本中手动解析 typed value。
|
||||||
- 连接测试、key browse、value inspector 和命令托盘在 Tauri 桌面运行时走真实后端桥;浏览器态 `pnpm run desktop:dev` 会明确回退到 demo 数据,不伪装成真实 Redis 行为。
|
- 连接测试、key browse、value inspector 和命令托盘在 Tauri 桌面运行时走真实后端桥;浏览器态 `pnpm run desktop:dev` 会明确回退到 demo 数据,不伪装成真实 Redis 行为。
|
||||||
- 具体 UI/UX 边界与 QA 验收入口见 `docs/frontend-workspace-baseline.md` 和 `docs/qa/acceptance-matrix.md`。
|
- 具体 UI/UX 边界与 QA 验收入口见 `docs/frontend-workspace-baseline.md` 和 `docs/qa/acceptance-matrix.md`。
|
||||||
|
|||||||
@@ -37,6 +37,13 @@ import {
|
|||||||
} from "./components/ui/dialog";
|
} from "./components/ui/dialog";
|
||||||
import { Input } from "./components/ui/input";
|
import { Input } from "./components/ui/input";
|
||||||
import { Textarea } from "./components/ui/textarea";
|
import { Textarea } from "./components/ui/textarea";
|
||||||
|
import {
|
||||||
|
isLocalSmokeFixtureConnection,
|
||||||
|
localSmokeFixtureCommandPresets,
|
||||||
|
localSmokeFixtureExpectedKeys,
|
||||||
|
localSmokeFixtureProfileId,
|
||||||
|
localSmokeFixtureSearchText,
|
||||||
|
} from "./lib/local-smoke-fixture";
|
||||||
import {
|
import {
|
||||||
toBackendError,
|
toBackendError,
|
||||||
type BackendErrorContext,
|
type BackendErrorContext,
|
||||||
@@ -45,6 +52,25 @@ import {
|
|||||||
toBackendErrorNotice,
|
toBackendErrorNotice,
|
||||||
type OperatorNotice,
|
type OperatorNotice,
|
||||||
} from "./lib/operator-notices";
|
} from "./lib/operator-notices";
|
||||||
|
import { buildQaSnapshot } from "./lib/qa-snapshot";
|
||||||
|
import {
|
||||||
|
buildStructuredInspectorSections,
|
||||||
|
type StructuredInspectorSection,
|
||||||
|
} from "./lib/structured-inspector";
|
||||||
|
import {
|
||||||
|
appendWorkspaceKeyRecords,
|
||||||
|
filterWorkspaceKeys,
|
||||||
|
replaceWorkspaceKeyRecord,
|
||||||
|
resolveSelectedWorkspaceKey,
|
||||||
|
resolveSelectedWorkspaceKeyId,
|
||||||
|
} from "./lib/key-workspace-state";
|
||||||
|
import {
|
||||||
|
parseDemoTtlToSeconds,
|
||||||
|
persistDemoKeyTtl,
|
||||||
|
updateDemoKeyTtl,
|
||||||
|
updateDemoStringValue,
|
||||||
|
} from "./lib/demo-workspace";
|
||||||
|
import { buildDatabaseSwitcherLabels } from "./lib/database-switcher";
|
||||||
import { cn } from "./lib/utils";
|
import { cn } from "./lib/utils";
|
||||||
|
|
||||||
declare const __APP_VERSION__: string;
|
declare const __APP_VERSION__: string;
|
||||||
@@ -289,7 +315,7 @@ const initialConnections: ConnectionProfile[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const keyData: KeyRecord[] = [
|
const initialDemoKeys: KeyRecord[] = [
|
||||||
{
|
{
|
||||||
id: "session:42",
|
id: "session:42",
|
||||||
name: "session:42",
|
name: "session:42",
|
||||||
@@ -332,8 +358,6 @@ const keyData: KeyRecord[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const databases = ["db0", "db1", "db2", "db5"];
|
|
||||||
|
|
||||||
const fallbackBootstrap: BackendBootstrap = {
|
const fallbackBootstrap: BackendBootstrap = {
|
||||||
app_name: "Redis GUI Foundation",
|
app_name: "Redis GUI Foundation",
|
||||||
surface: "desktop",
|
surface: "desktop",
|
||||||
@@ -369,11 +393,12 @@ const initialConsoleHistory: ConsoleEntry[] = [
|
|||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [connections, setConnections] = useState(initialConnections);
|
const [connections, setConnections] = useState(initialConnections);
|
||||||
|
const [demoKeys, setDemoKeys] = useState(initialDemoKeys);
|
||||||
const [activeConnectionId, setActiveConnectionId] = useState(initialConnections[0].id);
|
const [activeConnectionId, setActiveConnectionId] = useState(initialConnections[0].id);
|
||||||
const [activeDb, setActiveDb] = useState(databases[0]);
|
const [activeDb, setActiveDb] = useState("db0");
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [selectedKeyId, setSelectedKeyId] = useState<string | null>(keyData[1].id);
|
const [selectedKeyId, setSelectedKeyId] = useState<string | null>(initialDemoKeys[1].id);
|
||||||
const [draftValue, setDraftValue] = useState(keyData[1].value);
|
const [draftValue, setDraftValue] = useState(initialDemoKeys[1].value);
|
||||||
const [consoleInput, setConsoleInput] = useState("GET cache:homepage");
|
const [consoleInput, setConsoleInput] = useState("GET cache:homepage");
|
||||||
const [consoleHistory, setConsoleHistory] = useState(initialConsoleHistory);
|
const [consoleHistory, setConsoleHistory] = useState(initialConsoleHistory);
|
||||||
const [bootstrap, setBootstrap] = useState(fallbackBootstrap);
|
const [bootstrap, setBootstrap] = useState(fallbackBootstrap);
|
||||||
@@ -417,19 +442,14 @@ function App() {
|
|||||||
const activeConnection =
|
const activeConnection =
|
||||||
connections.find((connection) => connection.id === activeConnectionId) ??
|
connections.find((connection) => connection.id === activeConnectionId) ??
|
||||||
connections[0];
|
connections[0];
|
||||||
|
const databaseOptions = buildDatabaseSwitcherLabels(parseDatabaseLabel(activeDb));
|
||||||
|
|
||||||
const demoQuery = deferredSearch.trim().toLowerCase();
|
const filteredDemoKeys = filterWorkspaceKeys(demoKeys, deferredSearch);
|
||||||
const demoKeys = !demoQuery
|
const filteredKeys = tauriAvailable ? liveKeys : filteredDemoKeys;
|
||||||
? keyData
|
|
||||||
: keyData.filter((item) => item.name.toLowerCase().includes(demoQuery));
|
|
||||||
const filteredKeys = tauriAvailable ? liveKeys : demoKeys;
|
|
||||||
const draftErrors = validateConnectionDraft(connectionDraft, connections);
|
const draftErrors = validateConnectionDraft(connectionDraft, connections);
|
||||||
const draftErrorList = Object.values(draftErrors);
|
const draftErrorList = Object.values(draftErrors);
|
||||||
|
|
||||||
const selectedKey =
|
const selectedKey = resolveSelectedWorkspaceKey(filteredKeys, selectedKeyId);
|
||||||
filteredKeys.length === 0
|
|
||||||
? null
|
|
||||||
: filteredKeys.find((item) => item.id === selectedKeyId) ?? filteredKeys[0];
|
|
||||||
|
|
||||||
const isEditable = tauriAvailable
|
const isEditable = tauriAvailable
|
||||||
? selectedValueRecord?.write_capability === "replace_string" &&
|
? selectedValueRecord?.write_capability === "replace_string" &&
|
||||||
@@ -439,6 +459,14 @@ function App() {
|
|||||||
const inspectorText = tauriAvailable
|
const inspectorText = tauriAvailable
|
||||||
? formatInspectorValue(selectedValueRecord, selectedKey, isInspectorLoading)
|
? formatInspectorValue(selectedValueRecord, selectedKey, isInspectorLoading)
|
||||||
: (selectedKey?.value ?? "");
|
: (selectedKey?.value ?? "");
|
||||||
|
const structuredInspectorSections = tauriAvailable
|
||||||
|
? buildStructuredInspectorSections(selectedValueRecord)
|
||||||
|
: [];
|
||||||
|
const showStructuredInspector =
|
||||||
|
selectedKey !== null &&
|
||||||
|
!isInspectorLoading &&
|
||||||
|
structuredInspectorSections.length > 0 &&
|
||||||
|
!isEditable;
|
||||||
|
|
||||||
const supportNote = tauriAvailable
|
const supportNote = tauriAvailable
|
||||||
? buildLiveSupportNote(selectedValueRecord, selectedKey)
|
? buildLiveSupportNote(selectedValueRecord, selectedKey)
|
||||||
@@ -447,6 +475,8 @@ function App() {
|
|||||||
const ttlWriteSupported = tauriAvailable
|
const ttlWriteSupported = tauriAvailable
|
||||||
? selectedValueRecord?.ttl_write_supported === true
|
? selectedValueRecord?.ttl_write_supported === true
|
||||||
: selectedKey !== null;
|
: selectedKey !== null;
|
||||||
|
const localSmokeFixtureActive = isLocalSmokeFixtureConnection(activeConnection);
|
||||||
|
const localSmokeFilterActive = search.trim() === localSmokeFixtureSearchText;
|
||||||
const ttlDraftError = ttlDraft.trim() ? validateTtlDraft(ttlDraft) : null;
|
const ttlDraftError = ttlDraft.trim() ? validateTtlDraft(ttlDraft) : null;
|
||||||
const canSubmitTtlUpdate =
|
const canSubmitTtlUpdate =
|
||||||
selectedKey !== null &&
|
selectedKey !== null &&
|
||||||
@@ -462,23 +492,25 @@ function App() {
|
|||||||
? selectedValueRecord?.metadata.ttl.kind !== "persistent"
|
? selectedValueRecord?.metadata.ttl.kind !== "persistent"
|
||||||
: selectedKey?.ttl !== "persistent");
|
: selectedKey?.ttl !== "persistent");
|
||||||
const latestConsoleEntry = consoleHistory[0] ?? null;
|
const latestConsoleEntry = consoleHistory[0] ?? null;
|
||||||
const qaSnapshot = [
|
const qaSnapshot = buildQaSnapshot({
|
||||||
`app=${bootstrap.app_name}`,
|
appName: bootstrap.app_name,
|
||||||
`version=${appVersion}`,
|
appVersion,
|
||||||
`runtime=${runtimeMode}`,
|
runtimeMode,
|
||||||
`build=${buildMode}`,
|
buildMode,
|
||||||
`connection=${activeConnection.name}`,
|
connectionName: activeConnection.name,
|
||||||
`endpoint=${formatEndpoint(activeConnection.target)}`,
|
endpoint: formatEndpoint(activeConnection.target),
|
||||||
`database=${activeDb}`,
|
databaseLabel: activeDb,
|
||||||
`selectedKey=${selectedKey?.name ?? "none"}`,
|
smokeFixtureActive: localSmokeFixtureActive,
|
||||||
`selectedType=${selectedKey?.type ?? "none"}`,
|
smokeFilter: localSmokeFilterActive ? localSmokeFixtureSearchText : search,
|
||||||
`selectedTtl=${selectedKey?.ttl ?? "none"}`,
|
selectedKeyName: selectedKey?.name ?? null,
|
||||||
`browseVisible=${filteredKeys.length}${tauriAvailable && liveHasMore ? "+" : ""}`,
|
selectedKeyType: selectedKey?.type ?? null,
|
||||||
`latestCommand=${latestConsoleEntry?.command ?? "none"}`,
|
selectedKeyTtl: selectedKey?.ttl ?? null,
|
||||||
`latestCommandSource=${latestConsoleEntry?.source ?? "none"}`,
|
browseVisibleCount: `${filteredKeys.length}${tauriAvailable && liveHasMore ? "+" : ""}`,
|
||||||
`latestCommandStatus=${latestConsoleEntry?.status ?? "none"}`,
|
latestCommand: latestConsoleEntry?.command ?? null,
|
||||||
`banner=${banner.message}`,
|
latestCommandSource: latestConsoleEntry?.source ?? null,
|
||||||
].join("\n");
|
latestCommandStatus: latestConsoleEntry?.status ?? null,
|
||||||
|
bannerMessage: banner.message,
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!tauriAvailable) {
|
if (!tauriAvailable) {
|
||||||
@@ -557,9 +589,7 @@ function App() {
|
|||||||
setLiveKeys(nextKeys);
|
setLiveKeys(nextKeys);
|
||||||
setLiveNextCursor(result.next_cursor);
|
setLiveNextCursor(result.next_cursor);
|
||||||
setLiveHasMore(result.has_more);
|
setLiveHasMore(result.has_more);
|
||||||
setSelectedKeyId((current) =>
|
setSelectedKeyId((current) => resolveSelectedWorkspaceKeyId(nextKeys, current));
|
||||||
nextKeys.some((item) => item.id === current) ? current : (nextKeys[0]?.id ?? null),
|
|
||||||
);
|
|
||||||
setIsBrowseLoading(false);
|
setIsBrowseLoading(false);
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
@@ -632,7 +662,7 @@ function App() {
|
|||||||
const nextRecord = toLiveKeyRecordFromValue(record);
|
const nextRecord = toLiveKeyRecordFromValue(record);
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
setSelectedValueRecord(record);
|
setSelectedValueRecord(record);
|
||||||
setLiveKeys((current) => replaceKeyRecord(current, nextRecord));
|
setLiveKeys((current) => replaceWorkspaceKeyRecord(current, nextRecord));
|
||||||
setDraftValue(
|
setDraftValue(
|
||||||
record.data.kind === "string" && record.write_capability === "replace_string"
|
record.data.kind === "string" && record.write_capability === "replace_string"
|
||||||
? formatRedisValueContent(record.data.value)
|
? formatRedisValueContent(record.data.value)
|
||||||
@@ -711,6 +741,60 @@ function App() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleUseLocalSmokeFixture = () => {
|
||||||
|
const existingFixture = connections.find(isLocalSmokeFixtureConnection);
|
||||||
|
const fixtureConnection =
|
||||||
|
existingFixture ??
|
||||||
|
{
|
||||||
|
id: localSmokeFixtureProfileId,
|
||||||
|
name: localSmokeFixtureProfileId,
|
||||||
|
environment: "local" as const,
|
||||||
|
detail: "Fixture profile",
|
||||||
|
status: "idle" as const,
|
||||||
|
password: "",
|
||||||
|
target: {
|
||||||
|
host: "127.0.0.1",
|
||||||
|
port: 6380,
|
||||||
|
database: 0,
|
||||||
|
username: null,
|
||||||
|
tls_mode: "disabled" as const,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
startTransition(() => {
|
||||||
|
if (!existingFixture) {
|
||||||
|
setConnections((current) => [fixtureConnection, ...current]);
|
||||||
|
}
|
||||||
|
setActiveConnectionId(fixtureConnection.id);
|
||||||
|
setActiveDb(formatDatabaseLabel(fixtureConnection.target.database));
|
||||||
|
setSelectedKeyId(null);
|
||||||
|
setSearch(localSmokeFixtureSearchText);
|
||||||
|
setConsoleInput(localSmokeFixtureCommandPresets[0].command);
|
||||||
|
showAccentBanner(
|
||||||
|
"Loaded the local smoke fixture profile, narrowed the browser to smoke:* keys, and primed the command tray with PING.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFocusLocalSmokeKeys = () => {
|
||||||
|
startTransition(() => {
|
||||||
|
setSearch(localSmokeFixtureSearchText);
|
||||||
|
setSelectedKeyId(null);
|
||||||
|
showNeutralBanner(
|
||||||
|
`Focused the browser on ${localSmokeFixtureSearchText} for local smoke verification in ${activeDb}.`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleApplyLocalSmokeCommandPreset = (command: string) => {
|
||||||
|
startTransition(() => {
|
||||||
|
setConsoleInput(command);
|
||||||
|
showNeutralBanner(
|
||||||
|
`Loaded ${command} into the command tray so QA can execute the next safe smoke step without retyping.`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const handleCreateDraftConnection = () => {
|
const handleCreateDraftConnection = () => {
|
||||||
if (draftErrorList.length > 0) {
|
if (draftErrorList.length > 0) {
|
||||||
showNeutralBanner(`Connection draft is incomplete: ${draftErrorList[0]}`);
|
showNeutralBanner(`Connection draft is incomplete: ${draftErrorList[0]}`);
|
||||||
@@ -754,9 +838,12 @@ function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!tauriAvailable) {
|
if (!tauriAvailable) {
|
||||||
showNeutralBanner(
|
startTransition(() => {
|
||||||
`Saved mock string value for ${selectedKey.name} in ${activeDb}. Backend persistence is not wired yet.`,
|
setDemoKeys((current) => updateDemoStringValue(current, selectedKey.name, draftValue));
|
||||||
);
|
showNeutralBanner(
|
||||||
|
`Saved session-local mock string value for ${selectedKey.name} in ${activeDb}. Backend persistence is still unavailable in browser demo mode.`,
|
||||||
|
);
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -779,7 +866,7 @@ function App() {
|
|||||||
const nextRecord = toLiveKeyRecordFromValue(result);
|
const nextRecord = toLiveKeyRecordFromValue(result);
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
setSelectedValueRecord(result);
|
setSelectedValueRecord(result);
|
||||||
setLiveKeys((current) => replaceKeyRecord(current, nextRecord));
|
setLiveKeys((current) => replaceWorkspaceKeyRecord(current, nextRecord));
|
||||||
setDraftValue(
|
setDraftValue(
|
||||||
result.data.kind === "string"
|
result.data.kind === "string"
|
||||||
? formatRedisValueContent(result.data.value)
|
? formatRedisValueContent(result.data.value)
|
||||||
@@ -827,9 +914,14 @@ function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!tauriAvailable) {
|
if (!tauriAvailable) {
|
||||||
showNeutralBanner(
|
startTransition(() => {
|
||||||
`Browser dev mode keeps TTL changes in demo fallback for ${selectedKey.name}. Use desktop runtime for a live TTL update.`,
|
setDemoKeys((current) =>
|
||||||
);
|
updateDemoKeyTtl(current, selectedKey.name, Number.parseInt(ttlDraft, 10)),
|
||||||
|
);
|
||||||
|
showNeutralBanner(
|
||||||
|
`Updated session-local mock TTL for ${selectedKey.name} in ${activeDb}. Use desktop runtime for a live TTL change.`,
|
||||||
|
);
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -881,9 +973,13 @@ function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!tauriAvailable) {
|
if (!tauriAvailable) {
|
||||||
showNeutralBanner(
|
startTransition(() => {
|
||||||
`Browser dev mode keeps TTL removal in demo fallback for ${selectedKey.name}. Use desktop runtime for a live TTL change.`,
|
setDemoKeys((current) => persistDemoKeyTtl(current, selectedKey.name));
|
||||||
);
|
setTtlDraft("");
|
||||||
|
showNeutralBanner(
|
||||||
|
`Removed session-local mock TTL for ${selectedKey.name} in ${activeDb}. Use desktop runtime for a live TTL change.`,
|
||||||
|
);
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -955,7 +1051,7 @@ function App() {
|
|||||||
const nextRecord = toLiveKeyRecordFromValue(result);
|
const nextRecord = toLiveKeyRecordFromValue(result);
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
setSelectedValueRecord(result);
|
setSelectedValueRecord(result);
|
||||||
setLiveKeys((current) => replaceKeyRecord(current, nextRecord));
|
setLiveKeys((current) => replaceWorkspaceKeyRecord(current, nextRecord));
|
||||||
setDraftValue(
|
setDraftValue(
|
||||||
result.data.kind === "string" && result.write_capability === "replace_string"
|
result.data.kind === "string" && result.write_capability === "replace_string"
|
||||||
? formatRedisValueContent(result.data.value)
|
? formatRedisValueContent(result.data.value)
|
||||||
@@ -1008,11 +1104,15 @@ function App() {
|
|||||||
metadata,
|
metadata,
|
||||||
};
|
};
|
||||||
setSelectedValueRecord(nextRecord);
|
setSelectedValueRecord(nextRecord);
|
||||||
setLiveKeys((current) => replaceKeyRecord(current, toLiveKeyRecordFromValue(nextRecord)));
|
setLiveKeys((current) =>
|
||||||
|
replaceWorkspaceKeyRecord(current, toLiveKeyRecordFromValue(nextRecord)),
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setLiveKeys((current) => replaceKeyRecord(current, toLiveKeyRecordFromMetadata(metadata)));
|
setLiveKeys((current) =>
|
||||||
|
replaceWorkspaceKeyRecord(current, toLiveKeyRecordFromMetadata(metadata)),
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLoadMoreKeys = async () => {
|
const handleLoadMoreKeys = async () => {
|
||||||
@@ -1036,7 +1136,7 @@ function App() {
|
|||||||
|
|
||||||
const nextKeys = result.keys.map(toLiveKeyRecordFromMetadata);
|
const nextKeys = result.keys.map(toLiveKeyRecordFromMetadata);
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
setLiveKeys((current) => appendKeyRecords(current, nextKeys));
|
setLiveKeys((current) => appendWorkspaceKeyRecords(current, nextKeys));
|
||||||
setLiveNextCursor(result.next_cursor);
|
setLiveNextCursor(result.next_cursor);
|
||||||
setLiveHasMore(result.has_more);
|
setLiveHasMore(result.has_more);
|
||||||
setIsBrowseLoading(false);
|
setIsBrowseLoading(false);
|
||||||
@@ -1483,7 +1583,7 @@ function App() {
|
|||||||
<section>
|
<section>
|
||||||
<SectionEyebrow icon={<Database className="h-4 w-4" />} label="Database switcher" />
|
<SectionEyebrow icon={<Database className="h-4 w-4" />} label="Database switcher" />
|
||||||
<div className="mt-3 grid gap-2">
|
<div className="mt-3 grid gap-2">
|
||||||
{databases.map((database) => {
|
{databaseOptions.map((database) => {
|
||||||
const active = database === activeDb;
|
const active = database === activeDb;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1520,6 +1620,67 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<SectionEyebrow icon={<Activity className="h-4 w-4" />} label="Local smoke assist" />
|
||||||
|
<div className="mt-3 rounded-[22px] border border-[var(--line-soft)] bg-[var(--surface-base)] p-4">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-[var(--ink-0)]">
|
||||||
|
Seeded fixture path for desktop smoke runs
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-sm text-[var(--ink-soft)]">
|
||||||
|
Mirrors `docs/qa/local-desktop-smoke.md` and keeps the safe validation path visible inside the shell.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge variant={localSmokeFixtureActive ? "accent" : "neutral"}>
|
||||||
|
{localSmokeFixtureActive ? "active" : "inactive"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
|
<Badge variant="neutral">127.0.0.1:6380</Badge>
|
||||||
|
<Badge variant="neutral">db0</Badge>
|
||||||
|
<Badge variant="neutral">{localSmokeFixtureExpectedKeys.length} seeded keys</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={handleUseLocalSmokeFixture}>
|
||||||
|
Use fixture
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={handleFocusLocalSmokeKeys}>
|
||||||
|
Focus smoke:*
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
{localSmokeFixtureCommandPresets.map((preset) => (
|
||||||
|
<button
|
||||||
|
key={preset.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleApplyLocalSmokeCommandPreset(preset.command)}
|
||||||
|
className="w-full rounded-[18px] border border-[var(--line-soft)] bg-[var(--surface-muted)] px-3 py-2 text-left transition hover:border-[var(--line-strong)]"
|
||||||
|
>
|
||||||
|
<p className="font-mono text-sm text-[var(--ink-0)]">{preset.label}</p>
|
||||||
|
<p className="mt-1 text-xs text-[var(--ink-soft)]">{preset.summary}</p>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 rounded-[18px] border border-[var(--line-soft)] bg-[var(--surface-muted)] px-3 py-3">
|
||||||
|
<p className="font-mono text-[11px] uppercase tracking-[0.18em] text-[var(--ink-muted)]">
|
||||||
|
Seeded keys
|
||||||
|
</p>
|
||||||
|
<div className="mt-2 flex flex-wrap gap-2">
|
||||||
|
{localSmokeFixtureExpectedKeys.map((keyName) => (
|
||||||
|
<Badge key={keyName} variant="neutral" className="normal-case tracking-normal">
|
||||||
|
{keyName}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<SectionEyebrow icon={<ClipboardList className="h-4 w-4" />} label="QA snapshot" />
|
<SectionEyebrow icon={<ClipboardList className="h-4 w-4" />} label="QA snapshot" />
|
||||||
<div className="mt-3 rounded-[22px] border border-[var(--line-soft)] bg-[var(--surface-base)] p-4">
|
<div className="mt-3 rounded-[22px] border border-[var(--line-soft)] bg-[var(--surface-base)] p-4">
|
||||||
@@ -1559,7 +1720,7 @@ function App() {
|
|||||||
<Badge variant="neutral">
|
<Badge variant="neutral">
|
||||||
{tauriAvailable
|
{tauriAvailable
|
||||||
? `${filteredKeys.length}${liveHasMore ? "+" : ""} visible in ${activeDb}`
|
? `${filteredKeys.length}${liveHasMore ? "+" : ""} visible in ${activeDb}`
|
||||||
: `${filteredKeys.length} of ${keyData.length} keys in ${activeDb}`}
|
: `${filteredKeys.length} of ${demoKeys.length} keys in ${activeDb}`}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1662,15 +1823,20 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectedKey ? (
|
{selectedKey ? (
|
||||||
<Textarea
|
showStructuredInspector ? (
|
||||||
value={isEditable ? draftValue : inspectorText}
|
<StructuredInspectorPanels sections={structuredInspectorSections} />
|
||||||
onChange={(event) => setDraftValue(event.target.value)}
|
) : (
|
||||||
readOnly={!isEditable}
|
<Textarea
|
||||||
className={cn(
|
value={isEditable ? draftValue : inspectorText}
|
||||||
"mt-3 h-64 resize-none font-mono",
|
onChange={(event) => setDraftValue(event.target.value)}
|
||||||
!isEditable && "border-[var(--line-soft)] bg-[var(--surface-muted)] text-[var(--ink-soft)]",
|
readOnly={!isEditable}
|
||||||
)}
|
className={cn(
|
||||||
/>
|
"mt-3 h-64 resize-none font-mono",
|
||||||
|
!isEditable &&
|
||||||
|
"border-[var(--line-soft)] bg-[var(--surface-muted)] text-[var(--ink-soft)]",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
) : (
|
) : (
|
||||||
<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)]">
|
<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.
|
No key matches the current pattern in {activeDb}. Clear or adjust the search to restore an inspector target.
|
||||||
@@ -1944,6 +2110,85 @@ function InfoCard({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function StructuredInspectorPanels({
|
||||||
|
sections,
|
||||||
|
}: {
|
||||||
|
sections: StructuredInspectorSection[];
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="mt-3 h-64 space-y-3 overflow-auto pr-1">
|
||||||
|
{sections.map((section) => (
|
||||||
|
<section
|
||||||
|
key={section.id}
|
||||||
|
className="rounded-[22px] border border-[var(--line-soft)] bg-[var(--surface-muted)] p-4"
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-[var(--ink-0)]">{section.title}</p>
|
||||||
|
<p className="mt-1 text-sm text-[var(--ink-soft)]">{section.summary}</p>
|
||||||
|
</div>
|
||||||
|
<Badge variant="neutral">{section.rows.length} rows</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 hidden grid-cols-[minmax(0,0.38fr)_minmax(0,0.62fr)] gap-3 px-1 sm:grid">
|
||||||
|
<p className="font-mono text-[11px] uppercase tracking-[0.18em] text-[var(--ink-muted)]">
|
||||||
|
{section.primaryLabel}
|
||||||
|
</p>
|
||||||
|
<p className="font-mono text-[11px] uppercase tracking-[0.18em] text-[var(--ink-muted)]">
|
||||||
|
{section.secondaryLabel}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
{section.rows.map((row) => (
|
||||||
|
<div
|
||||||
|
key={row.id}
|
||||||
|
className="grid gap-3 rounded-[18px] border border-[var(--line-soft)] bg-[var(--surface-base)] p-3 sm:grid-cols-[minmax(0,0.38fr)_minmax(0,0.62fr)]"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="font-mono text-[11px] uppercase tracking-[0.18em] text-[var(--ink-muted)] sm:hidden">
|
||||||
|
{section.primaryLabel}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 break-all font-mono text-sm text-[var(--ink-0)] sm:mt-0">
|
||||||
|
{row.primary}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="font-mono text-[11px] uppercase tracking-[0.18em] text-[var(--ink-muted)] sm:hidden">
|
||||||
|
{section.secondaryLabel}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 break-all font-mono text-sm text-[var(--ink-0)] sm:mt-0">
|
||||||
|
{row.secondary}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{row.detailLines && row.detailLines.length > 0 ? (
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<p className="font-mono text-[11px] uppercase tracking-[0.18em] text-[var(--ink-muted)]">
|
||||||
|
Payload
|
||||||
|
</p>
|
||||||
|
<div className="mt-2 space-y-2">
|
||||||
|
{row.detailLines.map((detail) => (
|
||||||
|
<div
|
||||||
|
key={`${row.id}-${detail}`}
|
||||||
|
className="rounded-2xl border border-[var(--line-soft)] bg-[var(--surface-muted)] px-3 py-2 font-mono text-xs text-[var(--ink-soft)]"
|
||||||
|
>
|
||||||
|
{detail}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function isTauriRuntimeAvailable() {
|
function isTauriRuntimeAvailable() {
|
||||||
return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
|
return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
|
||||||
}
|
}
|
||||||
@@ -2051,7 +2296,7 @@ function buildMockCommandOutput(command: string, selectedKey: KeyRecord | null)
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (upper === "TTL") {
|
if (upper === "TTL") {
|
||||||
return `(integer) ${selectedKey.ttl === "persistent" ? -1 : selectedKey.ttl.replace(/[^0-9]/g, "")}`;
|
return `(integer) ${parseDemoTtlToSeconds(selectedKey.ttl)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (upper === "TYPE") {
|
if (upper === "TYPE") {
|
||||||
@@ -2194,39 +2439,6 @@ function toLiveKeyRecordFromValue(record: RedisValueRecord): KeyRecord {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function replaceKeyRecord(current: KeyRecord[], next: KeyRecord) {
|
|
||||||
let replaced = false;
|
|
||||||
const updated = current.map((item) => {
|
|
||||||
if (item.id !== next.id) {
|
|
||||||
return item;
|
|
||||||
}
|
|
||||||
|
|
||||||
replaced = true;
|
|
||||||
return {
|
|
||||||
...item,
|
|
||||||
...next,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
return replaced ? updated : [next, ...current];
|
|
||||||
}
|
|
||||||
|
|
||||||
function appendKeyRecords(current: KeyRecord[], next: KeyRecord[]) {
|
|
||||||
const seen = new Set(current.map((item) => item.id));
|
|
||||||
const appended = [...current];
|
|
||||||
|
|
||||||
for (const item of next) {
|
|
||||||
if (seen.has(item.id)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
seen.add(item.id);
|
|
||||||
appended.push(item);
|
|
||||||
}
|
|
||||||
|
|
||||||
return appended;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildMetadataPreview(metadata: RedisKeyMetadata) {
|
function buildMetadataPreview(metadata: RedisKeyMetadata) {
|
||||||
return `${formatRedisKeyTypeLabel(metadata.key_type)} key · TTL ${formatRedisKeyTtl(metadata.ttl)}`;
|
return `${formatRedisKeyTypeLabel(metadata.key_type)} key · TTL ${formatRedisKeyTtl(metadata.ttl)}`;
|
||||||
}
|
}
|
||||||
@@ -2281,7 +2493,7 @@ function buildLiveSupportNote(record: RedisValueRecord | null, selectedKey: KeyR
|
|||||||
case "set":
|
case "set":
|
||||||
case "sorted_set":
|
case "sorted_set":
|
||||||
case "stream":
|
case "stream":
|
||||||
return `${formatRedisKeyTypeLabel(record.metadata.key_type)} values are loaded live and remain explicitly read only in the current desktop contract.`;
|
return `${formatRedisKeyTypeLabel(record.metadata.key_type)} values are loaded live into structured read-only panels in the current desktop contract.`;
|
||||||
default:
|
default:
|
||||||
return "The current key is available for live inspection through the desktop backend contract.";
|
return "The current key is available for live inspection through the desktop backend contract.";
|
||||||
}
|
}
|
||||||
|
|||||||
74
apps/desktop/src/lib/database-switcher.test.ts
Normal file
74
apps/desktop/src/lib/database-switcher.test.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { buildDatabaseSwitcherLabels } from "./database-switcher";
|
||||||
|
|
||||||
|
describe("database switcher helper", () => {
|
||||||
|
it("returns the default operator range from db0 through db15", () => {
|
||||||
|
expect(buildDatabaseSwitcherLabels(0)).toEqual([
|
||||||
|
"db0",
|
||||||
|
"db1",
|
||||||
|
"db2",
|
||||||
|
"db3",
|
||||||
|
"db4",
|
||||||
|
"db5",
|
||||||
|
"db6",
|
||||||
|
"db7",
|
||||||
|
"db8",
|
||||||
|
"db9",
|
||||||
|
"db10",
|
||||||
|
"db11",
|
||||||
|
"db12",
|
||||||
|
"db13",
|
||||||
|
"db14",
|
||||||
|
"db15",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps an active out-of-range database visible and numerically ordered", () => {
|
||||||
|
expect(buildDatabaseSwitcherLabels(23)).toEqual([
|
||||||
|
"db0",
|
||||||
|
"db1",
|
||||||
|
"db2",
|
||||||
|
"db3",
|
||||||
|
"db4",
|
||||||
|
"db5",
|
||||||
|
"db6",
|
||||||
|
"db7",
|
||||||
|
"db8",
|
||||||
|
"db9",
|
||||||
|
"db10",
|
||||||
|
"db11",
|
||||||
|
"db12",
|
||||||
|
"db13",
|
||||||
|
"db14",
|
||||||
|
"db15",
|
||||||
|
"db23",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes negative or fractional inputs back to safe labels", () => {
|
||||||
|
expect(buildDatabaseSwitcherLabels(-3)).toEqual([
|
||||||
|
"db0",
|
||||||
|
"db1",
|
||||||
|
"db2",
|
||||||
|
"db3",
|
||||||
|
"db4",
|
||||||
|
"db5",
|
||||||
|
"db6",
|
||||||
|
"db7",
|
||||||
|
"db8",
|
||||||
|
"db9",
|
||||||
|
"db10",
|
||||||
|
"db11",
|
||||||
|
"db12",
|
||||||
|
"db13",
|
||||||
|
"db14",
|
||||||
|
"db15",
|
||||||
|
]);
|
||||||
|
expect(buildDatabaseSwitcherLabels(4.8, 2)).toEqual([
|
||||||
|
"db0",
|
||||||
|
"db1",
|
||||||
|
"db2",
|
||||||
|
"db4",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
29
apps/desktop/src/lib/database-switcher.ts
Normal file
29
apps/desktop/src/lib/database-switcher.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
export function buildDatabaseSwitcherLabels(
|
||||||
|
activeDatabase: number,
|
||||||
|
maxDefaultDatabase = 15,
|
||||||
|
) {
|
||||||
|
const normalizedDefaultMax = Math.max(0, Math.floor(maxDefaultDatabase));
|
||||||
|
const normalizedActive = Math.max(0, Math.floor(activeDatabase));
|
||||||
|
const labels = new Set<string>();
|
||||||
|
|
||||||
|
for (let database = 0; database <= normalizedDefaultMax; database += 1) {
|
||||||
|
labels.add(formatDatabaseLabel(database));
|
||||||
|
}
|
||||||
|
|
||||||
|
labels.add(formatDatabaseLabel(normalizedActive));
|
||||||
|
|
||||||
|
return [...labels].sort(compareDatabaseLabels);
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareDatabaseLabels(left: string, right: string) {
|
||||||
|
return parseDatabaseLabel(left) - parseDatabaseLabel(right);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDatabaseLabel(value: string) {
|
||||||
|
const parsed = Number.parseInt(value.replace(/^db/i, ""), 10);
|
||||||
|
return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDatabaseLabel(database: number) {
|
||||||
|
return `db${database}`;
|
||||||
|
}
|
||||||
55
apps/desktop/src/lib/demo-workspace.test.ts
Normal file
55
apps/desktop/src/lib/demo-workspace.test.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
buildDemoStringPreview,
|
||||||
|
formatDemoDurationFromMillis,
|
||||||
|
parseDemoTtlToSeconds,
|
||||||
|
persistDemoKeyTtl,
|
||||||
|
updateDemoKeyTtl,
|
||||||
|
updateDemoStringValue,
|
||||||
|
type DemoWorkspaceKey,
|
||||||
|
} from "./demo-workspace";
|
||||||
|
|
||||||
|
const demoKeys: DemoWorkspaceKey[] = [
|
||||||
|
{
|
||||||
|
id: "cache:homepage",
|
||||||
|
name: "cache:homepage",
|
||||||
|
type: "string",
|
||||||
|
ttl: "48s",
|
||||||
|
preview: "serialized landing payload",
|
||||||
|
editable: true,
|
||||||
|
value: "{\"hero\":\"Redis GUI\"}",
|
||||||
|
summary: "String keys are editable in V1.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("demo workspace helpers", () => {
|
||||||
|
it("updates string values and refreshes the visible preview", () => {
|
||||||
|
expect(
|
||||||
|
updateDemoStringValue(demoKeys, "cache:homepage", "next value"),
|
||||||
|
).toEqual([
|
||||||
|
{
|
||||||
|
...demoKeys[0],
|
||||||
|
value: "next value",
|
||||||
|
preview: "next value",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates TTL state for session-local demo flows", () => {
|
||||||
|
expect(updateDemoKeyTtl(demoKeys, "cache:homepage", 90_000)[0]?.ttl).toBe("1m");
|
||||||
|
expect(persistDemoKeyTtl(demoKeys, "cache:homepage")[0]?.ttl).toBe("persistent");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats preview text and durations consistently", () => {
|
||||||
|
expect(buildDemoStringPreview("alpha beta gamma delta epsilon zeta eta theta", 16)).toBe(
|
||||||
|
"alpha beta gamm…",
|
||||||
|
);
|
||||||
|
expect(formatDemoDurationFromMillis(750)).toBe("750ms");
|
||||||
|
expect(formatDemoDurationFromMillis(5_000)).toBe("5s");
|
||||||
|
expect(formatDemoDurationFromMillis(7_200_000)).toBe("2h");
|
||||||
|
expect(parseDemoTtlToSeconds("750ms")).toBe(0);
|
||||||
|
expect(parseDemoTtlToSeconds("5s")).toBe(5);
|
||||||
|
expect(parseDemoTtlToSeconds("2h")).toBe(7200);
|
||||||
|
expect(parseDemoTtlToSeconds("persistent")).toBe(-1);
|
||||||
|
});
|
||||||
|
});
|
||||||
114
apps/desktop/src/lib/demo-workspace.ts
Normal file
114
apps/desktop/src/lib/demo-workspace.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
export type DemoWorkspaceKey = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
ttl: string;
|
||||||
|
preview: string;
|
||||||
|
editable: boolean;
|
||||||
|
value: string;
|
||||||
|
summary: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function updateDemoStringValue(
|
||||||
|
keys: DemoWorkspaceKey[],
|
||||||
|
keyName: string,
|
||||||
|
value: string,
|
||||||
|
) {
|
||||||
|
return keys.map((key) =>
|
||||||
|
key.name === keyName
|
||||||
|
? {
|
||||||
|
...key,
|
||||||
|
value,
|
||||||
|
preview: buildDemoStringPreview(value),
|
||||||
|
}
|
||||||
|
: key,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateDemoKeyTtl(
|
||||||
|
keys: DemoWorkspaceKey[],
|
||||||
|
keyName: string,
|
||||||
|
ttlMillis: number,
|
||||||
|
) {
|
||||||
|
return keys.map((key) =>
|
||||||
|
key.name === keyName
|
||||||
|
? {
|
||||||
|
...key,
|
||||||
|
ttl: formatDemoDurationFromMillis(ttlMillis),
|
||||||
|
}
|
||||||
|
: key,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function persistDemoKeyTtl(keys: DemoWorkspaceKey[], keyName: string) {
|
||||||
|
return keys.map((key) =>
|
||||||
|
key.name === keyName
|
||||||
|
? {
|
||||||
|
...key,
|
||||||
|
ttl: "persistent",
|
||||||
|
}
|
||||||
|
: key,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildDemoStringPreview(value: string, maxLength = 48) {
|
||||||
|
const collapsed = value.replace(/\s+/g, " ").trim();
|
||||||
|
if (collapsed.length <= maxLength) {
|
||||||
|
return collapsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${collapsed.slice(0, maxLength - 1)}…`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDemoDurationFromMillis(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`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseDemoTtlToSeconds(value: string) {
|
||||||
|
if (value === "persistent") {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = value.trim().match(/^(\d+)(ms|s|m|h|d)$/);
|
||||||
|
if (!match) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const amount = Number.parseInt(match[1], 10);
|
||||||
|
const unit = match[2];
|
||||||
|
|
||||||
|
switch (unit) {
|
||||||
|
case "ms":
|
||||||
|
return Math.floor(amount / 1_000);
|
||||||
|
case "s":
|
||||||
|
return amount;
|
||||||
|
case "m":
|
||||||
|
return amount * 60;
|
||||||
|
case "h":
|
||||||
|
return amount * 60 * 60;
|
||||||
|
case "d":
|
||||||
|
return amount * 60 * 60 * 24;
|
||||||
|
default:
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
105
apps/desktop/src/lib/key-workspace-state.test.ts
Normal file
105
apps/desktop/src/lib/key-workspace-state.test.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
appendWorkspaceKeyRecords,
|
||||||
|
filterWorkspaceKeys,
|
||||||
|
replaceWorkspaceKeyRecord,
|
||||||
|
resolveSelectedWorkspaceKey,
|
||||||
|
resolveSelectedWorkspaceKeyId,
|
||||||
|
} from "./key-workspace-state";
|
||||||
|
|
||||||
|
const workspaceKeys = [
|
||||||
|
{
|
||||||
|
id: "session:42",
|
||||||
|
name: "session:42",
|
||||||
|
ttl: "13m",
|
||||||
|
preview: "hash value",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "cache:homepage",
|
||||||
|
name: "cache:homepage",
|
||||||
|
ttl: "48s",
|
||||||
|
preview: "string value",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("key workspace state helpers", () => {
|
||||||
|
it("filters keys by name using trimmed case-insensitive search", () => {
|
||||||
|
expect(filterWorkspaceKeys(workspaceKeys, " CACHE ")).toEqual([
|
||||||
|
workspaceKeys[1],
|
||||||
|
]);
|
||||||
|
expect(filterWorkspaceKeys(workspaceKeys, " ")).toEqual(workspaceKeys);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves the selected key and falls back to the first visible key", () => {
|
||||||
|
expect(resolveSelectedWorkspaceKey(workspaceKeys, "cache:homepage")).toEqual(
|
||||||
|
workspaceKeys[1],
|
||||||
|
);
|
||||||
|
expect(resolveSelectedWorkspaceKey(workspaceKeys, "missing")).toEqual(
|
||||||
|
workspaceKeys[0],
|
||||||
|
);
|
||||||
|
expect(resolveSelectedWorkspaceKey([], "missing")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves the selected key id for list refresh flows", () => {
|
||||||
|
expect(resolveSelectedWorkspaceKeyId(workspaceKeys, "cache:homepage")).toBe(
|
||||||
|
"cache:homepage",
|
||||||
|
);
|
||||||
|
expect(resolveSelectedWorkspaceKeyId(workspaceKeys, "missing")).toBe(
|
||||||
|
"session:42",
|
||||||
|
);
|
||||||
|
expect(resolveSelectedWorkspaceKeyId([], "missing")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replaces an existing key while preserving untouched fields", () => {
|
||||||
|
expect(
|
||||||
|
replaceWorkspaceKeyRecord(workspaceKeys, {
|
||||||
|
id: "cache:homepage",
|
||||||
|
name: "cache:homepage",
|
||||||
|
ttl: "persistent",
|
||||||
|
}),
|
||||||
|
).toEqual([
|
||||||
|
workspaceKeys[0],
|
||||||
|
{
|
||||||
|
...workspaceKeys[1],
|
||||||
|
ttl: "persistent",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prepends unseen replacements and appends only new paged keys", () => {
|
||||||
|
expect(
|
||||||
|
replaceWorkspaceKeyRecord(workspaceKeys, {
|
||||||
|
id: "smoke:string",
|
||||||
|
name: "smoke:string",
|
||||||
|
ttl: "5m",
|
||||||
|
}),
|
||||||
|
).toEqual([
|
||||||
|
{
|
||||||
|
id: "smoke:string",
|
||||||
|
name: "smoke:string",
|
||||||
|
ttl: "5m",
|
||||||
|
},
|
||||||
|
...workspaceKeys,
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
appendWorkspaceKeyRecords(workspaceKeys, [
|
||||||
|
workspaceKeys[1],
|
||||||
|
{
|
||||||
|
id: "smoke:string",
|
||||||
|
name: "smoke:string",
|
||||||
|
ttl: "5m",
|
||||||
|
preview: "fixture key",
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
).toEqual([
|
||||||
|
...workspaceKeys,
|
||||||
|
{
|
||||||
|
id: "smoke:string",
|
||||||
|
name: "smoke:string",
|
||||||
|
ttl: "5m",
|
||||||
|
preview: "fixture key",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
76
apps/desktop/src/lib/key-workspace-state.ts
Normal file
76
apps/desktop/src/lib/key-workspace-state.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
export type WorkspaceKeyIdentity = {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorkspaceSearchableKey = WorkspaceKeyIdentity & {
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function filterWorkspaceKeys<T extends WorkspaceSearchableKey>(
|
||||||
|
keys: T[],
|
||||||
|
search: string,
|
||||||
|
) {
|
||||||
|
const query = search.trim().toLowerCase();
|
||||||
|
if (!query) {
|
||||||
|
return keys;
|
||||||
|
}
|
||||||
|
|
||||||
|
return keys.filter((item) => item.name.toLowerCase().includes(query));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveSelectedWorkspaceKey<T extends WorkspaceKeyIdentity>(
|
||||||
|
keys: T[],
|
||||||
|
selectedKeyId: string | null,
|
||||||
|
) {
|
||||||
|
if (keys.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return keys.find((item) => item.id === selectedKeyId) ?? keys[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveSelectedWorkspaceKeyId<T extends WorkspaceKeyIdentity>(
|
||||||
|
keys: T[],
|
||||||
|
selectedKeyId: string | null,
|
||||||
|
) {
|
||||||
|
return resolveSelectedWorkspaceKey(keys, selectedKeyId)?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function replaceWorkspaceKeyRecord<T extends WorkspaceKeyIdentity>(
|
||||||
|
current: T[],
|
||||||
|
next: T,
|
||||||
|
) {
|
||||||
|
let replaced = false;
|
||||||
|
const updated = current.map((item) => {
|
||||||
|
if (item.id !== next.id) {
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
replaced = true;
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
...next,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return replaced ? updated : [next, ...current];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function appendWorkspaceKeyRecords<T extends WorkspaceKeyIdentity>(
|
||||||
|
current: T[],
|
||||||
|
next: T[],
|
||||||
|
) {
|
||||||
|
const seen = new Set(current.map((item) => item.id));
|
||||||
|
const appended = [...current];
|
||||||
|
|
||||||
|
for (const item of next) {
|
||||||
|
if (seen.has(item.id)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
seen.add(item.id);
|
||||||
|
appended.push(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
return appended;
|
||||||
|
}
|
||||||
55
apps/desktop/src/lib/local-smoke-fixture.test.ts
Normal file
55
apps/desktop/src/lib/local-smoke-fixture.test.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
isLocalSmokeFixtureConnection,
|
||||||
|
localSmokeFixtureCommandPresets,
|
||||||
|
localSmokeFixtureExpectedKeys,
|
||||||
|
localSmokeFixtureProfileId,
|
||||||
|
localSmokeFixtureSearchText,
|
||||||
|
} from "./local-smoke-fixture";
|
||||||
|
|
||||||
|
describe("local smoke fixture helpers", () => {
|
||||||
|
it("keeps the expected local smoke verification targets stable", () => {
|
||||||
|
expect(localSmokeFixtureProfileId).toBe("redis-local-fixture");
|
||||||
|
expect(localSmokeFixtureSearchText).toBe("smoke:*");
|
||||||
|
expect(localSmokeFixtureExpectedKeys).toEqual([
|
||||||
|
"smoke:string",
|
||||||
|
"smoke:string:ttl",
|
||||||
|
"smoke:hash",
|
||||||
|
"smoke:list",
|
||||||
|
"smoke:set",
|
||||||
|
"smoke:zset",
|
||||||
|
"smoke:stream",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects the built-in local fixture profile by id or target", () => {
|
||||||
|
expect(
|
||||||
|
isLocalSmokeFixtureConnection({
|
||||||
|
id: "redis-local-fixture",
|
||||||
|
target: { host: "10.0.0.1", port: 6379, database: 2 },
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
isLocalSmokeFixtureConnection({
|
||||||
|
id: "custom-profile",
|
||||||
|
target: { host: "127.0.0.1", port: 6380, database: 0 },
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
isLocalSmokeFixtureConnection({
|
||||||
|
id: "redis-local-fixture",
|
||||||
|
target: { host: "127.0.0.1", port: 6380, database: 1 },
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps fixture quick commands on a safe read-only path", () => {
|
||||||
|
expect(localSmokeFixtureCommandPresets.map((preset) => preset.command)).toEqual([
|
||||||
|
"PING",
|
||||||
|
"GET smoke:string",
|
||||||
|
"TTL smoke:string:ttl",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
53
apps/desktop/src/lib/local-smoke-fixture.ts
Normal file
53
apps/desktop/src/lib/local-smoke-fixture.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
export const localSmokeFixtureProfileId = "redis-local-fixture";
|
||||||
|
export const localSmokeFixtureSearchText = "smoke:*";
|
||||||
|
|
||||||
|
export const localSmokeFixtureExpectedKeys = [
|
||||||
|
"smoke:string",
|
||||||
|
"smoke:string:ttl",
|
||||||
|
"smoke:hash",
|
||||||
|
"smoke:list",
|
||||||
|
"smoke:set",
|
||||||
|
"smoke:zset",
|
||||||
|
"smoke:stream",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const localSmokeFixtureCommandPresets = [
|
||||||
|
{
|
||||||
|
id: "ping",
|
||||||
|
label: "PING",
|
||||||
|
command: "PING",
|
||||||
|
summary: "Validate the desktop command bridge against the seeded fixture.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "get-string",
|
||||||
|
label: "GET smoke:string",
|
||||||
|
command: "GET smoke:string",
|
||||||
|
summary: "Verify the string read path against the seeded smoke key.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ttl-string",
|
||||||
|
label: "TTL smoke:string:ttl",
|
||||||
|
command: "TTL smoke:string:ttl",
|
||||||
|
summary: "Verify the TTL path before using the inspector controls.",
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type LocalSmokeFixtureTarget = {
|
||||||
|
id: string;
|
||||||
|
target: {
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
database: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function isLocalSmokeFixtureConnection(
|
||||||
|
connection: LocalSmokeFixtureTarget,
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
connection.id === localSmokeFixtureProfileId ||
|
||||||
|
(connection.target.host === "127.0.0.1" &&
|
||||||
|
connection.target.port === 6380 &&
|
||||||
|
connection.target.database === 0)
|
||||||
|
);
|
||||||
|
}
|
||||||
72
apps/desktop/src/lib/qa-snapshot.test.ts
Normal file
72
apps/desktop/src/lib/qa-snapshot.test.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { buildQaSnapshot } from "./qa-snapshot";
|
||||||
|
|
||||||
|
describe("qa snapshot", () => {
|
||||||
|
it("serializes the current smoke context into a stable line-oriented summary", () => {
|
||||||
|
expect(
|
||||||
|
buildQaSnapshot({
|
||||||
|
appName: "Redis GUI Foundation",
|
||||||
|
appVersion: "0.1.0",
|
||||||
|
runtimeMode: "live desktop bridge",
|
||||||
|
buildMode: "production",
|
||||||
|
connectionName: "redis-local-fixture",
|
||||||
|
endpoint: "127.0.0.1:6380",
|
||||||
|
databaseLabel: "db0",
|
||||||
|
smokeFixtureActive: true,
|
||||||
|
smokeFilter: "smoke:*",
|
||||||
|
selectedKeyName: "smoke:string",
|
||||||
|
selectedKeyType: "string",
|
||||||
|
selectedKeyTtl: "persistent",
|
||||||
|
browseVisibleCount: "7",
|
||||||
|
latestCommand: "PING",
|
||||||
|
latestCommandSource: "live",
|
||||||
|
latestCommandStatus: "ok",
|
||||||
|
bannerMessage: "Copied the current QA snapshot to the clipboard for smoke evidence.",
|
||||||
|
}),
|
||||||
|
).toEqual(
|
||||||
|
[
|
||||||
|
"app=Redis GUI Foundation",
|
||||||
|
"version=0.1.0",
|
||||||
|
"runtime=live desktop bridge",
|
||||||
|
"build=production",
|
||||||
|
"connection=redis-local-fixture",
|
||||||
|
"endpoint=127.0.0.1:6380",
|
||||||
|
"database=db0",
|
||||||
|
"smokeFixtureActive=yes",
|
||||||
|
"smokeFilter=smoke:*",
|
||||||
|
"selectedKey=smoke:string",
|
||||||
|
"selectedType=string",
|
||||||
|
"selectedTtl=persistent",
|
||||||
|
"browseVisible=7",
|
||||||
|
"latestCommand=PING",
|
||||||
|
"latestCommandSource=live",
|
||||||
|
"latestCommandStatus=ok",
|
||||||
|
"banner=Copied the current QA snapshot to the clipboard for smoke evidence.",
|
||||||
|
].join("\n"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes empty or missing values so smoke logs stay readable", () => {
|
||||||
|
expect(
|
||||||
|
buildQaSnapshot({
|
||||||
|
appName: "Redis GUI Foundation",
|
||||||
|
appVersion: "0.1.0",
|
||||||
|
runtimeMode: "browser demo fallback",
|
||||||
|
buildMode: "dev",
|
||||||
|
connectionName: "redis-dev-eu-1",
|
||||||
|
endpoint: "127.0.0.1:6379",
|
||||||
|
databaseLabel: "db1",
|
||||||
|
smokeFixtureActive: false,
|
||||||
|
smokeFilter: " ",
|
||||||
|
selectedKeyName: null,
|
||||||
|
selectedKeyType: null,
|
||||||
|
selectedKeyTtl: null,
|
||||||
|
browseVisibleCount: "0",
|
||||||
|
latestCommand: null,
|
||||||
|
latestCommandSource: null,
|
||||||
|
latestCommandStatus: null,
|
||||||
|
bannerMessage: "No key matches the current pattern.",
|
||||||
|
}),
|
||||||
|
).toContain("smokeFilter=none");
|
||||||
|
});
|
||||||
|
});
|
||||||
41
apps/desktop/src/lib/qa-snapshot.ts
Normal file
41
apps/desktop/src/lib/qa-snapshot.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
export type QaSnapshotInput = {
|
||||||
|
appName: string;
|
||||||
|
appVersion: string;
|
||||||
|
runtimeMode: string;
|
||||||
|
buildMode: string;
|
||||||
|
connectionName: string;
|
||||||
|
endpoint: string;
|
||||||
|
databaseLabel: string;
|
||||||
|
smokeFixtureActive: boolean;
|
||||||
|
smokeFilter: string;
|
||||||
|
selectedKeyName: string | null;
|
||||||
|
selectedKeyType: string | null;
|
||||||
|
selectedKeyTtl: string | null;
|
||||||
|
browseVisibleCount: string;
|
||||||
|
latestCommand: string | null;
|
||||||
|
latestCommandSource: string | null;
|
||||||
|
latestCommandStatus: string | null;
|
||||||
|
bannerMessage: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function buildQaSnapshot(input: QaSnapshotInput) {
|
||||||
|
return [
|
||||||
|
`app=${input.appName}`,
|
||||||
|
`version=${input.appVersion}`,
|
||||||
|
`runtime=${input.runtimeMode}`,
|
||||||
|
`build=${input.buildMode}`,
|
||||||
|
`connection=${input.connectionName}`,
|
||||||
|
`endpoint=${input.endpoint}`,
|
||||||
|
`database=${input.databaseLabel}`,
|
||||||
|
`smokeFixtureActive=${input.smokeFixtureActive ? "yes" : "no"}`,
|
||||||
|
`smokeFilter=${input.smokeFilter.trim() || "none"}`,
|
||||||
|
`selectedKey=${input.selectedKeyName ?? "none"}`,
|
||||||
|
`selectedType=${input.selectedKeyType ?? "none"}`,
|
||||||
|
`selectedTtl=${input.selectedKeyTtl ?? "none"}`,
|
||||||
|
`browseVisible=${input.browseVisibleCount}`,
|
||||||
|
`latestCommand=${input.latestCommand ?? "none"}`,
|
||||||
|
`latestCommandSource=${input.latestCommandSource ?? "none"}`,
|
||||||
|
`latestCommandStatus=${input.latestCommandStatus ?? "none"}`,
|
||||||
|
`banner=${input.bannerMessage}`,
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
99
apps/desktop/src/lib/structured-inspector.test.ts
Normal file
99
apps/desktop/src/lib/structured-inspector.test.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { buildStructuredInspectorSections } from "./structured-inspector";
|
||||||
|
|
||||||
|
describe("structured inspector sections", () => {
|
||||||
|
it("renders hash entries as field/value rows", () => {
|
||||||
|
expect(
|
||||||
|
buildStructuredInspectorSections({
|
||||||
|
metadata: { key_type: "hash" },
|
||||||
|
data: {
|
||||||
|
kind: "hash",
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
field: { kind: "string", value: "region" },
|
||||||
|
value: { kind: "string", value: "eu-west-1" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toEqual([
|
||||||
|
{
|
||||||
|
id: "hash",
|
||||||
|
title: "1 field",
|
||||||
|
summary: "Live HGETALL output rendered as field/value rows.",
|
||||||
|
primaryLabel: "Field",
|
||||||
|
secondaryLabel: "Value",
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
id: "hash-0",
|
||||||
|
primary: "region",
|
||||||
|
secondary: "eu-west-1",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders sorted sets and stream entries with readable secondary detail", () => {
|
||||||
|
expect(
|
||||||
|
buildStructuredInspectorSections({
|
||||||
|
metadata: { key_type: "stream" },
|
||||||
|
data: {
|
||||||
|
kind: "stream",
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
id: "1711642800000-0",
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
field: { kind: "string", value: "event" },
|
||||||
|
value: { kind: "string", value: "ttl-updated" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: { kind: "string", value: "payload" },
|
||||||
|
value: { kind: "binary", bytes: [1, 2, 3, 4] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toEqual([
|
||||||
|
{
|
||||||
|
id: "stream",
|
||||||
|
title: "1 entry",
|
||||||
|
summary: "Live XRANGE output rendered as stream entries with inline field payloads.",
|
||||||
|
primaryLabel: "Entry",
|
||||||
|
secondaryLabel: "Fields",
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
id: "1711642800000-0",
|
||||||
|
primary: "1711642800000-0",
|
||||||
|
secondary: "2 fields",
|
||||||
|
detailLines: ["event = ttl-updated", "payload = [binary 4 bytes]"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps string and missing payloads on the existing textarea path", () => {
|
||||||
|
expect(
|
||||||
|
buildStructuredInspectorSections({
|
||||||
|
metadata: { key_type: "string" },
|
||||||
|
data: {
|
||||||
|
kind: "string",
|
||||||
|
value: { kind: "string", value: "hello" },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toEqual([]);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
buildStructuredInspectorSections({
|
||||||
|
metadata: { key_type: "string" },
|
||||||
|
data: {
|
||||||
|
kind: "missing",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
155
apps/desktop/src/lib/structured-inspector.ts
Normal file
155
apps/desktop/src/lib/structured-inspector.ts
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
export type RedisValueContent =
|
||||||
|
| { kind: "string"; value: string }
|
||||||
|
| { kind: "binary"; bytes: number[] };
|
||||||
|
|
||||||
|
export type RedisFieldValueEntry = {
|
||||||
|
field: RedisValueContent;
|
||||||
|
value: RedisValueContent;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RedisSortedSetEntry = {
|
||||||
|
member: RedisValueContent;
|
||||||
|
score: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RedisStreamEntry = {
|
||||||
|
id: string;
|
||||||
|
fields: RedisFieldValueEntry[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export 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[] };
|
||||||
|
|
||||||
|
export type StructuredValueRecord = {
|
||||||
|
metadata: {
|
||||||
|
key_type: string;
|
||||||
|
};
|
||||||
|
data: RedisValueData;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StructuredInspectorRow = {
|
||||||
|
id: string;
|
||||||
|
primary: string;
|
||||||
|
secondary: string;
|
||||||
|
detailLines?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StructuredInspectorSection = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
summary: string;
|
||||||
|
primaryLabel: string;
|
||||||
|
secondaryLabel: string;
|
||||||
|
rows: StructuredInspectorRow[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function buildStructuredInspectorSections(
|
||||||
|
record: StructuredValueRecord | null,
|
||||||
|
): StructuredInspectorSection[] {
|
||||||
|
if (!record) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (record.data.kind) {
|
||||||
|
case "hash":
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: "hash",
|
||||||
|
title: `${record.data.entries.length} field${record.data.entries.length === 1 ? "" : "s"}`,
|
||||||
|
summary: "Live HGETALL output rendered as field/value rows.",
|
||||||
|
primaryLabel: "Field",
|
||||||
|
secondaryLabel: "Value",
|
||||||
|
rows: record.data.entries.map((entry, index) => ({
|
||||||
|
id: `hash-${index}`,
|
||||||
|
primary: formatStructuredContent(entry.field),
|
||||||
|
secondary: formatStructuredContent(entry.value),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
case "list":
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: "list",
|
||||||
|
title: `${record.data.items.length} item${record.data.items.length === 1 ? "" : "s"}`,
|
||||||
|
summary: "Live LRANGE output rendered in the preserved list order.",
|
||||||
|
primaryLabel: "Index",
|
||||||
|
secondaryLabel: "Value",
|
||||||
|
rows: record.data.items.map((item, index) => ({
|
||||||
|
id: `list-${index}`,
|
||||||
|
primary: `[${index}]`,
|
||||||
|
secondary: formatStructuredContent(item),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
case "set":
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: "set",
|
||||||
|
title: `${record.data.members.length} member${record.data.members.length === 1 ? "" : "s"}`,
|
||||||
|
summary: "Live SMEMBERS output rendered as unique set members.",
|
||||||
|
primaryLabel: "Member",
|
||||||
|
secondaryLabel: "Value",
|
||||||
|
rows: record.data.members.map((member, index) => ({
|
||||||
|
id: `set-${index}`,
|
||||||
|
primary: `member ${index + 1}`,
|
||||||
|
secondary: formatStructuredContent(member),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
case "sorted_set":
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: "sorted-set",
|
||||||
|
title: `${record.data.entries.length} scored member${record.data.entries.length === 1 ? "" : "s"}`,
|
||||||
|
summary: "Live ZRANGE ... WITHSCORES output rendered as member/score pairs.",
|
||||||
|
primaryLabel: "Member",
|
||||||
|
secondaryLabel: "Score",
|
||||||
|
rows: record.data.entries.map((entry, index) => ({
|
||||||
|
id: `sorted-set-${index}`,
|
||||||
|
primary: formatStructuredContent(entry.member),
|
||||||
|
secondary: entry.score,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
case "stream":
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: "stream",
|
||||||
|
title: `${record.data.entries.length} entr${record.data.entries.length === 1 ? "y" : "ies"}`,
|
||||||
|
summary: "Live XRANGE output rendered as stream entries with inline field payloads.",
|
||||||
|
primaryLabel: "Entry",
|
||||||
|
secondaryLabel: "Fields",
|
||||||
|
rows: record.data.entries.map((entry) => ({
|
||||||
|
id: entry.id,
|
||||||
|
primary: entry.id,
|
||||||
|
secondary: `${entry.fields.length} field${entry.fields.length === 1 ? "" : "s"}`,
|
||||||
|
detailLines: entry.fields.map(
|
||||||
|
(field) =>
|
||||||
|
`${formatStructuredContent(field.field)} = ${formatStructuredContent(field.value)}`,
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
case "missing":
|
||||||
|
case "string":
|
||||||
|
default:
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatStructuredContent(content: RedisValueContent) {
|
||||||
|
switch (content.kind) {
|
||||||
|
case "string":
|
||||||
|
return content.value;
|
||||||
|
case "binary":
|
||||||
|
return `[binary ${content.bytes.length} bytes]`;
|
||||||
|
default:
|
||||||
|
return "unsupported";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -87,6 +87,7 @@ Use a four-zone workspace:
|
|||||||
- shadcn-style UI primitives are used locally for buttons, inputs, textareas, badges, and dialogs.
|
- shadcn-style UI primitives are used locally for buttons, inputs, textareas, badges, and dialogs.
|
||||||
- Browser-only dev mode uses explicit demo fallback so the visible shell can still be reviewed without a Tauri runtime.
|
- Browser-only dev mode uses explicit demo fallback so the visible shell can still be reviewed without a Tauri runtime.
|
||||||
- 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.
|
- 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 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.
|
- 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.
|
- 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.
|
||||||
@@ -103,6 +104,7 @@ Use a four-zone workspace:
|
|||||||
- Invalid draft connections cannot be created from the visible shell.
|
- 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.
|
- 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 are explicitly read-only.
|
||||||
|
- Non-string keys render as structured typed panels instead of raw JSON text blobs.
|
||||||
- String keys show a save affordance near the value surface.
|
- String keys show a save affordance near the value surface.
|
||||||
- TTL controls stay beside the inspector metadata and expose both set and remove flows.
|
- 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.
|
- Delete requires a second confirmation step that includes key name and DB context.
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ Owner: QA Engineer
|
|||||||
| 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 |
|
| 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 |
|
| 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 |
|
| 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 stay explicitly read only, but fixture-backed evidence and broader editing scope remain 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 |
|
||||||
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
||||||
|
|||||||
33
plans/2026-03-28-hac-18-typed-inspector-panels.md
Normal file
33
plans/2026-03-28-hac-18-typed-inspector-panels.md
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# HAC-18 Typed Inspector Panels
|
||||||
|
|
||||||
|
Date: 2026-03-28 UTC
|
||||||
|
Owner: Senior Frontend Engineer
|
||||||
|
Issue: HAC-18
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Ship one more visible desktop feature slice without waiting on GUI screenshot capture or AppImage validation.
|
||||||
|
|
||||||
|
## Selected Scope
|
||||||
|
|
||||||
|
- upgrade the inspector for non-string Redis values from raw JSON text to structured read-only panels
|
||||||
|
- keep the existing string edit flow, TTL actions, and error notice foundation unchanged
|
||||||
|
- stay within the current `read_redis_value` contract and avoid backend expansion
|
||||||
|
- add minimum frontend tests around the new structured presentation mapping
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- no delete execution
|
||||||
|
- no backend contract changes
|
||||||
|
- no packaging or screenshot evidence work in this heartbeat
|
||||||
|
- no full redesign of the existing desktop shell
|
||||||
|
|
||||||
|
## Verification Plan
|
||||||
|
|
||||||
|
- `pnpm --filter @redis-gui/desktop test`
|
||||||
|
- `pnpm run desktop:build`
|
||||||
|
|
||||||
|
## Expected User Impact
|
||||||
|
|
||||||
|
- operators can scan `hash/list/set/zset/stream` payloads directly in the inspector without mentally parsing raw JSON
|
||||||
|
- the read-only boundary for non-string types remains explicit, but the viewing experience becomes materially more usable
|
||||||
32
plans/2026-03-31-hac-22-db-switcher-hardening.md
Normal file
32
plans/2026-03-31-hac-22-db-switcher-hardening.md
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# 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.
|
||||||
36
plans/2026-03-31-hac-26-workspace-state-foundation.md
Normal file
36
plans/2026-03-31-hac-26-workspace-state-foundation.md
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# HAC-26 Workspace State Foundation
|
||||||
|
|
||||||
|
Date: 2026-03-31 UTC
|
||||||
|
Owner: Senior Frontend Engineer
|
||||||
|
Issue: HAC-26
|
||||||
|
Scope: `apps/desktop` frontend foundation only
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
- `apps/desktop/src/App.tsx` already owns the visible desktop-v1 shell, but it still contains repeated inline logic for key filtering, selected-key fallback, live key replacement, and paged append handling.
|
||||||
|
- These state transitions are part of the browsing and inspector foundation, not one-off UI copy, and upcoming desktop-v1 slices will need them again.
|
||||||
|
- The PM comment on `HAC-26` asked for the highest reusable foundation return, not a new visible feature.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
- Do not expand product scope.
|
||||||
|
- Extract reusable key-workspace state helpers from `App.tsx` into `src/lib`.
|
||||||
|
- Cover the extracted state transitions with focused Vitest cases so future desktop-v1 changes can reuse the same behavior safely.
|
||||||
|
|
||||||
|
## Planned Implementation
|
||||||
|
|
||||||
|
1. Create a shared frontend utility for:
|
||||||
|
- filtering visible keys by search text
|
||||||
|
- resolving selected key fallback when visible results change
|
||||||
|
- replacing a key record after live metadata/value refresh
|
||||||
|
- appending paged browse results without duplicate rows
|
||||||
|
2. Update `App.tsx` to consume the shared utility instead of inline logic.
|
||||||
|
3. Add Vitest coverage for zero-result fallback, selection resolution, replacement merge behavior, and append deduplication.
|
||||||
|
4. Re-run `pnpm --filter @redis-gui/desktop test` and `pnpm run desktop:build`.
|
||||||
|
|
||||||
|
## Acceptance
|
||||||
|
|
||||||
|
- `App.tsx` no longer owns the reusable key-workspace list and selection transitions inline.
|
||||||
|
- The extracted helper is covered by focused tests.
|
||||||
|
- Existing desktop frontend tests and production build remain green.
|
||||||
|
- No backend contract or visible feature scope changes are introduced.
|
||||||
Reference in New Issue
Block a user