Compare commits
16 Commits
cmp-16-loc
...
cmp-35-des
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cba41cb0a6 | ||
|
|
f890809420 | ||
|
|
5b1cb359ce | ||
|
|
0edeb27f94 | ||
|
|
b6cbe5a47e | ||
|
|
9c404d54a0 | ||
|
|
e83c518081 | ||
|
|
8b6689c445 | ||
|
|
4839165657 | ||
|
|
35040ef0c9 | ||
|
|
969744733f | ||
|
|
bf6f8bc0bf | ||
|
|
04da063abf | ||
|
|
291f744e0f | ||
|
|
7c1abf2fd1 | ||
|
|
a9524dfbf6 |
19
README.md
@@ -32,10 +32,12 @@ Redis GUI 的产品基础工程,采用 Rust workspace + Tauri desktop 结构
|
||||
```bash
|
||||
pnpm install
|
||||
cargo test -p redis-core
|
||||
pnpm --filter @redis-gui/desktop test
|
||||
pnpm run desktop:dev
|
||||
pnpm run desktop:build
|
||||
pnpm run desktop:tauri:dev
|
||||
pnpm run desktop:tauri:build
|
||||
pnpm run desktop:tauri:build:all
|
||||
```
|
||||
|
||||
说明:
|
||||
@@ -43,9 +45,11 @@ pnpm run desktop:tauri:build
|
||||
- 仓库现在同时补齐了 `pnpm-workspace.yaml` 和根 `package.json` 的 `packageManager`,将 `pnpm` 作为桌面开发与打包的规范入口。
|
||||
- 仓库通过 `.npmrc` 固定安装 devDependencies,避免宿主机的 `NODE_ENV=production` 破坏桌面构建链路。
|
||||
- `cargo test -p redis-core` 默认验证共享后端核心,包括 mock Redis 握手与命令执行测试,避免在缺少桌面系统库时阻断后端迭代。
|
||||
- `pnpm run desktop:dev` 启动浏览器态工作台壳层,用于检查布局、交互和 demo fallback;此模式下没有 Tauri 命令桥。
|
||||
- `pnpm --filter @redis-gui/desktop test` 运行桌面前端的最小单测基线;当前覆盖共享错误映射层,避免后续功能迭代把后端错误码重新退回成零散文案拼接。
|
||||
- `pnpm run desktop:dev` 启动浏览器态工作台壳层,用于检查布局、交互和 demo fallback;此模式下没有 Tauri 命令桥,但会在当前会话内保留 mock string 保存和 TTL 调整结果,便于做壳层级交互验收。
|
||||
- `pnpm run desktop:tauri:dev` 启动带 Tauri 后端桥的桌面工作台;连接测试和命令执行会调用现有 `test_redis_connection` 与 `execute_redis_command` 命令,因此需要完整的 Tauri Linux 运行依赖。
|
||||
- `pnpm run desktop:tauri:build` 会先执行桌面前端 build,再进入 Tauri/Rust 的真实打包流程;当前 Linux `.deb` 与 `.rpm` 产物位于仓库根 `target/release/bundle/`。
|
||||
- `pnpm run desktop:tauri:build` 是当前推荐的稳定 Linux 打包入口,只生成已验证可出的 `.deb` 与 `.rpm` 产物;产物位于仓库根 `target/release/bundle/`。
|
||||
- `pnpm run desktop:tauri:build:all` 保留完整 bundle 调查路径;当前会在 AppImage 的 `linuxdeploy` 阶段失败,因此不应作为默认 QA 打包命令。
|
||||
- Linux 上进入 Tauri 打包前,可先用 `pkg-config --modversion gtk+-3.0 webkit2gtk-4.1 libsoup-3.0` 做原生依赖预检查;任一缺失都会阻断桌面包产物生成。
|
||||
|
||||
## Local Redis Smoke Fixture
|
||||
@@ -77,7 +81,16 @@ pnpm run desktop:tauri:dev
|
||||
## Current Frontend Scope
|
||||
|
||||
- 首版可见界面现在已接入真实的桌面 browse / inspect / string save / TTL update 路径,但仍保持危险操作和完整产品化流程在后续阶段演进。
|
||||
- 当前前端消费 `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`、`create_redis_value`、`write_redis_value`、`update_redis_key_ttl` 和 `execute_redis_command`;浏览器态仍保持明确的 demo fallback。
|
||||
- 当前仓库还包含一个 string-only 的 `Add key` 可见入口,走 `create_redis_value` 合约;该能力已在仓库中实现,并在界面上明确标记为 `Preview`,但阶段验收仍需按 `docs/redis-gui-v1-product-requirements.md` 与 `docs/qa/acceptance-matrix.md` 单独判断,不应自动算入当前基线签收。
|
||||
- 当前前端还通过共享错误映射层统一消费 `redis-core` 的稳定错误码,使连接测试、浏览、读取、保存、TTL 和命令执行失败都落到一致的操作提示上。
|
||||
- 连接区域现在额外暴露一个可见的 session state 卡片,把 `ready / testing / demo fallback / retry needed` 边界和下一步操作提示固定在当前选中 connection / DB 语境里;如果用户在 live test 返回前切换了连接,测试结果只回写被测连接,不会偷偷改掉当前 active DB。
|
||||
- `inspect_redis_key` 与 `update_redis_key_ttl` 已在桌面桥可用;当前 UI 已暴露 TTL 设置与移除控制,但删除执行仍保持未开放状态。
|
||||
- 当前 phase 的 string 保存、TTL 设置和 TTL 移除在执行前都会进入 scoped review,重复展示 connection / DB / key 语境与 current-vs-next 变化,并阻止 no-op 提交直接触达 live bridge。
|
||||
- inspector 现在对 `hash/list/set/zset/stream` 提供结构化只读面板,不再要求操作者从原始 JSON 文本中手动解析 typed value。
|
||||
- inspector 现在还会明确显示当前 inspect 状态、value surface 语义、下一步操作路径和 key facts,降低 string / structured / missing / demo fallback 场景下的误读成本。
|
||||
- DB switcher 现在默认覆盖 `db0` 到 `db15`,并会在当前作用域落到更高 DB 时保留对应选项,避免 UI 退回到四个固定 DB 的弱验证面。
|
||||
- 连接测试、key browse、value inspector 和命令托盘在 Tauri 桌面运行时走真实后端桥;浏览器态 `pnpm run desktop:dev` 会明确回退到 demo 数据,不伪装成真实 Redis 行为。
|
||||
- 浏览器态现在还会在单次会话内同步 mock string 保存、TTL 设置和 TTL 移除的可见状态,保证壳层演示与 UI 反馈一致,但这仍不构成任何 live Redis 证据。
|
||||
- utility rail 现在额外提供本地 smoke assist 面板,可直接切换到 `127.0.0.1:6380 / db0` 的 fixture 路径、聚焦 `smoke:*` 键,并预填安全 smoke 命令。
|
||||
- 具体 UI/UX 边界与 QA 验收入口见 `docs/frontend-workspace-baseline.md` 和 `docs/qa/acceptance-matrix.md`。
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0 --port 1420",
|
||||
"build": "vite build",
|
||||
"test": "vitest run",
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -23,6 +24,7 @@
|
||||
"@tauri-apps/cli": "^2.1.0",
|
||||
"@vitejs/plugin-react": "^5.0.2",
|
||||
"tailwindcss": "^4.1.12",
|
||||
"vite": "^7.1.0"
|
||||
"vite": "^7.1.0",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
|
||||
BIN
apps/desktop/src-tauri/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
apps/desktop/src-tauri/icons/128x128@2x.png
Normal file
|
After Width: | Height: | Size: 6.7 KiB |
BIN
apps/desktop/src-tauri/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 993 B |
BIN
apps/desktop/src-tauri/icons/64x64.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
apps/desktop/src-tauri/icons/Square107x107Logo.png
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
BIN
apps/desktop/src-tauri/icons/Square142x142Logo.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
apps/desktop/src-tauri/icons/Square150x150Logo.png
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
BIN
apps/desktop/src-tauri/icons/Square284x284Logo.png
Normal file
|
After Width: | Height: | Size: 7.5 KiB |
BIN
apps/desktop/src-tauri/icons/Square30x30Logo.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
apps/desktop/src-tauri/icons/Square310x310Logo.png
Normal file
|
After Width: | Height: | Size: 8.2 KiB |
BIN
apps/desktop/src-tauri/icons/Square44x44Logo.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
apps/desktop/src-tauri/icons/Square71x71Logo.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
apps/desktop/src-tauri/icons/Square89x89Logo.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
apps/desktop/src-tauri/icons/StoreLogo.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
</adaptive-icon>
|
||||
BIN
apps/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
BIN
apps/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 9.2 KiB |
|
After Width: | Height: | Size: 8.7 KiB |
|
After Width: | Height: | Size: 8.0 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#fff</color>
|
||||
</resources>
|
||||
11
apps/desktop/src-tauri/icons/app-icon.svg
Normal file
@@ -0,0 +1,11 @@
|
||||
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="512" height="512" rx="132" fill="#1F5C52"/>
|
||||
<rect x="52" y="52" width="408" height="408" rx="104" fill="#F3F0EA"/>
|
||||
<rect x="104" y="118" width="304" height="276" rx="56" fill="#1F5C52"/>
|
||||
<ellipse cx="256" cy="178" rx="108" ry="34" fill="#DCECE7"/>
|
||||
<path d="M148 178V238C148 256.778 196.353 272 256 272C315.647 272 364 256.778 364 238V178" stroke="#DCECE7" stroke-width="24" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M148 238V298C148 316.778 196.353 332 256 332C315.647 332 364 316.778 364 298V238" stroke="#DCECE7" stroke-width="24" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<ellipse cx="256" cy="298" rx="108" ry="34" fill="#DCECE7"/>
|
||||
<rect x="190" y="376" width="132" height="24" rx="12" fill="#DCECE7"/>
|
||||
<rect x="334" y="360" width="30" height="56" rx="15" fill="#F08C7D"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 938 B |
BIN
apps/desktop/src-tauri/icons/icon.icns
Normal file
BIN
apps/desktop/src-tauri/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 70 B After Width: | Height: | Size: 14 KiB |
BIN
apps/desktop/src-tauri/icons/ios/AppIcon-20x20@1x.png
Normal file
|
After Width: | Height: | Size: 742 B |
BIN
apps/desktop/src-tauri/icons/ios/AppIcon-20x20@2x-1.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
apps/desktop/src-tauri/icons/ios/AppIcon-20x20@2x.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
apps/desktop/src-tauri/icons/ios/AppIcon-20x20@3x.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
apps/desktop/src-tauri/icons/ios/AppIcon-29x29@1x.png
Normal file
|
After Width: | Height: | Size: 962 B |
BIN
apps/desktop/src-tauri/icons/ios/AppIcon-29x29@2x-1.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
apps/desktop/src-tauri/icons/ios/AppIcon-29x29@2x.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
apps/desktop/src-tauri/icons/ios/AppIcon-29x29@3x.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
apps/desktop/src-tauri/icons/ios/AppIcon-40x40@1x.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
apps/desktop/src-tauri/icons/ios/AppIcon-40x40@2x-1.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
apps/desktop/src-tauri/icons/ios/AppIcon-40x40@2x.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
apps/desktop/src-tauri/icons/ios/AppIcon-40x40@3x.png
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
apps/desktop/src-tauri/icons/ios/AppIcon-512@2x.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
apps/desktop/src-tauri/icons/ios/AppIcon-60x60@2x.png
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
apps/desktop/src-tauri/icons/ios/AppIcon-60x60@3x.png
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
BIN
apps/desktop/src-tauri/icons/ios/AppIcon-76x76@1x.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
apps/desktop/src-tauri/icons/ios/AppIcon-76x76@2x.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
apps/desktop/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png
Normal file
|
After Width: | Height: | Size: 4.4 KiB |
@@ -2,8 +2,9 @@ use redis_core::{
|
||||
BackendBootstrap, BackendError, CommandExecutionResult, ConnectionTestResult,
|
||||
RedisCommandRequest, RedisConnectionRequest, RedisKeyBrowseRequest, RedisKeyBrowseResult,
|
||||
RedisKeyMetadata, RedisKeyMetadataRequest, RedisKeyTtlUpdateRequest, RedisKeyTtlUpdateResult,
|
||||
RedisValueReadRequest, RedisValueRecord, RedisValueWriteRequest, browse_keys, execute_command,
|
||||
inspect_key, read_value, test_connection, update_key_ttl, write_value,
|
||||
RedisValueCreateRequest, RedisValueCreateResult, RedisValueReadRequest, RedisValueRecord,
|
||||
RedisValueWriteRequest, browse_keys, create_value, execute_command, inspect_key, read_value,
|
||||
test_connection, update_key_ttl, write_value,
|
||||
};
|
||||
|
||||
#[tauri::command]
|
||||
@@ -45,6 +46,13 @@ fn write_redis_value(request: RedisValueWriteRequest) -> Result<RedisValueRecord
|
||||
write_value(request)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn create_redis_value(
|
||||
request: RedisValueCreateRequest,
|
||||
) -> Result<RedisValueCreateResult, BackendError> {
|
||||
create_value(request)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn update_redis_key_ttl(
|
||||
request: RedisKeyTtlUpdateRequest,
|
||||
@@ -62,6 +70,7 @@ fn main() {
|
||||
inspect_redis_key,
|
||||
read_redis_value,
|
||||
write_redis_value,
|
||||
create_redis_value,
|
||||
update_redis_key_ttl
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
|
||||
@@ -25,6 +25,14 @@
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all"
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.png",
|
||||
"icons/icon.ico",
|
||||
"icons/icon.icns"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
62
apps/desktop/src/lib/add-key-draft.test.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createDemoKeyRecord,
|
||||
findDuplicateKeyName,
|
||||
parseOptionalTtlMillis,
|
||||
validateAddKeyDraft,
|
||||
} from "./add-key-draft";
|
||||
|
||||
describe("add key draft helpers", () => {
|
||||
it("validates required key name and optional TTL shape", () => {
|
||||
expect(
|
||||
validateAddKeyDraft({
|
||||
name: " ",
|
||||
value: "hello world",
|
||||
ttlMillis: "0",
|
||||
}),
|
||||
).toEqual({
|
||||
name: "Redis key name must not be empty.",
|
||||
ttlMillis: "TTL must be greater than zero when provided.",
|
||||
});
|
||||
|
||||
expect(
|
||||
validateAddKeyDraft({
|
||||
name: "draft:key",
|
||||
value: "",
|
||||
ttlMillis: "soon",
|
||||
}),
|
||||
).toEqual({
|
||||
ttlMillis: "TTL must be an integer number of milliseconds when provided.",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses optional TTL input and preserves blank as persistent", () => {
|
||||
expect(parseOptionalTtlMillis("")).toBeNull();
|
||||
expect(parseOptionalTtlMillis(" 60000 ")).toBe(60000);
|
||||
});
|
||||
|
||||
it("detects duplicate key names with Redis-style case-sensitive matching", () => {
|
||||
expect(findDuplicateKeyName([{ name: "draft:key" }], "draft:key")).toBe(true);
|
||||
expect(findDuplicateKeyName([{ name: "draft:key" }], "Draft:Key")).toBe(false);
|
||||
});
|
||||
|
||||
it("builds a demo string key record with optional TTL formatting", () => {
|
||||
expect(
|
||||
createDemoKeyRecord({
|
||||
name: " draft:key ",
|
||||
value: "hello world",
|
||||
ttlMillis: "60000",
|
||||
}),
|
||||
).toEqual({
|
||||
id: "draft:key",
|
||||
name: "draft:key",
|
||||
type: "string",
|
||||
ttl: "1m",
|
||||
preview: "hello world",
|
||||
editable: true,
|
||||
value: "hello world",
|
||||
summary:
|
||||
"String-only Add Key flow stays aligned to the bounded create contract and keeps the new key visible in the current workspace scope.",
|
||||
});
|
||||
});
|
||||
});
|
||||
67
apps/desktop/src/lib/add-key-draft.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import {
|
||||
buildDemoStringPreview,
|
||||
formatDemoDurationFromMillis,
|
||||
type DemoWorkspaceKey,
|
||||
} from "./demo-workspace";
|
||||
|
||||
export type AddKeyDraft = {
|
||||
name: string;
|
||||
value: string;
|
||||
ttlMillis: string;
|
||||
};
|
||||
|
||||
export type AddKeyDraftErrors = Partial<Record<"name" | "ttlMillis", string>>;
|
||||
|
||||
export function validateAddKeyDraft(draft: AddKeyDraft): AddKeyDraftErrors {
|
||||
const errors: AddKeyDraftErrors = {};
|
||||
|
||||
if (!draft.name.trim()) {
|
||||
errors.name = "Redis key name must not be empty.";
|
||||
}
|
||||
|
||||
const ttlValue = draft.ttlMillis.trim();
|
||||
if (ttlValue) {
|
||||
if (!/^\d+$/.test(ttlValue)) {
|
||||
errors.ttlMillis = "TTL must be an integer number of milliseconds when provided.";
|
||||
} else if (Number.parseInt(ttlValue, 10) <= 0) {
|
||||
errors.ttlMillis = "TTL must be greater than zero when provided.";
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
export function parseOptionalTtlMillis(value: string) {
|
||||
const normalized = value.trim();
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = Number.parseInt(normalized, 10);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
export function findDuplicateKeyName<T extends { name: string }>(
|
||||
keys: T[],
|
||||
keyName: string,
|
||||
) {
|
||||
const normalized = keyName.trim();
|
||||
return keys.some((key) => key.name === normalized);
|
||||
}
|
||||
|
||||
export function createDemoKeyRecord(draft: AddKeyDraft): DemoWorkspaceKey {
|
||||
const keyName = draft.name.trim();
|
||||
const ttlMillis = parseOptionalTtlMillis(draft.ttlMillis);
|
||||
|
||||
return {
|
||||
id: keyName,
|
||||
name: keyName,
|
||||
type: "string",
|
||||
ttl: ttlMillis === null ? "persistent" : formatDemoDurationFromMillis(ttlMillis),
|
||||
preview: buildDemoStringPreview(draft.value),
|
||||
editable: true,
|
||||
value: draft.value,
|
||||
summary:
|
||||
"String-only Add Key flow stays aligned to the bounded create contract and keeps the new key visible in the current workspace scope.",
|
||||
};
|
||||
}
|
||||
151
apps/desktop/src/lib/connection-session.test.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
applyConnectionTestFailure,
|
||||
applyConnectionTestSuccess,
|
||||
buildConnectionSessionSummary,
|
||||
formatConnectionCardDetailLabel,
|
||||
formatConnectionStatusLabel,
|
||||
markConnectionTesting,
|
||||
} from "./connection-session";
|
||||
|
||||
const baseConnection = {
|
||||
id: "redis-local",
|
||||
name: "redis-local",
|
||||
detail: "Draft profile",
|
||||
status: "idle" as const,
|
||||
target: {
|
||||
database: 0,
|
||||
username: null,
|
||||
},
|
||||
};
|
||||
|
||||
describe("connection session helpers", () => {
|
||||
it("keeps connection test transitions in one place", () => {
|
||||
const checking = markConnectionTesting([baseConnection], baseConnection.id);
|
||||
expect(checking[0]).toMatchObject({
|
||||
status: "checking",
|
||||
detail: "Live test in progress",
|
||||
});
|
||||
|
||||
const healthy = applyConnectionTestSuccess(checking, baseConnection.id, {
|
||||
roundTripStatus: "PONG in 11ms",
|
||||
selectedDatabase: 4,
|
||||
authenticatedAs: "readonly",
|
||||
});
|
||||
expect(healthy[0]).toMatchObject({
|
||||
status: "healthy",
|
||||
detail: "PONG in 11ms",
|
||||
target: {
|
||||
database: 4,
|
||||
username: "readonly",
|
||||
},
|
||||
});
|
||||
|
||||
const failed = applyConnectionTestFailure(healthy, baseConnection.id, "authentication_failed");
|
||||
expect(failed[0]).toMatchObject({
|
||||
status: "error",
|
||||
detail: "authentication_failed",
|
||||
});
|
||||
});
|
||||
|
||||
it("formats consistent status and card copy", () => {
|
||||
expect(formatConnectionStatusLabel(baseConnection)).toBe("Idle · Draft profile");
|
||||
expect(
|
||||
formatConnectionStatusLabel({
|
||||
...baseConnection,
|
||||
status: "healthy",
|
||||
detail: "PONG in 11ms",
|
||||
}),
|
||||
).toBe("Ready · PONG in 11ms");
|
||||
expect(
|
||||
formatConnectionCardDetailLabel({
|
||||
...baseConnection,
|
||||
status: "error",
|
||||
detail: "authentication_failed",
|
||||
}),
|
||||
).toBe("retry authentication_failed");
|
||||
});
|
||||
|
||||
it("builds session summaries for demo, testing, ready, and retry states", () => {
|
||||
expect(
|
||||
buildConnectionSessionSummary({
|
||||
connection: baseConnection,
|
||||
databaseLabel: "db0",
|
||||
tauriAvailable: false,
|
||||
activeTestRunning: false,
|
||||
otherTestingConnectionName: null,
|
||||
}),
|
||||
).toMatchObject({
|
||||
badgeLabel: "Demo fallback",
|
||||
badgeVariant: "neutral",
|
||||
});
|
||||
|
||||
expect(
|
||||
buildConnectionSessionSummary({
|
||||
connection: baseConnection,
|
||||
databaseLabel: "db0",
|
||||
tauriAvailable: true,
|
||||
activeTestRunning: true,
|
||||
otherTestingConnectionName: null,
|
||||
}),
|
||||
).toMatchObject({
|
||||
badgeLabel: "Testing",
|
||||
badgeVariant: "accent",
|
||||
});
|
||||
|
||||
expect(
|
||||
buildConnectionSessionSummary({
|
||||
connection: {
|
||||
...baseConnection,
|
||||
status: "healthy",
|
||||
detail: "PONG in 11ms",
|
||||
},
|
||||
databaseLabel: "db4",
|
||||
tauriAvailable: true,
|
||||
activeTestRunning: false,
|
||||
otherTestingConnectionName: null,
|
||||
}),
|
||||
).toMatchObject({
|
||||
badgeLabel: "Ready",
|
||||
badgeVariant: "accent",
|
||||
});
|
||||
|
||||
expect(
|
||||
buildConnectionSessionSummary({
|
||||
connection: {
|
||||
...baseConnection,
|
||||
status: "error",
|
||||
detail: "authentication_failed",
|
||||
},
|
||||
databaseLabel: "db0",
|
||||
tauriAvailable: true,
|
||||
activeTestRunning: false,
|
||||
otherTestingConnectionName: null,
|
||||
}),
|
||||
).toMatchObject({
|
||||
badgeLabel: "Retry needed",
|
||||
badgeVariant: "danger",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the active session copy clear when another connection is testing", () => {
|
||||
expect(
|
||||
buildConnectionSessionSummary({
|
||||
connection: {
|
||||
...baseConnection,
|
||||
status: "healthy",
|
||||
detail: "PONG in 11ms",
|
||||
},
|
||||
databaseLabel: "db0",
|
||||
tauriAvailable: true,
|
||||
activeTestRunning: false,
|
||||
otherTestingConnectionName: "redis-staging",
|
||||
}),
|
||||
).toMatchObject({
|
||||
badgeLabel: "Ready",
|
||||
badgeVariant: "neutral",
|
||||
summary:
|
||||
"redis-local remains selected on db0 while redis-staging finishes a separate live test.",
|
||||
});
|
||||
});
|
||||
});
|
||||
179
apps/desktop/src/lib/connection-session.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
export type SessionConnection = {
|
||||
id: string;
|
||||
name: string;
|
||||
detail: string;
|
||||
status: "healthy" | "idle" | "checking" | "error";
|
||||
target: {
|
||||
database: number;
|
||||
username: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
export type ConnectionTestState = {
|
||||
connectionId: string;
|
||||
};
|
||||
|
||||
export type ConnectionSessionSummary = {
|
||||
badgeLabel: string;
|
||||
badgeVariant: "neutral" | "accent" | "danger";
|
||||
summary: string;
|
||||
hint: string;
|
||||
};
|
||||
|
||||
export type ConnectionTestSuccessResult = {
|
||||
roundTripStatus: string;
|
||||
selectedDatabase: number;
|
||||
authenticatedAs: string | null;
|
||||
};
|
||||
|
||||
export function formatConnectionStatusLabel(connection: SessionConnection) {
|
||||
if (connection.status === "healthy") {
|
||||
return `Ready · ${connection.detail}`;
|
||||
}
|
||||
|
||||
if (connection.status === "checking") {
|
||||
return "Testing · Live contract probe";
|
||||
}
|
||||
|
||||
if (connection.status === "error") {
|
||||
return `Attention · ${connection.detail}`;
|
||||
}
|
||||
|
||||
return `Idle · ${connection.detail}`;
|
||||
}
|
||||
|
||||
export function formatConnectionCardDetailLabel(connection: SessionConnection) {
|
||||
if (connection.status === "checking") {
|
||||
return "live test in progress";
|
||||
}
|
||||
|
||||
if (connection.status === "healthy") {
|
||||
return `ready ${connection.detail}`;
|
||||
}
|
||||
|
||||
if (connection.status === "error") {
|
||||
return `retry ${connection.detail}`;
|
||||
}
|
||||
|
||||
return connection.detail;
|
||||
}
|
||||
|
||||
export function markConnectionTesting<T extends SessionConnection>(
|
||||
connections: T[],
|
||||
connectionId: string,
|
||||
) {
|
||||
return connections.map((connection) =>
|
||||
connection.id === connectionId
|
||||
? {
|
||||
...connection,
|
||||
status: "checking" as const,
|
||||
detail: "Live test in progress",
|
||||
}
|
||||
: connection,
|
||||
);
|
||||
}
|
||||
|
||||
export function applyConnectionTestSuccess<T extends SessionConnection>(
|
||||
connections: T[],
|
||||
connectionId: string,
|
||||
result: ConnectionTestSuccessResult,
|
||||
) {
|
||||
return connections.map((connection) =>
|
||||
connection.id === connectionId
|
||||
? {
|
||||
...connection,
|
||||
status: "healthy" as const,
|
||||
detail: result.roundTripStatus,
|
||||
target: {
|
||||
...connection.target,
|
||||
database: result.selectedDatabase,
|
||||
username: result.authenticatedAs ?? connection.target.username,
|
||||
},
|
||||
}
|
||||
: connection,
|
||||
);
|
||||
}
|
||||
|
||||
export function applyConnectionTestFailure<T extends SessionConnection>(
|
||||
connections: T[],
|
||||
connectionId: string,
|
||||
detail: string,
|
||||
) {
|
||||
return connections.map((connection) =>
|
||||
connection.id === connectionId
|
||||
? {
|
||||
...connection,
|
||||
status: "error" as const,
|
||||
detail,
|
||||
}
|
||||
: connection,
|
||||
);
|
||||
}
|
||||
|
||||
export function buildConnectionSessionSummary(args: {
|
||||
connection: SessionConnection;
|
||||
databaseLabel: string;
|
||||
tauriAvailable: boolean;
|
||||
activeTestRunning: boolean;
|
||||
otherTestingConnectionName: string | null;
|
||||
}): ConnectionSessionSummary {
|
||||
const {
|
||||
connection,
|
||||
databaseLabel,
|
||||
tauriAvailable,
|
||||
activeTestRunning,
|
||||
otherTestingConnectionName,
|
||||
} = args;
|
||||
|
||||
if (!tauriAvailable) {
|
||||
return {
|
||||
badgeLabel: "Demo fallback",
|
||||
badgeVariant: "neutral",
|
||||
summary: `${connection.name} stays scoped to ${databaseLabel} in browser mode without contacting Redis.`,
|
||||
hint: "Use Test demo to verify shell flow only, then switch to the Tauri desktop runtime for a live connection retry.",
|
||||
};
|
||||
}
|
||||
|
||||
if (activeTestRunning) {
|
||||
return {
|
||||
badgeLabel: "Testing",
|
||||
badgeVariant: "accent",
|
||||
summary: `Running a live connection probe for ${connection.name} on ${databaseLabel}.`,
|
||||
hint: "Keep this scope selected if you want the tested database and retry messaging to update in place.",
|
||||
};
|
||||
}
|
||||
|
||||
if (otherTestingConnectionName) {
|
||||
return {
|
||||
badgeLabel: "Ready",
|
||||
badgeVariant: "neutral",
|
||||
summary: `${connection.name} remains selected on ${databaseLabel} while ${otherTestingConnectionName} finishes a separate live test.`,
|
||||
hint: "The current workspace stays stable. Switch back to the testing connection after the probe completes if you need to review its result.",
|
||||
};
|
||||
}
|
||||
|
||||
if (connection.status === "healthy") {
|
||||
return {
|
||||
badgeLabel: "Ready",
|
||||
badgeVariant: "accent",
|
||||
summary: `${connection.name} last passed a live connection check on ${databaseLabel}.`,
|
||||
hint: "Re-run Test live after changing host, DB, credentials, or after recovering from a Redis outage.",
|
||||
};
|
||||
}
|
||||
|
||||
if (connection.status === "error") {
|
||||
return {
|
||||
badgeLabel: "Retry needed",
|
||||
badgeVariant: "danger",
|
||||
summary: `${connection.name} last failed a live connection check on ${databaseLabel}.`,
|
||||
hint: "Review the banner and connection card detail, adjust the profile if needed, then retry Test live from this same scope.",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
badgeLabel: "Ready to test",
|
||||
badgeVariant: "neutral",
|
||||
summary: `${connection.name} is selected on ${databaseLabel} but has not completed a live check in the current session.`,
|
||||
hint: "Run Test live before treating browse, inspect, or command results as current Redis evidence.",
|
||||
};
|
||||
}
|
||||
74
apps/desktop/src/lib/database-switcher.test.ts
Normal file
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
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
@@ -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
@@ -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
@@ -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}"`;
|
||||
}
|
||||
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
@@ -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
@@ -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
@@ -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)
|
||||
);
|
||||
}
|
||||
93
apps/desktop/src/lib/mutation-guardrails.test.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildMutationGuardDialogContent,
|
||||
canRemoveTtl,
|
||||
hasMeaningfulStringChange,
|
||||
hasMeaningfulTtlChange,
|
||||
} from "./mutation-guardrails";
|
||||
|
||||
describe("mutation guardrails", () => {
|
||||
it("summarizes live string replacement with current and next payload previews", () => {
|
||||
expect(
|
||||
buildMutationGuardDialogContent({
|
||||
kind: "value_write",
|
||||
runtimeMode: "live",
|
||||
keyName: "cache:homepage",
|
||||
databaseLabel: "db0",
|
||||
currentValue: "hello",
|
||||
nextValue: "hello world",
|
||||
}),
|
||||
).toEqual({
|
||||
title: "Replace string value for cache:homepage?",
|
||||
description: "Review the scoped string save for db0 before continuing.",
|
||||
currentLabel: "Current value",
|
||||
currentValue: '5 chars, 1 line, preview "hello"',
|
||||
nextLabel: "Next value",
|
||||
nextValue: '11 chars, 1 line, preview "hello world"',
|
||||
warning: "This replaces the full live string value and keeps the current TTL boundary.",
|
||||
confirmLabel: "Save live string",
|
||||
});
|
||||
});
|
||||
|
||||
it("summarizes TTL update in browser demo mode", () => {
|
||||
expect(
|
||||
buildMutationGuardDialogContent({
|
||||
kind: "ttl_update",
|
||||
runtimeMode: "demo",
|
||||
keyName: "session:42",
|
||||
databaseLabel: "db5",
|
||||
currentTtl: "persistent",
|
||||
nextTtl: "1m",
|
||||
}),
|
||||
).toEqual({
|
||||
title: "Apply TTL to session:42?",
|
||||
description: "Review the scoped TTL change for db5 before continuing.",
|
||||
currentLabel: "Current TTL",
|
||||
currentValue: "persistent",
|
||||
nextLabel: "Next TTL",
|
||||
nextValue: "1m",
|
||||
warning: "This only updates the session-local browser demo state.",
|
||||
confirmLabel: "Set demo TTL",
|
||||
});
|
||||
});
|
||||
|
||||
it("summarizes live TTL removal", () => {
|
||||
expect(
|
||||
buildMutationGuardDialogContent({
|
||||
kind: "ttl_remove",
|
||||
runtimeMode: "live",
|
||||
keyName: "smoke:string:ttl",
|
||||
databaseLabel: "db0",
|
||||
currentTtl: "48s",
|
||||
}),
|
||||
).toEqual({
|
||||
title: "Remove TTL from smoke:string:ttl?",
|
||||
description: "Review the scoped TTL removal for db0 before continuing.",
|
||||
currentLabel: "Current TTL",
|
||||
currentValue: "48s",
|
||||
nextLabel: "Next TTL",
|
||||
nextValue: "persistent",
|
||||
warning: "This removes the live expiry and makes the key persistent.",
|
||||
confirmLabel: "Remove live TTL",
|
||||
});
|
||||
});
|
||||
|
||||
it("detects no-op string saves", () => {
|
||||
expect(hasMeaningfulStringChange("hello", "hello")).toBe(false);
|
||||
expect(hasMeaningfulStringChange("hello", "hello world")).toBe(true);
|
||||
});
|
||||
|
||||
it("detects exact live TTL no-ops and formatted demo TTL no-ops", () => {
|
||||
expect(
|
||||
hasMeaningfulTtlChange({ kind: "expires_in_millis", value: 60_000 }, 60_000),
|
||||
).toBe(false);
|
||||
expect(hasMeaningfulTtlChange({ kind: "display", value: "1m" }, 60_000)).toBe(false);
|
||||
expect(hasMeaningfulTtlChange({ kind: "display", value: "1m" }, 120_000)).toBe(true);
|
||||
});
|
||||
|
||||
it("blocks TTL removal when the key is already persistent", () => {
|
||||
expect(canRemoveTtl({ kind: "persistent" })).toBe(false);
|
||||
expect(canRemoveTtl({ kind: "display", value: "persistent" })).toBe(false);
|
||||
expect(canRemoveTtl({ kind: "expires_in_millis", value: 10_000 })).toBe(true);
|
||||
});
|
||||
});
|
||||
178
apps/desktop/src/lib/mutation-guardrails.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
export type MutationGuardRuntimeMode = "live" | "demo";
|
||||
|
||||
export type ComparableTtlState =
|
||||
| { kind: "persistent" }
|
||||
| { kind: "missing" }
|
||||
| { kind: "expires_in_millis"; value: number }
|
||||
| { kind: "display"; value: string };
|
||||
|
||||
export type MutationGuardDialogInput =
|
||||
| {
|
||||
kind: "value_write";
|
||||
runtimeMode: MutationGuardRuntimeMode;
|
||||
keyName: string;
|
||||
databaseLabel: string;
|
||||
currentValue: string;
|
||||
nextValue: string;
|
||||
}
|
||||
| {
|
||||
kind: "ttl_update";
|
||||
runtimeMode: MutationGuardRuntimeMode;
|
||||
keyName: string;
|
||||
databaseLabel: string;
|
||||
currentTtl: string;
|
||||
nextTtl: string;
|
||||
}
|
||||
| {
|
||||
kind: "ttl_remove";
|
||||
runtimeMode: MutationGuardRuntimeMode;
|
||||
keyName: string;
|
||||
databaseLabel: string;
|
||||
currentTtl: string;
|
||||
};
|
||||
|
||||
export type MutationGuardDialogContent = {
|
||||
title: string;
|
||||
description: string;
|
||||
currentLabel: string;
|
||||
currentValue: string;
|
||||
nextLabel: string;
|
||||
nextValue: string;
|
||||
warning: string;
|
||||
confirmLabel: string;
|
||||
};
|
||||
|
||||
export function buildMutationGuardDialogContent(
|
||||
input: MutationGuardDialogInput,
|
||||
): MutationGuardDialogContent {
|
||||
switch (input.kind) {
|
||||
case "value_write":
|
||||
return {
|
||||
title: `Replace string value for ${input.keyName}?`,
|
||||
description: `Review the scoped string save for ${input.databaseLabel} before continuing.`,
|
||||
currentLabel: "Current value",
|
||||
currentValue: describeStringPayload(input.currentValue),
|
||||
nextLabel: "Next value",
|
||||
nextValue: describeStringPayload(input.nextValue),
|
||||
warning:
|
||||
input.runtimeMode === "live"
|
||||
? "This replaces the full live string value and keeps the current TTL boundary."
|
||||
: "This only updates the session-local browser demo state.",
|
||||
confirmLabel:
|
||||
input.runtimeMode === "live" ? "Save live string" : "Save demo string",
|
||||
};
|
||||
case "ttl_update":
|
||||
return {
|
||||
title: `Apply TTL to ${input.keyName}?`,
|
||||
description: `Review the scoped TTL change for ${input.databaseLabel} before continuing.`,
|
||||
currentLabel: "Current TTL",
|
||||
currentValue: input.currentTtl,
|
||||
nextLabel: "Next TTL",
|
||||
nextValue: input.nextTtl,
|
||||
warning:
|
||||
input.runtimeMode === "live"
|
||||
? "This changes the live expiry for the selected key in Redis."
|
||||
: "This only updates the session-local browser demo state.",
|
||||
confirmLabel: input.runtimeMode === "live" ? "Set live TTL" : "Set demo TTL",
|
||||
};
|
||||
case "ttl_remove":
|
||||
return {
|
||||
title: `Remove TTL from ${input.keyName}?`,
|
||||
description: `Review the scoped TTL removal for ${input.databaseLabel} before continuing.`,
|
||||
currentLabel: "Current TTL",
|
||||
currentValue: input.currentTtl,
|
||||
nextLabel: "Next TTL",
|
||||
nextValue: "persistent",
|
||||
warning:
|
||||
input.runtimeMode === "live"
|
||||
? "This removes the live expiry and makes the key persistent."
|
||||
: "This only updates the session-local browser demo state.",
|
||||
confirmLabel:
|
||||
input.runtimeMode === "live" ? "Remove live TTL" : "Remove demo TTL",
|
||||
};
|
||||
default:
|
||||
return {
|
||||
title: "Review mutation",
|
||||
description: "Review the scoped mutation before continuing.",
|
||||
currentLabel: "Current",
|
||||
currentValue: "n/a",
|
||||
nextLabel: "Next",
|
||||
nextValue: "n/a",
|
||||
warning: "",
|
||||
confirmLabel: "Confirm",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function hasMeaningfulStringChange(currentValue: string, nextValue: string) {
|
||||
return currentValue !== nextValue;
|
||||
}
|
||||
|
||||
export function hasMeaningfulTtlChange(
|
||||
currentTtl: ComparableTtlState,
|
||||
nextTtlMillis: number,
|
||||
) {
|
||||
switch (currentTtl.kind) {
|
||||
case "expires_in_millis":
|
||||
return currentTtl.value !== nextTtlMillis;
|
||||
case "display":
|
||||
return currentTtl.value !== formatDurationFromMillis(nextTtlMillis);
|
||||
case "persistent":
|
||||
case "missing":
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export function canRemoveTtl(currentTtl: ComparableTtlState) {
|
||||
switch (currentTtl.kind) {
|
||||
case "persistent":
|
||||
return false;
|
||||
case "display":
|
||||
return currentTtl.value !== "persistent";
|
||||
case "expires_in_millis":
|
||||
case "missing":
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function describeStringPayload(value: string) {
|
||||
const lines = value.split(/\r?\n/).length;
|
||||
const compactPreview = value.replace(/\s+/g, " ").trim();
|
||||
const preview = compactPreview ? truncateText(compactPreview, 56) : "(empty string)";
|
||||
|
||||
return `${value.length} chars, ${lines} line${lines === 1 ? "" : "s"}, preview ${JSON.stringify(preview)}`;
|
||||
}
|
||||
|
||||
function truncateText(value: string, maxLength: number) {
|
||||
if (value.length <= maxLength) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return `${value.slice(0, maxLength - 3)}...`;
|
||||
}
|
||||
|
||||
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`;
|
||||
}
|
||||
111
apps/desktop/src/lib/operator-notices.test.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createAccentNotice,
|
||||
createNeutralNotice,
|
||||
toBackendError,
|
||||
toBackendErrorNotice,
|
||||
} from "./operator-notices";
|
||||
|
||||
describe("operator notices", () => {
|
||||
it("creates explicit accent notices for successful actions", () => {
|
||||
expect(createAccentNotice("Saved")).toEqual({
|
||||
tone: "accent",
|
||||
message: "Saved",
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes unknown thrown values into internal backend errors", () => {
|
||||
expect(toBackendError(new Error("boom"))).toEqual({
|
||||
code: "internal",
|
||||
message: "boom",
|
||||
detail: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("maps authentication failures to a consistent operator-facing notice", () => {
|
||||
expect(
|
||||
toBackendErrorNotice(
|
||||
{
|
||||
code: "authentication_failed",
|
||||
message: "Authentication failed.",
|
||||
detail: "WRONGPASS invalid username-password pair",
|
||||
},
|
||||
{
|
||||
operation: "connection_test",
|
||||
connectionName: "redis-local-fixture",
|
||||
databaseLabel: "db0",
|
||||
},
|
||||
),
|
||||
).toEqual({
|
||||
tone: "danger",
|
||||
message:
|
||||
"Redis rejected the credentials used while testing redis-local-fixture on db0. Verify username, password, and ACL scope for the selected connection. Backend detail: Authentication failed. (WRONGPASS invalid username-password pair).",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps unsupported operations in a non-destructive neutral notice", () => {
|
||||
expect(
|
||||
toBackendErrorNotice(
|
||||
{
|
||||
code: "unsupported_operation",
|
||||
message: "Only string replacement is supported in V1.",
|
||||
detail: null,
|
||||
},
|
||||
{
|
||||
operation: "value_write",
|
||||
connectionName: "redis-local-fixture",
|
||||
databaseLabel: "db0",
|
||||
keyName: "session:42",
|
||||
},
|
||||
),
|
||||
).toEqual(
|
||||
createNeutralNotice(
|
||||
"The current desktop contract does not allow saving session:42 on redis-local-fixture / db0 yet. Stay on the visible read-only path or wait for a backend contract change before retrying. Backend detail: Only string replacement is supported in V1.",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("maps duplicate key creation into a stable neutral notice", () => {
|
||||
expect(
|
||||
toBackendErrorNotice(
|
||||
{
|
||||
code: "already_exists",
|
||||
message: "Redis key creation does not overwrite an existing key.",
|
||||
detail: "key `draft:key` already exists",
|
||||
},
|
||||
{
|
||||
operation: "key_create",
|
||||
connectionName: "redis-local-fixture",
|
||||
databaseLabel: "db0",
|
||||
keyName: "draft:key",
|
||||
},
|
||||
),
|
||||
).toEqual(
|
||||
createNeutralNotice(
|
||||
"Redis refused to proceed while creating draft:key on redis-local-fixture / db0 because the key already exists. Choose a new key name or inspect the existing key before retrying. Backend detail: Redis key creation does not overwrite an existing key. (key `draft:key` already exists).",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("describes command failures with command and scope context", () => {
|
||||
expect(
|
||||
toBackendErrorNotice(
|
||||
{
|
||||
code: "command_failed",
|
||||
message: "ERR unknown command 'FOOO'",
|
||||
detail: null,
|
||||
},
|
||||
{
|
||||
operation: "command_execution",
|
||||
connectionName: "redis-local-fixture",
|
||||
databaseLabel: "db0",
|
||||
commandName: "fooo",
|
||||
},
|
||||
),
|
||||
).toEqual({
|
||||
tone: "danger",
|
||||
message:
|
||||
"Redis returned an error while running FOOO on redis-local-fixture / db0. Adjust the Redis input first; retrying the same command unchanged is unlikely to help. Backend detail: ERR unknown command 'FOOO'.",
|
||||
});
|
||||
});
|
||||
});
|
||||
220
apps/desktop/src/lib/operator-notices.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
export type NoticeTone = "neutral" | "accent" | "danger";
|
||||
|
||||
export type OperatorNotice = {
|
||||
tone: NoticeTone;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type BackendErrorCode =
|
||||
| "invalid_connection_config"
|
||||
| "unsupported_tls_mode"
|
||||
| "unsupported_operation"
|
||||
| "already_exists"
|
||||
| "connection_failed"
|
||||
| "authentication_failed"
|
||||
| "command_failed"
|
||||
| "internal";
|
||||
|
||||
export type BackendError = {
|
||||
code: BackendErrorCode;
|
||||
message: string;
|
||||
detail: string | null;
|
||||
};
|
||||
|
||||
export type BackendErrorContext =
|
||||
| {
|
||||
operation: "key_create";
|
||||
connectionName: string;
|
||||
databaseLabel: string;
|
||||
keyName: string;
|
||||
}
|
||||
| {
|
||||
operation: "connection_test";
|
||||
connectionName: string;
|
||||
databaseLabel: string;
|
||||
}
|
||||
| {
|
||||
operation: "key_browse";
|
||||
connectionName: string;
|
||||
databaseLabel: string;
|
||||
}
|
||||
| {
|
||||
operation: "value_read" | "value_write" | "ttl_update" | "ttl_remove" | "inspector_refresh";
|
||||
connectionName: string;
|
||||
databaseLabel: string;
|
||||
keyName: string;
|
||||
}
|
||||
| {
|
||||
operation: "command_execution";
|
||||
connectionName: string;
|
||||
databaseLabel: string;
|
||||
commandName: string;
|
||||
};
|
||||
|
||||
export function createNeutralNotice(message: string): OperatorNotice {
|
||||
return {
|
||||
tone: "neutral",
|
||||
message,
|
||||
};
|
||||
}
|
||||
|
||||
export function createAccentNotice(message: string): OperatorNotice {
|
||||
return {
|
||||
tone: "accent",
|
||||
message,
|
||||
};
|
||||
}
|
||||
|
||||
export function createDangerNotice(message: string): OperatorNotice {
|
||||
return {
|
||||
tone: "danger",
|
||||
message,
|
||||
};
|
||||
}
|
||||
|
||||
export function toBackendError(error: unknown): BackendError {
|
||||
if (
|
||||
typeof error === "object" &&
|
||||
error !== null &&
|
||||
"message" in error &&
|
||||
typeof error.message === "string"
|
||||
) {
|
||||
return {
|
||||
code: normalizeBackendErrorCode("code" in error ? error.code : undefined),
|
||||
message: error.message,
|
||||
detail:
|
||||
"detail" in error && typeof error.detail === "string"
|
||||
? error.detail
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
code: "internal",
|
||||
message: error instanceof Error ? error.message : "Unknown backend error.",
|
||||
detail: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function toBackendErrorNotice(
|
||||
error: unknown,
|
||||
context: BackendErrorContext,
|
||||
): OperatorNotice {
|
||||
const backendError = toBackendError(error);
|
||||
const tone =
|
||||
backendError.code === "invalid_connection_config" ||
|
||||
backendError.code === "already_exists" ||
|
||||
backendError.code === "unsupported_operation" ||
|
||||
backendError.code === "unsupported_tls_mode"
|
||||
? "neutral"
|
||||
: "danger";
|
||||
|
||||
const headline = buildHeadline(backendError, context);
|
||||
const hint = buildHint(backendError.code, context);
|
||||
const detail = buildTechnicalDetail(backendError);
|
||||
|
||||
return {
|
||||
tone,
|
||||
message: [headline, hint, detail].filter(Boolean).join(" "),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeBackendErrorCode(value: unknown): BackendErrorCode {
|
||||
switch (value) {
|
||||
case "invalid_connection_config":
|
||||
case "unsupported_tls_mode":
|
||||
case "already_exists":
|
||||
case "unsupported_operation":
|
||||
case "connection_failed":
|
||||
case "authentication_failed":
|
||||
case "command_failed":
|
||||
case "internal":
|
||||
return value;
|
||||
default:
|
||||
return "internal";
|
||||
}
|
||||
}
|
||||
|
||||
function buildHeadline(error: BackendError, context: BackendErrorContext) {
|
||||
const target = describeContext(context);
|
||||
|
||||
switch (error.code) {
|
||||
case "invalid_connection_config":
|
||||
return `The request for ${target} was rejected before Redis was contacted.`;
|
||||
case "unsupported_tls_mode":
|
||||
return `The request for ${target} requires TLS handling that this foundation build does not support yet.`;
|
||||
case "already_exists":
|
||||
return `Redis refused to proceed while ${target} because the key already exists.`;
|
||||
case "unsupported_operation":
|
||||
return `The current desktop contract does not allow ${target} yet.`;
|
||||
case "connection_failed":
|
||||
return `Redis could not be reached while ${target}.`;
|
||||
case "authentication_failed":
|
||||
return `Redis rejected the credentials used while ${target}.`;
|
||||
case "command_failed":
|
||||
return `Redis returned an error while ${target}.`;
|
||||
case "internal":
|
||||
return `The desktop bridge failed while ${target}.`;
|
||||
default:
|
||||
return `The desktop bridge failed while ${target}.`;
|
||||
}
|
||||
}
|
||||
|
||||
function buildHint(code: BackendErrorCode, context: BackendErrorContext) {
|
||||
switch (code) {
|
||||
case "invalid_connection_config":
|
||||
return "Check host, port, database, search pattern, and key input before retrying.";
|
||||
case "unsupported_tls_mode":
|
||||
return "Keep TLS disabled in this foundation build unless product scope explicitly adds transport controls.";
|
||||
case "already_exists":
|
||||
return "Choose a new key name or inspect the existing key before retrying.";
|
||||
case "unsupported_operation":
|
||||
return "Stay on the visible read-only path or wait for a backend contract change before retrying.";
|
||||
case "connection_failed":
|
||||
return `Verify that ${context.connectionName} is reachable from this machine and that the selected database is available.`;
|
||||
case "authentication_failed":
|
||||
return "Verify username, password, and ACL scope for the selected connection.";
|
||||
case "command_failed":
|
||||
return "Adjust the Redis input first; retrying the same command unchanged is unlikely to help.";
|
||||
case "internal":
|
||||
return "Retry once, then capture the QA snapshot and escalate if the same failure repeats.";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function buildTechnicalDetail(error: BackendError) {
|
||||
const detail = error.detail && error.detail !== error.message ? error.detail : null;
|
||||
const summary = detail ? `${error.message} (${detail})` : error.message;
|
||||
|
||||
return `Backend detail: ${ensureTrailingPeriod(summary)}`;
|
||||
}
|
||||
|
||||
function describeContext(context: BackendErrorContext) {
|
||||
switch (context.operation) {
|
||||
case "connection_test":
|
||||
return `testing ${context.connectionName} on ${context.databaseLabel}`;
|
||||
case "key_create":
|
||||
return `creating ${context.keyName} on ${context.connectionName} / ${context.databaseLabel}`;
|
||||
case "key_browse":
|
||||
return `loading keys from ${context.connectionName} / ${context.databaseLabel}`;
|
||||
case "value_read":
|
||||
return `reading ${context.keyName} on ${context.connectionName} / ${context.databaseLabel}`;
|
||||
case "value_write":
|
||||
return `saving ${context.keyName} on ${context.connectionName} / ${context.databaseLabel}`;
|
||||
case "ttl_update":
|
||||
return `updating TTL for ${context.keyName} on ${context.connectionName} / ${context.databaseLabel}`;
|
||||
case "ttl_remove":
|
||||
return `removing TTL from ${context.keyName} on ${context.connectionName} / ${context.databaseLabel}`;
|
||||
case "inspector_refresh":
|
||||
return `refreshing ${context.keyName} on ${context.connectionName} / ${context.databaseLabel}`;
|
||||
case "command_execution":
|
||||
return `running ${context.commandName.toUpperCase()} on ${context.connectionName} / ${context.databaseLabel}`;
|
||||
default:
|
||||
return "handling a desktop Redis request";
|
||||
}
|
||||
}
|
||||
|
||||
function ensureTrailingPeriod(value: string) {
|
||||
return /[.!?]$/.test(value) ? value : `${value}.`;
|
||||
}
|
||||
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
@@ -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
@@ -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
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -117,6 +117,15 @@ pub struct RedisValueWriteRequest {
|
||||
pub value: RedisValueWriteInput,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct RedisValueCreateRequest {
|
||||
pub connection: RedisConnectionRequest,
|
||||
pub key: String,
|
||||
pub value: RedisValueCreateInput,
|
||||
#[serde(default)]
|
||||
pub ttl_millis: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct RedisKeyTtlUpdateRequest {
|
||||
pub connection: RedisConnectionRequest,
|
||||
@@ -154,6 +163,11 @@ pub struct RedisKeyTtlUpdateResult {
|
||||
pub metadata: RedisKeyMetadata,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct RedisValueCreateResult {
|
||||
pub record: RedisValueRecord,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct RedisFieldValueEntry {
|
||||
pub field: RedisValueContent,
|
||||
@@ -197,6 +211,12 @@ pub enum RedisValueWriteInput {
|
||||
String { value: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum RedisValueCreateInput {
|
||||
String { value: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RedisValueWriteCapability {
|
||||
|
||||
@@ -5,6 +5,7 @@ use thiserror::Error;
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum BackendErrorCode {
|
||||
InvalidConnectionConfig,
|
||||
AlreadyExists,
|
||||
UnsupportedTlsMode,
|
||||
UnsupportedOperation,
|
||||
ConnectionFailed,
|
||||
@@ -30,6 +31,10 @@ impl BackendError {
|
||||
Self::new(BackendErrorCode::UnsupportedTlsMode, message, None)
|
||||
}
|
||||
|
||||
pub fn already_exists(message: impl Into<String>, detail: Option<String>) -> Self {
|
||||
Self::new(BackendErrorCode::AlreadyExists, message, detail)
|
||||
}
|
||||
|
||||
pub fn connection_failed(message: impl Into<String>, detail: Option<String>) -> Self {
|
||||
Self::new(BackendErrorCode::ConnectionFailed, message, detail)
|
||||
}
|
||||
|
||||
@@ -9,14 +9,15 @@ pub use connection::{
|
||||
RedisCommandRequest, RedisConnectionRequest, RedisFieldValueEntry, RedisKeyBrowseRequest,
|
||||
RedisKeyBrowseResult, RedisKeyMetadata, RedisKeyMetadataRequest, RedisKeyTtl,
|
||||
RedisKeyTtlUpdate, RedisKeyTtlUpdateRequest, RedisKeyTtlUpdateResult, RedisResponse,
|
||||
RedisSortedSetEntry, RedisStreamEntry, RedisValueContent, RedisValueData,
|
||||
RedisValueReadRequest, RedisValueRecord, RedisValueWriteCapability, RedisValueWriteInput,
|
||||
RedisValueWriteRequest, TlsMode,
|
||||
RedisSortedSetEntry, RedisStreamEntry, RedisValueContent, RedisValueCreateInput,
|
||||
RedisValueCreateRequest, RedisValueCreateResult, RedisValueData, RedisValueReadRequest,
|
||||
RedisValueRecord, RedisValueWriteCapability, RedisValueWriteInput, RedisValueWriteRequest,
|
||||
TlsMode,
|
||||
};
|
||||
pub use error::{BackendError, BackendErrorCode};
|
||||
pub use service::{
|
||||
browse_keys, execute_command, inspect_key, read_value, test_connection, update_key_ttl,
|
||||
write_value,
|
||||
browse_keys, create_value, execute_command, inspect_key, read_value, test_connection,
|
||||
update_key_ttl, write_value,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -9,7 +9,8 @@ use crate::{
|
||||
RedisConnectionRequest, RedisFieldValueEntry, RedisKeyBrowseRequest, RedisKeyBrowseResult,
|
||||
RedisKeyMetadata, RedisKeyMetadataRequest, RedisKeyTtl, RedisKeyTtlUpdate,
|
||||
RedisKeyTtlUpdateRequest, RedisKeyTtlUpdateResult, RedisResponse, RedisSortedSetEntry,
|
||||
RedisStreamEntry, RedisValueContent, RedisValueData, RedisValueReadRequest, RedisValueRecord,
|
||||
RedisStreamEntry, RedisValueContent, RedisValueCreateInput, RedisValueCreateRequest,
|
||||
RedisValueCreateResult, RedisValueData, RedisValueReadRequest, RedisValueRecord,
|
||||
RedisValueWriteCapability, RedisValueWriteInput, RedisValueWriteRequest, TlsMode,
|
||||
};
|
||||
|
||||
@@ -141,6 +142,26 @@ pub fn write_value(request: RedisValueWriteRequest) -> Result<RedisValueRecord,
|
||||
read_value_record(&mut connection, database, &request.key)
|
||||
}
|
||||
|
||||
pub fn create_value(
|
||||
request: RedisValueCreateRequest,
|
||||
) -> Result<RedisValueCreateResult, BackendError> {
|
||||
validate_key_name(&request.key)?;
|
||||
validate_optional_ttl_millis(request.ttl_millis)?;
|
||||
|
||||
let database = request.connection.target.database;
|
||||
let mut connection = open_connection(&request.connection)?;
|
||||
|
||||
match &request.value {
|
||||
RedisValueCreateInput::String { value } => {
|
||||
create_string_value(&mut connection, &request.key, value, request.ttl_millis)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(RedisValueCreateResult {
|
||||
record: read_value_record(&mut connection, database, &request.key)?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn update_key_ttl(
|
||||
request: RedisKeyTtlUpdateRequest,
|
||||
) -> Result<RedisKeyTtlUpdateResult, BackendError> {
|
||||
@@ -490,6 +511,28 @@ fn write_string_value(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn create_string_value(
|
||||
connection: &mut RedisConnection,
|
||||
key_name: &str,
|
||||
value: &str,
|
||||
ttl_millis: Option<u64>,
|
||||
) -> Result<(), BackendError> {
|
||||
let mut command = vec![
|
||||
"SET".to_string(),
|
||||
key_name.to_string(),
|
||||
value.to_string(),
|
||||
"NX".to_string(),
|
||||
];
|
||||
|
||||
if let Some(ttl_millis) = ttl_millis {
|
||||
command.push("PX".to_string());
|
||||
command.push(ttl_millis.to_string());
|
||||
}
|
||||
|
||||
let response = connection.execute_owned(command)?;
|
||||
expect_set_create_applied(response, key_name)
|
||||
}
|
||||
|
||||
fn parse_resp_string(value: RespValue) -> Result<String, BackendError> {
|
||||
match value {
|
||||
RespValue::SimpleString(value) => Ok(value),
|
||||
@@ -708,6 +751,16 @@ fn validate_key_name(key: &str) -> Result<(), BackendError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_optional_ttl_millis(ttl_millis: Option<u64>) -> Result<(), BackendError> {
|
||||
if matches!(ttl_millis, Some(0)) {
|
||||
return Err(BackendError::invalid_connection_config(
|
||||
"Redis key creation TTL must be greater than zero when provided.",
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn expect_ok(value: RespValue) -> Result<(), RespValue> {
|
||||
match value {
|
||||
RespValue::SimpleString(value) if value == "OK" => Ok(()),
|
||||
@@ -751,6 +804,24 @@ fn expect_set_write_applied(value: RespValue) -> Result<(), BackendError> {
|
||||
}
|
||||
}
|
||||
|
||||
fn expect_set_create_applied(value: RespValue, key_name: &str) -> Result<(), BackendError> {
|
||||
match value {
|
||||
RespValue::SimpleString(value) if value == "OK" => Ok(()),
|
||||
RespValue::BulkString(None) => Err(BackendError::already_exists(
|
||||
"Redis key creation does not overwrite an existing key.",
|
||||
Some(format!("key `{key_name}` already exists")),
|
||||
)),
|
||||
RespValue::Error(message) => Err(BackendError::command_failed(
|
||||
"Redis string key creation failed.",
|
||||
Some(message),
|
||||
)),
|
||||
other => Err(BackendError::command_failed(
|
||||
"Redis string key creation returned an unexpected response.",
|
||||
Some(format!("{other:?}")),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn map_auth_error(error: RespValue) -> BackendError {
|
||||
match error {
|
||||
RespValue::Error(message) => BackendError::authentication_failed(
|
||||
@@ -1530,6 +1601,137 @@ mod tests {
|
||||
assert_eq!(error.code, BackendErrorCode::UnsupportedOperation);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creates_string_value_without_ttl() {
|
||||
let server = MockRedisServer::spawn(vec![
|
||||
ServerStep::expect(
|
||||
["SET", "draft:key", "hello world", "NX"],
|
||||
Response::simple("OK"),
|
||||
),
|
||||
ServerStep::expect(["TYPE", "draft:key"], Response::simple("string")),
|
||||
ServerStep::expect(["PTTL", "draft:key"], Response::integer(-1)),
|
||||
ServerStep::expect(["GET", "draft:key"], Response::bulk("hello world")),
|
||||
]);
|
||||
|
||||
let result = create_value(RedisValueCreateRequest {
|
||||
connection: RedisConnectionRequest {
|
||||
target: ConnectionTarget {
|
||||
host: "127.0.0.1".to_string(),
|
||||
port: server.port(),
|
||||
database: 0,
|
||||
username: None,
|
||||
tls_mode: TlsMode::Disabled,
|
||||
},
|
||||
password: None,
|
||||
},
|
||||
key: "draft:key".to_string(),
|
||||
value: RedisValueCreateInput::String {
|
||||
value: "hello world".to_string(),
|
||||
},
|
||||
ttl_millis: None,
|
||||
})
|
||||
.expect("key create should succeed");
|
||||
|
||||
assert_eq!(result.record.metadata.name, "draft:key");
|
||||
assert_eq!(result.record.metadata.ttl, RedisKeyTtl::Persistent);
|
||||
assert_eq!(
|
||||
result.record.write_capability,
|
||||
RedisValueWriteCapability::ReplaceString
|
||||
);
|
||||
assert_eq!(
|
||||
result.record.data,
|
||||
RedisValueData::String {
|
||||
value: RedisValueContent::String {
|
||||
value: "hello world".to_string(),
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creates_string_value_with_ttl() {
|
||||
let server = MockRedisServer::spawn(vec![
|
||||
ServerStep::expect(
|
||||
["SET", "draft:ttl", "expires soon", "NX", "PX", "60000"],
|
||||
Response::simple("OK"),
|
||||
),
|
||||
ServerStep::expect(["TYPE", "draft:ttl"], Response::simple("string")),
|
||||
ServerStep::expect(["PTTL", "draft:ttl"], Response::integer(59_000)),
|
||||
ServerStep::expect(["GET", "draft:ttl"], Response::bulk("expires soon")),
|
||||
]);
|
||||
|
||||
let result = create_value(RedisValueCreateRequest {
|
||||
connection: RedisConnectionRequest {
|
||||
target: ConnectionTarget {
|
||||
host: "127.0.0.1".to_string(),
|
||||
port: server.port(),
|
||||
database: 0,
|
||||
username: None,
|
||||
tls_mode: TlsMode::Disabled,
|
||||
},
|
||||
password: None,
|
||||
},
|
||||
key: "draft:ttl".to_string(),
|
||||
value: RedisValueCreateInput::String {
|
||||
value: "expires soon".to_string(),
|
||||
},
|
||||
ttl_millis: Some(60_000),
|
||||
})
|
||||
.expect("ttl create should succeed");
|
||||
|
||||
assert_eq!(
|
||||
result.record.metadata.ttl,
|
||||
RedisKeyTtl::ExpiresInMillis { value: 59_000 }
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_duplicate_key_creation_with_stable_conflict_error() {
|
||||
let server = MockRedisServer::spawn(vec![ServerStep::expect(
|
||||
["SET", "draft:key", "hello world", "NX"],
|
||||
Response::null_bulk(),
|
||||
)]);
|
||||
|
||||
let error = create_value(RedisValueCreateRequest {
|
||||
connection: RedisConnectionRequest {
|
||||
target: ConnectionTarget {
|
||||
host: "127.0.0.1".to_string(),
|
||||
port: server.port(),
|
||||
database: 0,
|
||||
username: None,
|
||||
tls_mode: TlsMode::Disabled,
|
||||
},
|
||||
password: None,
|
||||
},
|
||||
key: "draft:key".to_string(),
|
||||
value: RedisValueCreateInput::String {
|
||||
value: "hello world".to_string(),
|
||||
},
|
||||
ttl_millis: None,
|
||||
})
|
||||
.expect_err("duplicate create should fail");
|
||||
|
||||
assert_eq!(error.code, BackendErrorCode::AlreadyExists);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_ttl_for_created_key_before_network_access() {
|
||||
let error = create_value(RedisValueCreateRequest {
|
||||
connection: RedisConnectionRequest {
|
||||
target: ConnectionTarget::default(),
|
||||
password: None,
|
||||
},
|
||||
key: "draft:key".to_string(),
|
||||
value: RedisValueCreateInput::String {
|
||||
value: "hello world".to_string(),
|
||||
},
|
||||
ttl_millis: Some(0),
|
||||
})
|
||||
.expect_err("zero ttl should fail");
|
||||
|
||||
assert_eq!(error.code, BackendErrorCode::InvalidConnectionConfig);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn updates_ttl_for_existing_keys() {
|
||||
let expiring_server = MockRedisServer::spawn(vec![
|
||||
@@ -1656,6 +1858,7 @@ mod tests {
|
||||
Error(String),
|
||||
Integer(i64),
|
||||
Bulk(Vec<u8>),
|
||||
NullBulk,
|
||||
Array(Vec<Response>),
|
||||
}
|
||||
|
||||
@@ -1676,6 +1879,10 @@ mod tests {
|
||||
Self::Bulk(value.as_bytes().to_vec())
|
||||
}
|
||||
|
||||
fn null_bulk() -> Self {
|
||||
Self::NullBulk
|
||||
}
|
||||
|
||||
fn array<const N: usize>(items: [Response; N]) -> Self {
|
||||
Self::Array(items.into())
|
||||
}
|
||||
@@ -1691,6 +1898,7 @@ mod tests {
|
||||
encoded.extend(b"\r\n");
|
||||
encoded
|
||||
}
|
||||
Self::NullBulk => b"$-1\r\n".to_vec(),
|
||||
Self::Array(items) => {
|
||||
let mut encoded = format!("*{}\r\n", items.len()).into_bytes();
|
||||
for item in items {
|
||||
|
||||
@@ -16,15 +16,17 @@
|
||||
- `RedisKeyBrowseRequest`: key 浏览请求,显式携带 `SCAN` cursor、pattern 与 page size。
|
||||
- `RedisKeyMetadataRequest`: 单个 key 的元数据读取请求,用于 inspector 刷新与删除前确认。
|
||||
- `RedisValueReadRequest`: 单个 key 的 typed value 读取请求。
|
||||
- `RedisValueCreateRequest`: 单个 key 的显式创建请求;当前仅允许新建 string key,并可选携带创建时 TTL。
|
||||
- `RedisValueWriteRequest`: 单个 key 的 value 写入请求;当前仅允许 string 整值替换。
|
||||
- `RedisKeyTtlUpdateRequest`: 单个 key 的 TTL 修改请求,支持设置过期时间和移除 TTL。
|
||||
- `RedisCommandRequest`: 基础命令执行请求,显式拆分命令名与参数。
|
||||
- `RedisKeyMetadata`: key 浏览与 inspector 共享的稳定元数据结构,覆盖存在性、类型和 TTL。
|
||||
- `RedisValueRecord`: inspector 使用的稳定结构,组合 key 元数据、typed value 数据、value 写入能力和 TTL 修改能力。
|
||||
- `RedisValueCreateResult`: 创建成功后的稳定返回体,复用 `RedisValueRecord` 让调用方立即获得可浏览/可检查的最新状态。
|
||||
- `RedisValueData`: typed value 返回体,覆盖 `string/hash/list/set/zset/stream` 与缺失 key。
|
||||
- `RedisValueWriteCapability`: 当前明确区分 `replace_string` 与 `none`,让入口层知道哪些类型仍是只读。
|
||||
- `CommandExecutionResult`: 以稳定的 RESP2 派生结构返回基础命令结果。
|
||||
- `BackendError`: 结构化错误码,区分连接配置、认证、连接失败和命令失败。
|
||||
- `BackendError`: 结构化错误码,区分连接配置、重复创建、认证、连接失败和命令失败。
|
||||
- `BackendBootstrap`: 向入口层暴露当前支持的能力边界和安全约束。
|
||||
|
||||
这些对象构成当前阶段的稳定后端契约:
|
||||
@@ -36,6 +38,7 @@
|
||||
- key 浏览按 `SCAN` 分页返回,并为每个命中的 key 补充 `TYPE` 与 `PTTL` 元数据。
|
||||
- inspector 可对单个 key 单独刷新元数据,避免 UI 为了刷新单项而重跑整页浏览。
|
||||
- typed value surface 当前覆盖 `string/hash/list/set/zset/stream` 六类 Redis 数据,并显式保留类型差异。
|
||||
- V1 Add Key 通过独立 create contract 建模,只允许显式新建 string key,并通过 `SET ... NX` 拒绝任何隐式覆盖。
|
||||
- V1 value 编辑只允许已有 string key 的整值替换;非 string key 保持只读,避免错误覆盖集合类型。
|
||||
- string 写入会保留原 TTL,TTL 修改独立通过 `PERSIST` / `PEXPIRE` 语义建模。
|
||||
|
||||
@@ -63,6 +66,7 @@
|
||||
- `TlsMode::disabled` 可用,`preferred` 和 `required` 目前明确返回 `unsupported_tls_mode`,避免伪支持。
|
||||
- 命令执行结果目前覆盖 RESP2 的 `simple string`、`bulk string`、`integer`、`array` 和 `null`。
|
||||
- typed value 读取当前是 key 级 eager 读取:`HGETALL`、`LRANGE 0 -1`、`SMEMBERS`、`ZRANGE ... WITHSCORES`、`XRANGE - +`。这保证了契约直接,但尚未引入大 key 分页、截断或流式读取策略。
|
||||
- string key 创建使用单条 `SET key value NX [PX ttl]`,确保重复 key 走稳定冲突路径,而不是把“创建”降级成覆盖写入。
|
||||
- string 写入使用事务化的 `SET ... XX` 与 TTL 恢复,避免把非 string key 误写成 string,也避免默认清空现有 TTL。
|
||||
- key 元数据仍未覆盖编码、内存占用、slot、cluster 拓扑或 ACL 细粒度探测。
|
||||
- 这层契约已足以支撑连接管理、基础命令台、key 浏览、value inspector 与 TTL 编辑,但还不足以覆盖 cluster、pub/sub、流式结果或长连接会话管理。
|
||||
@@ -70,7 +74,7 @@
|
||||
## Entry Point Contract
|
||||
|
||||
- Tauri 命令只暴露共享核心层的数据,不直接返回临时字符串。
|
||||
- 当前桌面入口暴露八个命令:`backend_bootstrap`、`test_redis_connection`、`browse_redis_keys`、`inspect_redis_key`、`read_redis_value`、`write_redis_value`、`update_redis_key_ttl`、`execute_redis_command`。
|
||||
- 当前桌面入口暴露九个命令:`backend_bootstrap`、`test_redis_connection`、`browse_redis_keys`、`inspect_redis_key`、`read_redis_value`、`create_redis_value`、`write_redis_value`、`update_redis_key_ttl`、`execute_redis_command`。
|
||||
- 任何新增入口都应优先复用 `redis-core`,保证 CLI、GUI 行为模型一致。
|
||||
- 桌面入口允许演进 UI,但不应绕过领域约束自行定义 Redis 协议行为。
|
||||
|
||||
|
||||
@@ -45,8 +45,10 @@ Use a four-zone workspace:
|
||||
|
||||
- Connection library
|
||||
- New connection form entry point
|
||||
- Active connection session summary with clear ready/testing/demo/retry states
|
||||
- Active DB switcher
|
||||
- Safety posture and backend boundary summary
|
||||
- Local smoke fixture assist for repeatable QA runs
|
||||
|
||||
### Browser pane
|
||||
|
||||
@@ -84,25 +86,50 @@ Use a four-zone workspace:
|
||||
- `apps/desktop` now runs on React and Tailwind-based styling.
|
||||
- The shell now consumes the existing Tauri bridge for `backend_bootstrap`, `test_redis_connection`, `browse_redis_keys`, `read_redis_value`, `write_redis_value`, and `execute_redis_command`.
|
||||
- The inspector now consumes `update_redis_key_ttl` for live TTL set and persist actions in desktop runtime.
|
||||
- The repository now also contains a visible string-only `Add key` dialog wired to `create_redis_value`; that entry is explicitly labeled `Preview`, and the capability remains a separately tracked next-phase surface rather than part of this baseline milestone's required acceptance.
|
||||
- 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.
|
||||
- Tauri desktop mode now allows live connection tests, live key browsing, live value inspection, string save for supported keys, live TTL updates, and live command execution while keeping browser dev mode on explicit demo fallback.
|
||||
- Non-string live values now render in structured read-only panels for `hash`, `list`, `set`, `zset`, and `stream`, so operators can scan typed payloads without reading raw JSON dumps.
|
||||
- The visible shell now validates draft-connection name, host, port, and DB input before creation, and prevents duplicate profile names inside the local shell state.
|
||||
- The active connection area now also exposes a dedicated session summary so operators can see whether the current scope is ready, testing, demo-only, or waiting for retry without inferring that state from scattered card copy.
|
||||
- Live connection test completion now only reapplies the returned DB scope when the same connection is still active, preventing a stale test response from silently replacing the operator's newer active workspace selection.
|
||||
- The DB switcher now exposes a predictable default operator range from `db0` through `db15` and keeps an out-of-range active DB visible instead of depending on a four-item hardcoded list.
|
||||
- Zero-result key searches now place the inspector into an explicit empty state instead of leaving a stale key visible.
|
||||
- Frontend error handling now routes stable backend error codes through a shared operator-facing notice layer so live failures render with consistent copy and banner tone across connection, browse, inspect, write, TTL, and command flows.
|
||||
- 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.
|
||||
- Current-phase writable actions now require a scoped review step before string save, TTL set, or TTL removal proceeds, and no-op submissions are blocked before they reach the live bridge or demo fallback.
|
||||
- The utility rail now exposes a bounded local smoke assist surface with fixture targeting, `smoke:*` focus, safe command presets, and the seeded key list from the local smoke runbook.
|
||||
- Browser-only demo mode now keeps session-local mock string-save and TTL changes visible in the browser, inspector, and mock command responses so shell review does not claim success while leaving stale UI state behind.
|
||||
|
||||
## QA Acceptance For This Milestone
|
||||
|
||||
- The shell renders four distinct zones: rail, browser, inspector, command tray.
|
||||
- The header keeps app version, runtime mode, and build mode visible for smoke validation.
|
||||
- The utility rail exposes a copyable QA snapshot with runtime, connection, selection, and latest command context.
|
||||
- The utility rail exposes the seeded local smoke fixture path and safe smoke shortcuts without requiring manual re-entry from the runbook.
|
||||
- The active connection and active DB stay visible without opening a modal.
|
||||
- The active connection area shows a dedicated session summary with explicit ready, testing, demo fallback, or retry-needed wording plus a visible next-step hint.
|
||||
- The DB switcher exposes `db0` through `db15` by default and still keeps the current DB visible when the active scope falls outside that range.
|
||||
- Search filters the browser list and shows a no-results state.
|
||||
- Search now exposes explicit mode feedback so operators can see whether the current input is being treated as a contains search or a raw Redis pattern.
|
||||
- Search now exposes a bounded clear action in the browser header so operators can return to normal browse state without manually deleting the entire input.
|
||||
- When search returns zero keys, the inspector switches to an empty state instead of showing the last selected key.
|
||||
- Empty browser and inspector states now distinguish between an empty DB and a filtered no-match result instead of using one generic no-results sentence.
|
||||
- The connection action distinguishes desktop live testing from browser demo fallback.
|
||||
- If a live connection test finishes after the operator switches to a different connection, the tested connection card updates but the newly active DB scope remains unchanged.
|
||||
- Invalid draft connections cannot be created from the visible shell.
|
||||
- Live backend failures are shown through a consistent banner treatment instead of per-surface ad hoc copy.
|
||||
- Non-string keys are explicitly read-only.
|
||||
- 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.
|
||||
- String save and TTL mutations now require a second review step that repeats scoped current-vs-next state before continuing.
|
||||
- Failed save and TTL attempts keep the current draft value or TTL input in place so operators can adjust and retry without rebuilding context.
|
||||
- Browser demo mode keeps mock string-save and TTL changes visible for the current session while still labeling the runtime as non-live.
|
||||
- The visible `Add key` entry and dialog keep a `Preview` label so operators do not mistake that next-phase flow for approved current-baseline scope.
|
||||
- Current baseline sign-off does not require the visible `Add key` surface to pass or fail; keep any Add Key evidence separate unless PM explicitly promotes it into the active milestone.
|
||||
- Delete requires a second confirmation step that includes key name and DB context.
|
||||
- The command tray visibly blocks dangerous administrative commands.
|
||||
- Command history marks whether each result came from the live desktop bridge or the demo shell.
|
||||
@@ -113,11 +140,13 @@ Use:
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm --filter @redis-gui/desktop test
|
||||
pnpm run desktop:dev
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- `pnpm run desktop:dev` is the browser shell path. It supports layout review and demo fallback only.
|
||||
- `pnpm run desktop:dev` is the browser shell path. It supports layout review and session-local demo fallback only; mock save and TTL actions update visible UI state, but no Tauri or Redis mutation occurs.
|
||||
- `pnpm run desktop:tauri:dev` is the desktop integration path. Use it when QA or backend needs to validate the existing command bridge against a live Redis target.
|
||||
- `pnpm --filter @redis-gui/desktop test` should stay green before handing frontend foundation changes to QA or other feature teams.
|
||||
- Use `pnpm run desktop:build` for a production bundle check before handing the shell to QA or integration work.
|
||||
|
||||
@@ -1,38 +1,51 @@
|
||||
# Redis GUI V1 Acceptance Matrix
|
||||
|
||||
Date: 2026-03-27
|
||||
Date: 2026-03-31
|
||||
Owner: QA Engineer
|
||||
|
||||
## Evidence Collected On 2026-03-27
|
||||
## Evidence Collected On 2026-03-31
|
||||
|
||||
- A fresh `cargo test -p redis-core` rerun passes in this session with 16 tests green.
|
||||
- `pnpm install --lockfile-only` passes at repository root after adding `pnpm-workspace.yaml`.
|
||||
- `cargo test -p redis-core` passes in this session with 20 tests green.
|
||||
- `pnpm --filter @redis-gui/desktop test` passes in this session with 12 files and 48 tests green.
|
||||
- `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)`.
|
||||
- `pnpm run desktop:dev` starts Vite on `http://127.0.0.1:1420/`, and the served HTML points to the desktop shell entrypoint.
|
||||
- `pnpm run desktop:tauri:build` now completes Linux `.deb` and `.rpm` artifact generation under `target/release/bundle/`.
|
||||
- `pnpm run desktop:tauri:build:all` still fails later in the AppImage stage because `linuxdeploy` aborts in the current environment.
|
||||
- `dpkg-deb -I target/release/bundle/deb/Redis GUI Foundation_0.1.0_amd64.deb` confirms Debian package metadata and runtime dependencies on `libwebkit2gtk-4.1-0` and `libgtk-3-0`.
|
||||
- Direct launch of `target/release/desktop-shell` in this heartbeat fails before window creation with `Failed to initialize GTK`, because the current session has no `DISPLAY`, `WAYLAND_DISPLAY`, or `xvfb-run`.
|
||||
- `./scripts/redis-fixture/start.sh`, `seed.sh`, and `stop.sh` pass in this session, and the seeded fixture exposes 7 `smoke:*` keys with expected string, TTL, hash, list, set, zset, and stream evidence.
|
||||
- `./scripts/redis-fixture/seed.sh` also fails correctly when the fixture container is absent, with an explicit instruction to run `start.sh` first.
|
||||
- The repository still contains no `Dockerfile`, `docker-compose.yml`, `compose.yaml`, or equivalent compose asset; Docker-backed verification currently depends on the documented fixture scripts plus a reachable local Docker daemon.
|
||||
- The repository now contains a Rust workspace, a React workspace UI baseline, Tauri commands for connection testing and command execution, and shared frontend scope documentation.
|
||||
- Browser dev mode still uses demo data for shell review, while Tauri desktop mode can now exercise live connection-test, key browse, value read/write, and command-execution bridges.
|
||||
- The checked-out desktop shell now also exposes a visible string-only `Add key` flow through `create_redis_value`; the UI labels it as `Preview`, and PM phase notes still treat that capability as next-phase inventory rather than a required current-baseline acceptance item.
|
||||
- Browser dev mode now also keeps mock string-save and TTL changes visible for the current session, reducing shell-review drift between action banners and visible UI state without changing the live acceptance boundary.
|
||||
- The active connection area now shows an explicit session summary for ready, testing, demo fallback, and retry-needed states, and live test completion no longer rewrites the current active DB if the operator already switched to another connection while the probe was running.
|
||||
- The desktop DB switcher now exposes a default `db0` through `db15` range and keeps an active out-of-range DB visible in the selector.
|
||||
- A repeatable local Redis fixture and smoke runbook now exist in `scripts/redis-fixture/` and `docs/qa/local-desktop-smoke.md`.
|
||||
- `docs/frontend-workspace-baseline.md` and `docs/redis-gui-v1-product-requirements.md` now both exist in-repo.
|
||||
|
||||
## Matrix
|
||||
|
||||
| Scope | Happy Path Acceptance | Failure Path Acceptance | Required Evidence | Current Status |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 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 |
|
||||
| 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, package artifacts, and smoke path now exist, but this heartbeat had no GUI-capable display session, so real Tauri window launch-and-connect evidence 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 |
|
||||
| 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 clear messaging | Tauri run log, screenshots, repeatable steps, Redis target | Partially blocked: the shell now validates draft name/host/port/DB locally and calls the real connection-test command, and a seeded Redis smoke path exists, 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 | 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 | 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 |
|
||||
| TTL operations | View TTL, set TTL, remove TTL, and verify expiration behavior | Invalid TTL, already expired key, and permission error handled safely | 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 | 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 |
|
||||
| Desktop workspace shell | Rail, browser, inspector, and command tray render together with active connection, build identity, runtime mode, a copyable QA snapshot, local smoke assist, visible inspector summary/key facts surfaces, and scoped mutation review for current-phase write actions | Empty search state clears the inspector selection, search mode feedback and clear action stay visible and predictable, read-only non-string value state, inspector status/action guidance stays aligned to live vs demo vs missing cases, destructive confirmation, no-op write attempts are blocked before execution, and browser-mode demo fallback stays explicit while mock save and TTL actions update session-local UI state | `npm run desktop:dev` or `npm run desktop:build`, `docs/frontend-workspace-baseline.md`, screenshots or copied QA snapshot text | Pass for shell-level evidence; not a substitute for Redis feature acceptance |
|
||||
| Connection management | Create a validated draft profile, keep DB context visible, switch among a predictable default DB range, run the existing live connection-test bridge from Tauri desktop mode, and keep the active session state visibly scoped as ready, testing, demo fallback, or retry needed | Invalid host, bad port, bad password, duplicate name, timeout, and unreachable server are handled with consistent operator-facing messaging, and a late-arriving test result must not silently replace a newer active connection/DB selection | Tauri run log, screenshots, repeatable steps, Redis target | Partially blocked: the shell now validates draft name/host/port/DB locally, exposes `db0` through `db15` while preserving active out-of-range DB context, maps backend error codes into shared notices, keeps session-state guidance visible, and calls the real connection-test command without reapplying stale DB scope after an operator switch, but profile persistence and full evidence capture are not complete |
|
||||
| Key browsing | Browse DBs and keys, paginate or virtualize large lists, view metadata | Empty DB, deleted key during browse, permission error, and connection drop do not crash UI, and live failures reuse the shared error banner treatment | Seed dataset, result screenshots, observed limits | Partially blocked: desktop UI now calls the live browse bridge with `SCAN` pagination and metadata, but fixture-backed Tauri screenshots and measured limits are still pending |
|
||||
| Search | Search by key name or pattern and return matching set within documented limits; visible shell makes it clear whether the current input is treated as a contains search or a raw Redis pattern | No matches, invalid pattern, empty DB state, and very large result sets are handled predictably, and operators can clear search back to browse state without ambiguity | Seed dataset, search queries, measured behavior | Partially blocked: desktop UI now maps search to live browse requests and exposes clearer search-state feedback, but fixture-backed evidence and measured limits are still pending |
|
||||
| Value editing | Open supported value types, review pending string replacement, confirm save, and refresh persisted value; inspector keeps current state and write semantics explicit while editing | Concurrent modification, invalid payload, missing-key transitions, and permission error preserve user context, while no-op edits are blocked before execution and live failures reuse the shared operator notice layer | Before/after values, screenshots, repeatable steps | Partially blocked: string read/save/refresh is wired through the live desktop bridge, non-string types now render in structured read-only inspector panels, inspector guidance is clearer across live and fallback states, scoped review now clarifies current-vs-next value before save, and browser demo mode keeps session-local save state coherent, but fixture-backed live evidence and broader editing scope remain pending |
|
||||
| TTL operations | View TTL, review pending TTL change or removal, confirm the mutation, and verify the resulting expiration behavior | Invalid TTL, already persistent/no-op TTL requests, already expired key, and permission error are handled safely with explicit guardrails and consistent banner feedback | Seed dataset, timestamps, verification steps | Partially blocked: desktop UI now exposes live TTL set and remove controls through the existing contract, blocks no-op TTL submissions, and keeps browser demo TTL state coherent, but seeded-fixture live 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 |
|
||||
| Installer and packaging | Package metadata, icon, version, and uninstall behavior are correct on each target OS | Corrupt install, upgrade, downgrade, and missing dependency behavior are documented or handled | Artifact checklist, install logs, uninstall evidence | Partially blocked: Linux `.deb` and `.rpm` artifacts now exist, but AppImage failed and install or uninstall evidence is still absent |
|
||||
| Installer and packaging | Package metadata, icon, version, and uninstall behavior are correct on each target OS | Corrupt install, upgrade, downgrade, and missing dependency behavior are documented or handled | Artifact checklist, install logs, uninstall evidence | Partially blocked: Linux `.deb` metadata and contents are inspectable and the standard `.deb`/`.rpm` build path is stable, but AppImage remains blocked and install or uninstall evidence is still absent |
|
||||
|
||||
## Current Acceptance Judgment
|
||||
|
||||
- No Redis-backed Redis GUI V1 feature acceptance item can be marked passed on 2026-03-27.
|
||||
- Current repository state now supports shell-level frontend acceptance and partial backend feature review: the React workspace shell builds, live desktop bridges exist for connection test, browse, value read/write, and command execution, draft-validation and empty-state behavior are explicit, and shared UI scope documentation is present in-repo.
|
||||
- Rust source-level test evidence is now current for `redis-core`, Linux package artifacts exist for `.deb` and `.rpm`, but live Redis screenshots and installation evidence are still required.
|
||||
- QA can now reuse the documented fixture and smoke path, but feature acceptance and release sign-off remain blocked on end-to-end desktop evidence.
|
||||
- No Redis-backed Redis GUI V1 feature acceptance item can be marked passed on 2026-03-31.
|
||||
- Current repository state now supports shell-level frontend acceptance and partial backend feature review: the React workspace shell builds, live desktop bridges exist for connection test, browse, string create, value read/write, and command execution, draft-validation and empty-state behavior are explicit, and shared UI scope documentation is present in-repo.
|
||||
- The visible `Add key` surface is labeled `Preview` and should be tracked as implemented next-phase inventory only; its presence in the repository must not be treated as automatic current-baseline pass criteria or release scope expansion.
|
||||
- Rust source-level test evidence is current for `redis-core`, the desktop test baseline is current, the browser demo entrypoint is reproducible, Linux package artifacts exist for `.deb` and `.rpm`, Debian metadata is inspectable, and the fixture path is reproducible on both success and one key failure path, but live Redis screenshots or logs and installation evidence are still required.
|
||||
- QA can now independently execute the documented Linux baseline path, and the phase-level evidence loop is summarized in `docs/qa/current-phase-evidence-loop.md`, but feature acceptance and release sign-off remain blocked on a GUI-capable desktop session for end-to-end evidence, AppImage packaging, cross-platform evidence, and installer install or uninstall checks.
|
||||
|
||||
@@ -45,6 +45,6 @@ Provide one minimal but repeatable smoke flow for Windows, macOS, and Linux that
|
||||
|
||||
## Current Blockers On 2026-03-27
|
||||
|
||||
- Verified Linux package artifacts now exist for `.deb` and `.rpm` under `target/release/bundle/`, but AppImage failed in the latest run and no validated package artifact evidence exists yet for macOS or Windows.
|
||||
- Verified Linux package artifacts now exist for `.deb` and `.rpm` under `target/release/bundle/`, while the separate `pnpm run desktop:tauri:build:all` path still fails in the AppImage stage; no validated package artifact evidence exists yet for macOS or Windows.
|
||||
- The repository now provides a Linux-oriented local smoke runbook and Docker-backed Redis fixture conventions, but those steps still need saved cross-platform execution evidence.
|
||||
- Current desktop shell now supports live browse, inspect, string save, TTL update, and command execution in Tauri runtime, but full end-to-end smoke evidence still needs saved screenshots or run logs.
|
||||
|
||||
39
docs/qa/current-phase-evidence-loop.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# Current Phase Evidence Loop
|
||||
|
||||
Date: 2026-03-31
|
||||
Owner: QA Engineer
|
||||
Issue: HAC-33
|
||||
|
||||
## Scope Boundary Aligned On 2026-03-31
|
||||
|
||||
- Current phase is `desktop-v1` baseline hardening and acceptance closure, as defined in `docs/redis-gui-v1-product-requirements.md`, `plans/2026-03-31-redis-gui-v1-status-refresh.md`, and `plans/2026-03-31-cto-handoff-current-phase.md`.
|
||||
- Current-phase acceptance is limited to the existing operator loop: connect, browse, inspect, string save, TTL update, and safe command execution in live Tauri runtime.
|
||||
- `Add Key` exists in the checked-out repository, but PM classification still keeps it in `desktop-v1.1`; it must not be folded into current-baseline acceptance or release claims.
|
||||
- Real delete remains unimplemented and must stay outside shipped-current-phase capability claims.
|
||||
|
||||
## Execution Issue Evidence Map
|
||||
|
||||
| Issue | What It Owns | Evidence References | QA Readout On 2026-03-31 | Open Risk |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| HAC-15 | Stable Linux build, package, fixture, and smoke path foundation | `plans/2026-03-27-hac-15-desktop-build-smoke.md`, `docs/qa/local-desktop-smoke.md`, `docs/qa/verification-path-inventory.md`, `docs/qa/acceptance-matrix.md` | Passed for repeatable command-level Linux evidence: `cargo test -p redis-core`, `pnpm --filter @redis-gui/desktop test`, `pnpm run desktop:build`, `pnpm run desktop:tauri:build`, and fixture scripts are reproducible in this session. | Does not provide real-window smoke, Linux install or uninstall proof, or any macOS or Windows evidence. |
|
||||
| HAC-19 | Local smoke assist surface for faster operator verification | `plans/2026-03-28-hac-19-local-smoke-assist.md`, `docs/frontend-workspace-baseline.md`, `docs/qa/local-desktop-smoke.md`, `docs/qa/acceptance-matrix.md` | Passed at shell-level evidence: smoke fixture path is documented in-repo, visible in the product surface, and reproducible from the current tree. | Still depends on a GUI-capable desktop session to turn the assisted path into release-grade live smoke evidence. |
|
||||
| HAC-22 | DB switcher hardening inside current phase | `plans/2026-03-31-hac-22-db-switcher-hardening.md`, `docs/redis-gui-v1-product-requirements.md`, `docs/qa/acceptance-matrix.md` | Passed at command-level regression evidence: the frontend baseline now covers the wider DB selector path and remains green under the current desktop test/build commands. | Live Tauri screenshots or run logs for DB switching are still absent in this headless QA session. |
|
||||
| HAC-26 | Frontend workspace-state foundation and browser-demo hardening | `plans/2026-03-31-hac-26-workspace-state-foundation.md`, `plans/2026-03-31-frontend-demo-state-hardening.md`, `docs/qa/acceptance-matrix.md` | Passed at regression-baseline level: `pnpm --filter @redis-gui/desktop test` now passes 8 files and 29 tests, and browser-shell build remains reproducible. | Browser-demo hardening improves shell review only; it does not satisfy live Redis acceptance without Tauri smoke evidence. |
|
||||
| HAC-27 | Manual GUI smoke and AppImage install verification | `docs/qa/acceptance-matrix.md`, `docs/qa/release-readiness-checklist.md`, `docs/qa/verification-path-inventory.md` | Blocked in this environment: `target/release/desktop-shell` fails with `Failed to initialize GTK` because the session has no `DISPLAY`, `WAYLAND_DISPLAY`, or `xvfb-run`, and `pnpm run desktop:tauri:build:all` still fails with `failed to run linuxdeploy`. | Real-window smoke, Linux install or uninstall proof, and AppImage validation still require a GUI-capable Linux session plus follow-up evidence capture. |
|
||||
| HAC-24 / HAC-25 | Future-phase Add Key implementation and QA coverage | `plans/2026-03-31-hac-24-add-key-frontend.md`, `docs/redis-gui-v1-product-requirements.md`, `plans/2026-03-31-hac-21-execution-split.md` | Explicitly excluded from current-phase sign-off. Repository presence is recorded as next-phase inventory only. | Scope leakage would weaken current-baseline acceptance if this capability is treated as already approved release scope. |
|
||||
|
||||
## Current Phase Judgment
|
||||
|
||||
- Command-level Linux evidence is closed enough to support an honest phase readout: build, package, browser-shell startup, fixture success path, and one fixture failure path are reproducible from the checked-out tree.
|
||||
- Current phase is not accepted yet. Required live Tauri smoke evidence for connect, browse, inspect, string save, TTL update, and safe command execution is still missing.
|
||||
- Current release readiness remains blocked by four named gaps:
|
||||
- no GUI-capable Linux session for real-window smoke capture
|
||||
- AppImage still fails in the `linuxdeploy` stage
|
||||
- Linux install and uninstall evidence has not been captured
|
||||
- macOS and Windows evidence is still absent
|
||||
|
||||
## Alignment Summary
|
||||
|
||||
- PM acceptance boundary is repo-local and inspectable through `docs/redis-gui-v1-product-requirements.md` and `plans/2026-03-31-redis-gui-v1-status-refresh.md`.
|
||||
- CTO execution priority is consistent with QA findings: unblock GUI smoke first, keep Linux package evidence repeatable, and treat AppImage plus cross-platform gaps as explicit risks instead of implied passes.
|
||||
- This issue closes the evidence-summary loop for the current phase. It does not clear the blocked acceptance items tracked through HAC-27 and the release-readiness checklist.
|
||||
70
docs/qa/integration-baseline-governance.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# Integration Baseline Governance
|
||||
|
||||
Date: 2026-03-31
|
||||
Owner: CTO
|
||||
|
||||
## Purpose
|
||||
|
||||
Keep issue status aligned with the code branch that QA and manual testers actually use.
|
||||
|
||||
## Current Desktop Baseline
|
||||
|
||||
- Current designated desktop integration branch: `cmp-35-desktop-integration-baseline`
|
||||
- Source issue: `HAC-35`
|
||||
- Intended consumers:
|
||||
- QA smoke and acceptance work
|
||||
- manual product review
|
||||
- release-readiness spot checks for the current desktop phase
|
||||
|
||||
## Required Delivery States
|
||||
|
||||
Engineering work now has three separate states:
|
||||
|
||||
1. `implemented`
|
||||
- code exists on the issue branch
|
||||
2. `verified`
|
||||
- issue-scoped test or build evidence exists
|
||||
3. `integrated`
|
||||
- the verified change exists on the designated shared integration branch
|
||||
|
||||
An issue is only fully complete after it reaches `integrated`.
|
||||
|
||||
## Required Handoff Rule
|
||||
|
||||
Before an engineer or manager closes a delivery issue, the issue record must identify:
|
||||
|
||||
- work branch
|
||||
- final commit SHA
|
||||
- push target
|
||||
- verification result
|
||||
- designated integration branch
|
||||
- whether the change is already present on that integration branch
|
||||
|
||||
If the change is not yet present on the integration branch, the issue should stay open or be explicitly marked as waiting for integration.
|
||||
|
||||
## Baseline Branch Rules
|
||||
|
||||
- The integration baseline branch must be explicit and named in the owning integration issue.
|
||||
- QA should always test from that branch, not from a hand-built local composition of "done" branches.
|
||||
- When a new integration branch supersedes the old one, the superseding issue must publish:
|
||||
- the new branch name
|
||||
- what source branches or commits were absorbed
|
||||
- whether conflicts were resolved
|
||||
- the verification commands and outcomes
|
||||
- A completed feature branch does not become the test baseline automatically.
|
||||
|
||||
## Conflict Resolution Rule
|
||||
|
||||
When completed branches overlap, the integration owner must:
|
||||
|
||||
- state the preserved base branch
|
||||
- state the absorbed source branches or SHAs
|
||||
- record which files needed manual conflict resolution
|
||||
- re-run the minimum branch-level verification after resolution
|
||||
|
||||
## Minimum Verification For Desktop Integration
|
||||
|
||||
- `pnpm --filter @redis-gui/desktop test`
|
||||
- `pnpm run desktop:build`
|
||||
|
||||
Additional verification may still be required by QA or release readiness, but these two commands are the minimum gate before the integration branch can be handed off as the current desktop test baseline.
|
||||
@@ -70,13 +70,14 @@ Seeded keys:
|
||||
1. Run `./scripts/redis-fixture/start.sh`.
|
||||
2. Run `./scripts/redis-fixture/seed.sh`.
|
||||
3. Launch `pnpm run desktop:tauri:dev`.
|
||||
4. In the desktop app, connect to host `127.0.0.1`, port `6380`, database `0`, no password, TLS disabled.
|
||||
4. In the desktop app, either use the utility-rail `Local smoke assist` shortcut or connect manually 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.
|
||||
6. Use the same `Local smoke assist` panel to prefill `smoke:*`, `PING`, or `GET smoke:string` if desired, then verify the command console can run a safe read.
|
||||
7. Verify the seeded keys listed in both the utility rail and this runbook 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.
|
||||
9. Optional next-phase check only: use `Add key` to create a new string key such as `smoke:new-string`, verify it appears immediately in the browser and inspector, then retry the same name to confirm duplicate-key failure stays on the shared notice surface. Do not mix this evidence into current-baseline sign-off unless PM explicitly reclassifies Add Key into the active phase.
|
||||
10. 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.
|
||||
11. Run `./scripts/redis-fixture/stop.sh` after the session.
|
||||
|
||||
## Current Validation Boundary
|
||||
|
||||
@@ -84,4 +85,4 @@ Seeded keys:
|
||||
- `pnpm run desktop:build` passes in the current workspace.
|
||||
- `./scripts/redis-fixture/start.sh`, `./scripts/redis-fixture/seed.sh`, and `./scripts/redis-fixture/stop.sh` pass in the current workspace.
|
||||
- `pnpm run desktop:tauri:build` now produces Linux `.deb` and `.rpm` artifacts under `target/release/bundle/`.
|
||||
- The same Tauri build still fails in its AppImage stage with `io: Connection reset by peer (os error 104)`, so AppImage should be treated as not yet verified.
|
||||
- `pnpm run desktop:tauri:build:all` still fails in its AppImage stage because `linuxdeploy` aborts, so AppImage should be treated as not yet verified.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Release Readiness Checklist
|
||||
|
||||
Date: 2026-03-27
|
||||
Date: 2026-03-31
|
||||
Owner: QA Engineer
|
||||
|
||||
## Must Be True Before Redis GUI V1 Can Ship
|
||||
@@ -19,13 +19,15 @@ Owner: QA Engineer
|
||||
- Known release blockers are triaged with owner and severity.
|
||||
- Installer or portable package basic install, launch, and uninstall checks are complete.
|
||||
|
||||
## Current Release Blockers On 2026-03-27
|
||||
## Current Release Blockers On 2026-03-31
|
||||
|
||||
- Blocked: Linux package artifact evidence now exists for `.deb` and `.rpm`, but AppImage is still unverified and there is no validated artifact evidence yet for macOS or Windows.
|
||||
- Blocked: a repeatable Docker-backed seed and smoke path now exists, but no completed smoke evidence set has been captured from it yet.
|
||||
- Blocked: release artifact naming now exists in produced Linux packages, but install and uninstall verification is still not documented with saved evidence.
|
||||
- Blocked: a repeatable Docker-backed seed and smoke path now exists, but no completed real-desktop smoke evidence set has been captured from it yet.
|
||||
- Blocked: release artifact naming and Debian package metadata now exist in produced Linux packages, but install and uninstall verification is still not documented with saved evidence.
|
||||
- Blocked: the desktop app is not yet validated end-to-end against a seeded Redis path; browse, edit, and TTL controls are inspectable, but broader workflow evidence remains incomplete.
|
||||
- Blocked: desktop runtime smoke evidence inside the real Tauri window is still pending even though current `redis-core` Rust test evidence and Linux package artifacts are reproducible from the present tree.
|
||||
- Blocked: desktop runtime smoke evidence inside the real Tauri window is still pending even though current `redis-core` Rust test evidence and Linux package artifacts are reproducible from the present tree; the current QA heartbeat has no GUI-capable `DISPLAY`, `WAYLAND_DISPLAY`, or `xvfb-run`.
|
||||
- Blocked: `pnpm run desktop:tauri:build:all` still fails in the AppImage `linuxdeploy` stage, so AppImage cannot be treated as a verified Linux install path.
|
||||
- Blocked: Docker-backed smoke setup is script-reproducible, but there is still no compose-managed handoff path for non-author operators.
|
||||
|
||||
## Minimum Sign-Off Evidence
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# QA Test Strategy
|
||||
|
||||
Date: 2026-03-27
|
||||
Date: 2026-03-31
|
||||
Owner: QA Engineer
|
||||
|
||||
## Test Objectives
|
||||
@@ -21,7 +21,7 @@ Owner: QA Engineer
|
||||
|
||||
## Environments
|
||||
|
||||
- Linux: current local environment available to QA.
|
||||
- Linux: current local environment available to QA, but the present session is headless and cannot capture a real Tauri window.
|
||||
- macOS: required for release acceptance, currently not provisioned in this repository.
|
||||
- Windows: required for release acceptance, currently not provisioned in this repository.
|
||||
|
||||
@@ -37,10 +37,10 @@ Owner: QA Engineer
|
||||
- "Blocked" is valid only when the missing prerequisite is named.
|
||||
- "Not run" is distinct from "Pass" and must remain visible in checklists.
|
||||
|
||||
## Current Gaps On 2026-03-27
|
||||
## Current Gaps On 2026-03-31
|
||||
|
||||
- Product shell exists as a visible desktop workspace baseline with shell-level validation and empty-state handling, but not as a complete Redis feature surface.
|
||||
- No seed dataset or local Redis automation.
|
||||
- No smoke harness, installer path, or CI workflow.
|
||||
- Tauri packaging evidence is not currently present in the repository.
|
||||
- Backend automated coverage is limited to foundation-model tests in `crates/redis-core`.
|
||||
- The highest-risk gap is still missing real-window desktop evidence for connect, browse, inspect, string save, TTL update, and command execution in a GUI-capable session.
|
||||
- `pnpm run desktop:tauri:build:all` still fails in the AppImage `linuxdeploy` stage, so the full Linux bundle path is not release-ready.
|
||||
- No validated macOS or Windows build, install, launch, or smoke evidence exists.
|
||||
- Linux installer install and uninstall checks are still absent even though `.deb` and `.rpm` artifacts are reproducible.
|
||||
- Docker-backed smoke reproducibility currently depends on direct fixture scripts; there is still no `Dockerfile` or compose asset to standardize the environment handoff.
|
||||
|
||||
29
docs/qa/verification-path-inventory.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# Verification Path Inventory
|
||||
|
||||
Date: 2026-03-31
|
||||
Owner: QA Engineer
|
||||
|
||||
## Goal
|
||||
|
||||
Pin down which startup, demo, seed, smoke, and Docker paths are actually reproducible in the current repository, and which ones remain blocked or undocumented.
|
||||
|
||||
## Inventory
|
||||
|
||||
| Path | Entry Point | Evidence On 2026-03-31 | Current Status | Gap |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Browser demo startup | `pnpm run desktop:dev` | Vite served `http://127.0.0.1:1420/`; `curl` returned the shell HTML entrypoint | Reproducible | Demo-only path; not valid live Redis evidence |
|
||||
| Browser production build | `pnpm run desktop:build` | Build passed and emitted `apps/desktop/dist/` | Reproducible | Still frontend-shell evidence only |
|
||||
| Linux desktop package build | `pnpm run desktop:tauri:build` | Build passed and produced `.deb` and `.rpm` under `target/release/bundle/` | Reproducible | Does not prove install, launch, or workflow acceptance |
|
||||
| Linux native app launch | `target/release/desktop-shell` | Launch failed with `Failed to initialize GTK` in a session with no `DISPLAY`, `WAYLAND_DISPLAY`, or `xvfb-run` | Blocked by environment | Needs GUI-capable session or virtual display tooling |
|
||||
| Full Linux bundle path | `pnpm run desktop:tauri:build:all` | `.deb` and `.rpm` bundled, then AppImage failed with `failed to run linuxdeploy` | Partially reproducible | AppImage remains unverified |
|
||||
| Seed fixture happy path | `./scripts/redis-fixture/start.sh` -> `seed.sh` -> `redis-cli` checks -> `stop.sh` | Container started, 7 `smoke:*` keys seeded, types and TTL verified, cleanup succeeded | Reproducible | No GUI evidence attached to the live desktop workflow yet |
|
||||
| Seed fixture failure path | `./scripts/redis-fixture/seed.sh` without a running container | Script failed explicitly with `Run scripts/redis-fixture/start.sh first.` | Reproducible | Only one negative fixture path is documented so far |
|
||||
| Local smoke runbook | `docs/qa/local-desktop-smoke.md` | Commands, fixture inputs, and expected keys align with current scripts and README | Reproducible on the command line | Still blocked from real-window smoke capture in this session |
|
||||
| Docker support path | Local Docker daemon + fixture scripts | Docker daemon reachable and scripts work | Reproducible | No `Dockerfile` or compose asset exists; reproducibility depends on direct script usage |
|
||||
|
||||
## QA Readout
|
||||
|
||||
- Startup paths are split cleanly between browser demo evidence and Tauri package evidence.
|
||||
- The seeded Docker fixture path is now reproducible on both success and one clear failure path.
|
||||
- The release-critical missing evidence is still real-window desktop smoke, not command-level buildability.
|
||||
- Docker support exists only as a QA fixture dependency, not as a packaged app runtime or compose-managed environment.
|
||||
261
docs/redis-gui-v1-product-requirements.md
Normal file
@@ -0,0 +1,261 @@
|
||||
# Redis GUI V1 Product Requirements
|
||||
|
||||
Date: 2026-03-30
|
||||
Owner: Product Manager
|
||||
Status: Active working product spec
|
||||
|
||||
## 1. Document Purpose
|
||||
|
||||
This document defines the first usable product scope for the Redis desktop GUI based on the repository state inspected on 2026-03-30. It is intended to be specific enough for the CTO to split engineering work and for QA to validate acceptance.
|
||||
|
||||
This document supersedes the 2026-03-27 assumption that the workspace or demo was not accessible.
|
||||
|
||||
## 2. Current Demo Inventory On 2026-03-30
|
||||
|
||||
Confirmed from `README.md`, `docs/architecture.md`, `docs/frontend-workspace-baseline.md`, `docs/qa/acceptance-matrix.md`, `docs/qa/local-desktop-smoke.md`, `apps/desktop/src/App.tsx`, and `apps/desktop/src-tauri/src/main.rs`:
|
||||
|
||||
- The project now contains an inspectable desktop implementation path built around `apps/desktop` and a shared Rust core in `crates/redis-core`.
|
||||
- Tauri desktop mode exposes live backend commands for `backend_bootstrap`, `test_redis_connection`, `browse_redis_keys`, `inspect_redis_key`, `read_redis_value`, `write_redis_value`, `update_redis_key_ttl`, and `execute_redis_command`.
|
||||
- The visible desktop shell already implements the intended four-zone workspace: utility rail, browser pane, inspector pane, and command tray.
|
||||
- Browser dev mode is intentionally a demo fallback. It does not pretend to be a live Redis runtime.
|
||||
- The shell supports draft connection creation with local validation for profile name, host, port, and DB. Duplicate profile names are rejected in local shell state.
|
||||
- Live connection testing exists in Tauri mode. Active connection and DB context stay visible in the workspace.
|
||||
- Key browsing is wired to live `SCAN`-style pagination with type and TTL metadata.
|
||||
- Search is currently key-name and pattern based within the active DB and reuses the browse contract.
|
||||
- The inspector supports structured value viewing for `string`, `hash`, `list`, `set`, `zset`, and `stream`.
|
||||
- Live editing is intentionally narrow: only existing string keys can be replaced as a whole value. The current TTL is preserved during string save.
|
||||
- TTL update and TTL removal are already live in desktop mode.
|
||||
- The command tray executes live commands in desktop mode and explicitly blocks `FLUSH*`, `CONFIG*`, and `SHUTDOWN*` from the visible shell.
|
||||
- The shell includes a local smoke assist path for the seeded fixture on `127.0.0.1:6380` and a copyable QA snapshot for evidence capture.
|
||||
- Linux packaging evidence now exists for `.deb` and `.rpm`. AppImage is still blocked. macOS and Windows smoke evidence do not yet exist.
|
||||
|
||||
Known gaps that remain product-significant:
|
||||
|
||||
- No persisted connection catalog or secret storage is enabled yet.
|
||||
- Real key deletion is not implemented. The current delete confirmation is a mock-only flow.
|
||||
- Non-string value editing remains unsupported.
|
||||
- The accepted runtime target is still single-instance Redis, not cluster or Sentinel.
|
||||
- TLS modes other than `disabled` are not actually supported by the backend yet.
|
||||
- QA has not yet collected enough end-to-end live desktop evidence to mark Redis-backed feature acceptance as passed.
|
||||
|
||||
## 3. Target Users
|
||||
|
||||
Primary users:
|
||||
|
||||
- Backend or full-stack engineers who need to inspect and make low-risk fixes to Redis data in local, development, test, or staging environments.
|
||||
- QA or support engineers who need to verify keys, values, and TTL behavior without relying on Redis CLI fluency.
|
||||
- Engineers debugging application behavior who need visible DB context and safer workflows than raw shell commands.
|
||||
|
||||
Not the primary V1 user:
|
||||
|
||||
- Infra operators managing cluster topology, failover, replication, or production fleet administration.
|
||||
- Users expecting observability dashboards, performance analytics, or admin-level server management.
|
||||
|
||||
## 4. Problem Statement
|
||||
|
||||
The target user needs a faster and safer way to inspect Redis data, verify key state, make narrow string fixes, adjust TTL, and run basic safe commands without falling back to raw CLI for every step.
|
||||
|
||||
Today, the fallback path is still Redis CLI or ad hoc shell commands. That path is efficient for experts but has three product problems:
|
||||
|
||||
- it hides active connection and DB context too easily
|
||||
- it increases the chance of destructive mistakes
|
||||
- it is slower for routine inspection, especially for typed values and TTL checks
|
||||
|
||||
## 5. Product Goal
|
||||
|
||||
Ship a desktop Redis operator tool for single-instance Redis workflows that makes routine inspect, verify, string-fix, TTL-adjust, and safe-command tasks faster and safer than raw CLI in local, dev, test, and staging environments.
|
||||
|
||||
## 6. Core Operator Flow
|
||||
|
||||
1. User selects or creates a connection profile.
|
||||
2. User tests the connection before relying on the workspace.
|
||||
3. User enters the workspace with the active connection and DB visibly pinned.
|
||||
4. User browses or searches keys in the active DB.
|
||||
5. User inspects metadata and value structure for the selected key.
|
||||
6. User either saves a string value, updates TTL, or runs a safe command in the same scoped context.
|
||||
7. User verifies the result immediately in the browser and inspector without reconnecting.
|
||||
|
||||
## 7. Current Phase Scope
|
||||
|
||||
### 7.1 Phase Definition
|
||||
|
||||
The current phase is not "full product release". It is a desktop operator baseline with live Redis-backed workflows for the smallest reliable surface that can support real usability testing and downstream hardening.
|
||||
|
||||
### 7.2 Included In The Current Phase
|
||||
|
||||
- One active standalone Redis connection at a time.
|
||||
- Connection inputs for host, port, optional username, optional password, and DB index.
|
||||
- Local validation for draft profile creation.
|
||||
- Live connection test in Tauri desktop mode.
|
||||
- Visible active connection and active DB context across the workspace.
|
||||
- Key browsing with incremental loading through the current backend browse contract.
|
||||
- Key-name and pattern search within the active DB.
|
||||
- Metadata display for key name, key type, existence state, and TTL.
|
||||
- Structured inspector views for `string`, `hash`, `list`, `set`, `zset`, and `stream`.
|
||||
- String edit support only for existing string keys through whole-value replacement.
|
||||
- TTL set and TTL removal for the selected key.
|
||||
- Safe command execution in the active connection and DB context.
|
||||
- Consistent operator-facing error banner mapping across connection, browse, inspect, save, TTL, and command flows.
|
||||
- Local fixture assist and QA snapshot support for repeatable smoke runs.
|
||||
|
||||
### 7.3 Explicitly Not Included In The Current Phase
|
||||
|
||||
- Real delete execution.
|
||||
- Create-new-key workflows.
|
||||
- Editing support for hash, list, set, zset, or stream values.
|
||||
- Multiple simultaneous connections or compare views.
|
||||
- Cluster, Sentinel, replication, or topology awareness.
|
||||
- SSH tunnel orchestration, secret vault integration, or production-grade credential persistence.
|
||||
- Administrative or fleet-level Redis operations.
|
||||
- Release sign-off for macOS or Windows.
|
||||
|
||||
## 8. Non-Goals
|
||||
|
||||
The following should stay out of V1 unless the CEO and CTO explicitly re-scope:
|
||||
|
||||
- Redis Cluster and Sentinel support.
|
||||
- Replication insight, failover tooling, slot views, or topology diagrams.
|
||||
- Pub/Sub tooling, monitor streams, slowlog, or performance dashboards.
|
||||
- Value-content full-text search across all keys.
|
||||
- Bulk actions, mass delete, import or export, migration, or schema management.
|
||||
- Editing support for every Redis data type.
|
||||
- Administrative commands such as `FLUSHALL`, `CONFIG SET`, or `SHUTDOWN`.
|
||||
- Multi-pane workflows or collaborative operator features.
|
||||
|
||||
## 9. Acceptance Standards For This Phase
|
||||
|
||||
### 9.1 Runtime Mode Boundary
|
||||
|
||||
- Browser dev mode must remain explicitly demo-only.
|
||||
- Desktop mode must clearly indicate that it is operating against live Redis.
|
||||
- The UI must not blur demo output and live output.
|
||||
|
||||
### 9.2 Connection Management
|
||||
|
||||
- User can create a visible profile draft with validated host, port, DB, and name fields.
|
||||
- User can run a live connection test in Tauri mode before treating the profile as usable.
|
||||
- Workspace always shows the active connection and DB after selection.
|
||||
- Connection failures surface stable, operator-readable messaging for at least auth failure, invalid target, and connection failure.
|
||||
|
||||
### 9.3 Browse And Search
|
||||
|
||||
- User can browse keys in the active DB without assuming a full blocking fetch.
|
||||
- Search is scoped to the active DB and returns predictable empty states.
|
||||
- Key rows expose at minimum name, type, and TTL state when available.
|
||||
- Clearing search returns the user to normal browse behavior without reconnecting.
|
||||
|
||||
### 9.4 Inspect, Save, And TTL
|
||||
|
||||
- Selecting a key opens a readable inspector with metadata and typed value structure.
|
||||
- String keys expose save actions only when the backend confirms write support.
|
||||
- Saving a string key refreshes the visible value state and preserves TTL.
|
||||
- Non-string keys remain explicitly read-only.
|
||||
- TTL can be set or removed for the selected key and the inspector reflects the new TTL state after success.
|
||||
- Failed save or TTL operations preserve user context and show a clear outcome.
|
||||
|
||||
### 9.5 Command Console
|
||||
|
||||
- The console always shows which connection and DB it targets.
|
||||
- The user can run a single command and see a clear result or error.
|
||||
- Dangerous or administrative commands outside scope are blocked before they appear to succeed.
|
||||
- Command history distinguishes live results from demo fallback results.
|
||||
|
||||
### 9.6 Release Evidence Required Before Calling V1 Accepted
|
||||
|
||||
- Live Tauri smoke evidence against the seeded fixture must exist for connect, browse, inspect, string save, TTL update, and command execution.
|
||||
- Linux launch and packaging evidence must remain repeatable.
|
||||
- macOS and Windows remain outside current acceptance until build and smoke evidence exist.
|
||||
- The in-repo product requirements document must exist and stay aligned with QA artifacts.
|
||||
|
||||
## 10. CLI Demo To Product Evolution
|
||||
|
||||
The repository currently centers on the desktop path, but the intended evolution still follows a CLI-to-product logic at the capability level:
|
||||
|
||||
### Stage A: Shared Redis capability proof
|
||||
|
||||
Use `redis-core` or a thin CLI surface to prove:
|
||||
|
||||
- connection and authentication
|
||||
- DB selection
|
||||
- browse pagination
|
||||
- typed value reads
|
||||
- narrow string write rules
|
||||
- TTL mutation rules
|
||||
- safe command execution
|
||||
|
||||
### Stage B: Live desktop workflow
|
||||
|
||||
Map those validated capabilities into one operator workspace with:
|
||||
|
||||
- visible connection scope
|
||||
- browse and search
|
||||
- typed inspection
|
||||
- string save and TTL actions
|
||||
- safe command tray
|
||||
|
||||
### Stage C: Product hardening
|
||||
|
||||
Add the product behaviors that make the workflow trustworthy:
|
||||
|
||||
- consistent failure copy
|
||||
- explicit unsupported-state messaging
|
||||
- confirmation and safety boundaries
|
||||
- smoke evidence capture
|
||||
- clearer onboarding and profile handling
|
||||
|
||||
### Stage D: Productization
|
||||
|
||||
Only after the operator baseline is stable should the team expand toward:
|
||||
|
||||
- persisted profile management
|
||||
- secure secret handling
|
||||
- true destructive flows with audited confirmation
|
||||
- cross-platform packaging evidence
|
||||
- broader data-type editing if still justified by user demand
|
||||
|
||||
## 11. Handoff To CTO
|
||||
|
||||
The CTO should split implementation and closure work into these product slices:
|
||||
|
||||
- Connection profile handling, validation, and runtime-mode boundary
|
||||
- Browse and search flows on top of the existing `SCAN` contract
|
||||
- Inspector and typed value presentation
|
||||
- String save and TTL workflows
|
||||
- Command safety boundary and blocked-command rules
|
||||
- Release hardening, evidence capture, and cross-platform packaging follow-up
|
||||
|
||||
The CTO should treat the following as boundary conditions, not optional polish:
|
||||
|
||||
- do not expand editing surface before current string and TTL flows are fully evidenced
|
||||
- do not treat mock delete confirmation as shipped delete capability
|
||||
- do not treat browser demo mode as release evidence for Redis-backed acceptance
|
||||
|
||||
## 12. Open Risks And Dependencies
|
||||
|
||||
- Delete is visible in the shell but not real in the backend. That can confuse operators unless messaging stays explicit.
|
||||
- Lack of persisted profiles and secrets limits day-to-day usability and onboarding continuity.
|
||||
- The backend contract still rejects TLS modes beyond `disabled`, which narrows usable environments.
|
||||
- QA evidence is improving, but Redis-backed feature acceptance remains incomplete as of 2026-03-30.
|
||||
|
||||
## 13. Scope Classification Follow-Up On 2026-03-31
|
||||
|
||||
The PM scope-classification follow-up for `HAC-19` is recorded in `plans/2026-03-31-hac-19-scope-classification.md`.
|
||||
|
||||
Result:
|
||||
|
||||
- no new requests were approved into `desktop-v1`
|
||||
- `Add Key` is classified as a `desktop-v1.1` candidate only
|
||||
- blue visual refresh and bilingual UI remain `later`
|
||||
|
||||
This keeps the current phase focused on proving and accepting the existing live desktop operator baseline before any visible scope expansion.
|
||||
|
||||
## 14. Add Key Boundary On 2026-03-31
|
||||
|
||||
The repository now contains an implemented Add Key path, but PM does not treat code presence as automatic current-phase approval.
|
||||
|
||||
Current rule:
|
||||
|
||||
- `Add Key` remains outside `desktop-v1` acceptance and release claim
|
||||
- it stays a `desktop-v1.1` candidate until PM explicitly reclassifies it
|
||||
|
||||
The boundary note for CTO and QA is recorded in `plans/2026-03-31-add-key-phase-boundary.md`.
|
||||
@@ -9,6 +9,7 @@
|
||||
"desktop:dev": "pnpm --filter @redis-gui/desktop dev",
|
||||
"desktop:build": "pnpm --filter @redis-gui/desktop build",
|
||||
"desktop:tauri:dev": "pnpm --filter @redis-gui/desktop tauri dev",
|
||||
"desktop:tauri:build": "pnpm --filter @redis-gui/desktop tauri build"
|
||||
"desktop:tauri:build": "pnpm --filter @redis-gui/desktop tauri build --bundles deb,rpm",
|
||||
"desktop:tauri:build:all": "pnpm --filter @redis-gui/desktop tauri build"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,10 +29,14 @@ Make the desktop repository usable through one stable build path, one stable pac
|
||||
- Passed: `./scripts/redis-fixture/start.sh`
|
||||
- Passed: `./scripts/redis-fixture/seed.sh`
|
||||
- Passed: `./scripts/redis-fixture/stop.sh`
|
||||
- In progress: `pnpm run desktop:tauri:build` now clears the pre-build script failure and reaches Cargo dependency download and compile steps
|
||||
- Passed: `pkg-config --modversion gtk+-3.0`
|
||||
- Passed: `pkg-config --modversion webkit2gtk-4.1`
|
||||
- Passed: `pkg-config --modversion libsoup-3.0`
|
||||
- Passed: `pnpm run desktop:tauri:build`
|
||||
- Evidence: Linux package artifacts were emitted under `target/release/bundle/`, including `.deb` and `.rpm`
|
||||
|
||||
## Remaining Risk
|
||||
|
||||
- This heartbeat has not yet produced a saved unsigned package artifact for Linux, macOS, or Windows.
|
||||
- First-run Cargo package downloads remain slow and can serialize on the shared package-cache lock, so package verification should be rerun in a clean single-build window.
|
||||
- End-to-end smoke evidence inside the desktop UI still depends on frontend surfaces that are only partially implemented.
|
||||
- `HAC-15` exit criteria are satisfied for the documented stable Linux path, but macOS and Windows packaging remain outside this issue's validated evidence set.
|
||||
- The separate `pnpm run desktop:tauri:build:all` path still has AppImage-stage risk and should not replace the stable QA packaging command.
|
||||
- End-to-end GUI smoke evidence still depends on a human-run desktop session with screenshots or QA snapshot capture.
|
||||
|
||||
@@ -23,14 +23,13 @@ Create the first acceptance matrix and cross-platform smoke plan for Redis GUI V
|
||||
|
||||
## Known Blockers
|
||||
|
||||
- `npm run desktop:tauri:build` currently fails because its pre-build command targets a non-existent parent `build` script.
|
||||
- Repository does not yet contain Docker, demo, or seed assets.
|
||||
- Current automated coverage is limited to foundation-model tests in `crates/redis-core`.
|
||||
- Completed issue comments for `HAC-2` and `HAC-8` reference shared files that are absent from the current workspace, so QA cannot inspect those artifacts locally.
|
||||
- Existing Rust test artifacts in `target/` are stale relative to current source, so backend baseline verification needs a fresh `cargo test` rerun.
|
||||
- `pnpm run desktop:tauri:build:all` still fails in the AppImage stage with `failed to run linuxdeploy`.
|
||||
- Linux `.deb` and `.rpm` bundles now exist, but macOS and Windows artifact evidence is still absent.
|
||||
- The local fixture and smoke runbook are reproducible, but saved Tauri-window smoke evidence is still missing.
|
||||
- The current QA environment still has no GUI-capable `DISPLAY`, `WAYLAND_DISPLAY`, or `xvfb-run`, so real-window screenshot capture is blocked in this session even though the desktop binary is built.
|
||||
|
||||
## Alignment
|
||||
|
||||
- Product acceptance source: `HAC-2` issue scope and completion comment remain the current acceptance baseline for connect, browse, search, edit, and command console flows.
|
||||
- CTO priority source: `HAC-15` is now the high-priority owner for Tauri build, packaging, local smoke steps, and Redis fixture conventions.
|
||||
- QA closure for this plan remains downstream of `HAC-15`, backend feature issues `HAC-5` to `HAC-7`, and frontend feature issues `HAC-9` to `HAC-11`.
|
||||
- QA baseline closure for this plan is now materially achieved on Linux through the shared docs and repeatable smoke path; broader product release sign-off remains downstream of `HAC-15`, backend feature issues `HAC-5` to `HAC-7`, and frontend feature issues `HAC-9` to `HAC-11`.
|
||||
|
||||