2658 lines
90 KiB
TypeScript
2658 lines
90 KiB
TypeScript
import { invoke } from "@tauri-apps/api/core";
|
|
import {
|
|
startTransition,
|
|
useDeferredValue,
|
|
useEffect,
|
|
useState,
|
|
type ChangeEvent,
|
|
type FormEvent,
|
|
type ReactElement,
|
|
} from "react";
|
|
import {
|
|
Activity,
|
|
CircleAlert,
|
|
ClipboardList,
|
|
Copy,
|
|
Database,
|
|
KeyRound,
|
|
PanelLeft,
|
|
PlugZap,
|
|
Search,
|
|
ShieldAlert,
|
|
TerminalSquare,
|
|
TimerReset,
|
|
Trash2,
|
|
} from "lucide-react";
|
|
import { Badge } from "./components/ui/badge";
|
|
import { Button } from "./components/ui/button";
|
|
import {
|
|
Dialog,
|
|
DialogClose,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogTrigger,
|
|
} from "./components/ui/dialog";
|
|
import { Input } from "./components/ui/input";
|
|
import { Textarea } from "./components/ui/textarea";
|
|
import {
|
|
isLocalSmokeFixtureConnection,
|
|
localSmokeFixtureCommandPresets,
|
|
localSmokeFixtureExpectedKeys,
|
|
localSmokeFixtureProfileId,
|
|
localSmokeFixtureSearchText,
|
|
} from "./lib/local-smoke-fixture";
|
|
import {
|
|
toBackendError,
|
|
type BackendErrorContext,
|
|
createAccentNotice,
|
|
createNeutralNotice,
|
|
toBackendErrorNotice,
|
|
type OperatorNotice,
|
|
} from "./lib/operator-notices";
|
|
import { buildQaSnapshot } from "./lib/qa-snapshot";
|
|
import {
|
|
buildStructuredInspectorSections,
|
|
type StructuredInspectorSection,
|
|
} from "./lib/structured-inspector";
|
|
import {
|
|
appendWorkspaceKeyRecords,
|
|
filterWorkspaceKeys,
|
|
replaceWorkspaceKeyRecord,
|
|
resolveSelectedWorkspaceKey,
|
|
resolveSelectedWorkspaceKeyId,
|
|
} from "./lib/key-workspace-state";
|
|
import {
|
|
buildInspectorEmptyStateMessage,
|
|
buildKeyBrowserEmptyStateMessage,
|
|
buildKeyBrowserStatusMessage,
|
|
resolveKeyBrowserSearchState,
|
|
} from "./lib/key-browser-search";
|
|
import {
|
|
parseDemoTtlToSeconds,
|
|
persistDemoKeyTtl,
|
|
updateDemoKeyTtl,
|
|
updateDemoStringValue,
|
|
} from "./lib/demo-workspace";
|
|
import { cn } from "./lib/utils";
|
|
|
|
declare const __APP_VERSION__: string;
|
|
|
|
type RedisKeyType = string;
|
|
type TlsMode = "disabled" | "preferred" | "required";
|
|
type ConnectionStatus = "healthy" | "idle" | "checking" | "error";
|
|
type ConsoleEntryStatus = "ok" | "blocked" | "error";
|
|
type ConsoleEntrySource = "live" | "mock";
|
|
type RedisValueWriteCapability = "none" | "replace_string";
|
|
|
|
type ConnectionProfile = {
|
|
id: string;
|
|
name: string;
|
|
environment: "dev" | "test" | "local";
|
|
detail: string;
|
|
status: ConnectionStatus;
|
|
password: string;
|
|
target: {
|
|
host: string;
|
|
port: number;
|
|
database: number;
|
|
username: string | null;
|
|
tls_mode: TlsMode;
|
|
};
|
|
};
|
|
|
|
type KeyRecord = {
|
|
id: string;
|
|
name: string;
|
|
type: RedisKeyType;
|
|
ttl: string;
|
|
preview: string;
|
|
editable: boolean;
|
|
value: string;
|
|
summary: string;
|
|
};
|
|
|
|
type ConsoleEntry = {
|
|
command: string;
|
|
output: string;
|
|
status: ConsoleEntryStatus;
|
|
source: ConsoleEntrySource;
|
|
};
|
|
|
|
type ConnectionDraft = {
|
|
name: string;
|
|
host: string;
|
|
port: string;
|
|
database: string;
|
|
user: string;
|
|
password: string;
|
|
};
|
|
|
|
type ConnectionDraftErrors = Partial<
|
|
Record<"name" | "host" | "port" | "database", string>
|
|
>;
|
|
|
|
type BackendBootstrap = {
|
|
app_name: string;
|
|
surface: string;
|
|
persistence: {
|
|
connection_catalog: string;
|
|
secrets: string;
|
|
};
|
|
command_execution: string;
|
|
key_browsing: string;
|
|
next_backend_milestone: string;
|
|
};
|
|
|
|
type RedisConnectionRequest = {
|
|
target: ConnectionProfile["target"];
|
|
password: string | null;
|
|
};
|
|
|
|
type ConnectionTestResult = {
|
|
selected_database: number;
|
|
authenticated_as: string | null;
|
|
round_trip_status: string;
|
|
};
|
|
|
|
type RedisCommandRequest = {
|
|
connection: RedisConnectionRequest;
|
|
command: string;
|
|
arguments: string[];
|
|
};
|
|
|
|
type RedisKeyTtl =
|
|
| { kind: "persistent" }
|
|
| { kind: "missing" }
|
|
| { kind: "expires_in_millis"; value: number };
|
|
|
|
type RedisKeyMetadata = {
|
|
name: string;
|
|
database: number;
|
|
exists: boolean;
|
|
key_type: string;
|
|
ttl: RedisKeyTtl;
|
|
};
|
|
|
|
type RedisKeyBrowseRequest = {
|
|
connection: RedisConnectionRequest;
|
|
cursor: string;
|
|
pattern: string | null;
|
|
page_size: number;
|
|
};
|
|
|
|
type RedisKeyBrowseResult = {
|
|
database: number;
|
|
submitted_cursor: string;
|
|
next_cursor: string;
|
|
has_more: boolean;
|
|
page_size: number;
|
|
pattern: string | null;
|
|
keys: RedisKeyMetadata[];
|
|
};
|
|
|
|
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: RedisKeyMetadata;
|
|
data: RedisValueData;
|
|
write_capability: RedisValueWriteCapability;
|
|
ttl_write_supported: boolean;
|
|
};
|
|
|
|
type RedisValueReadRequest = {
|
|
connection: RedisConnectionRequest;
|
|
key: string;
|
|
};
|
|
|
|
type RedisValueWriteRequest = {
|
|
connection: RedisConnectionRequest;
|
|
key: string;
|
|
value: {
|
|
kind: "string";
|
|
value: string;
|
|
};
|
|
};
|
|
|
|
type RedisKeyTtlUpdate =
|
|
| { kind: "persist" }
|
|
| { kind: "expires_in_millis"; value: number };
|
|
|
|
type RedisKeyTtlUpdateRequest = {
|
|
connection: RedisConnectionRequest;
|
|
key: string;
|
|
operation: RedisKeyTtlUpdate;
|
|
};
|
|
|
|
type RedisKeyTtlUpdateResult = {
|
|
metadata: RedisKeyMetadata;
|
|
};
|
|
|
|
type RedisResponse =
|
|
| { kind: "null" }
|
|
| { kind: "integer"; value: number }
|
|
| { kind: "string"; value: string }
|
|
| { kind: "binary"; bytes: number[] }
|
|
| { kind: "array"; items: RedisResponse[] };
|
|
|
|
type CommandExecutionResult = {
|
|
command: string;
|
|
arguments: string[];
|
|
database: number;
|
|
response: RedisResponse;
|
|
};
|
|
|
|
const browsePageSize = 50;
|
|
|
|
const initialConnections: ConnectionProfile[] = [
|
|
{
|
|
id: "redis-dev-eu-1",
|
|
name: "redis-dev-eu-1",
|
|
environment: "dev",
|
|
detail: "13 ms",
|
|
status: "healthy",
|
|
password: "",
|
|
target: {
|
|
host: "127.0.0.1",
|
|
port: 6379,
|
|
database: 0,
|
|
username: null,
|
|
tls_mode: "disabled",
|
|
},
|
|
},
|
|
{
|
|
id: "redis-test-audit",
|
|
name: "redis-test-audit",
|
|
environment: "test",
|
|
detail: "28 ms",
|
|
status: "healthy",
|
|
password: "",
|
|
target: {
|
|
host: "10.42.2.19",
|
|
port: 6379,
|
|
database: 0,
|
|
username: "readonly",
|
|
tls_mode: "disabled",
|
|
},
|
|
},
|
|
{
|
|
id: "redis-local-fixture",
|
|
name: "redis-local-fixture",
|
|
environment: "local",
|
|
detail: "Fixture profile",
|
|
status: "idle",
|
|
password: "",
|
|
target: {
|
|
host: "127.0.0.1",
|
|
port: 6380,
|
|
database: 0,
|
|
username: null,
|
|
tls_mode: "disabled",
|
|
},
|
|
},
|
|
];
|
|
|
|
const initialDemoKeys: KeyRecord[] = [
|
|
{
|
|
id: "session:42",
|
|
name: "session:42",
|
|
type: "hash",
|
|
ttl: "13m",
|
|
preview: "user profile and auth state",
|
|
editable: false,
|
|
value: "userId=42, role=editor, featureFlag=beta",
|
|
summary: "Read-only in V1. Structured hash inspection ships before editing support to avoid unsafe partial writes.",
|
|
},
|
|
{
|
|
id: "cache:homepage",
|
|
name: "cache:homepage",
|
|
type: "string",
|
|
ttl: "48s",
|
|
preview: "serialized landing payload",
|
|
editable: true,
|
|
value: "{\"hero\":\"Redis GUI\",\"updatedAt\":\"2026-03-27T12:00:00Z\"}",
|
|
summary: "String keys are editable in V1 and should refresh browser metadata after save without reconnecting.",
|
|
},
|
|
{
|
|
id: "jobs:failed",
|
|
name: "jobs:failed",
|
|
type: "list",
|
|
ttl: "persistent",
|
|
preview: "3 queued failure payloads",
|
|
editable: false,
|
|
value: "[payload-1, payload-2, payload-3]",
|
|
summary: "Lists remain inspectable but read only in the first visible shell.",
|
|
},
|
|
{
|
|
id: "feature:enabled",
|
|
name: "feature:enabled",
|
|
type: "set",
|
|
ttl: "1h",
|
|
preview: "5 active feature flags",
|
|
editable: false,
|
|
value: "{search_v2, audit_log, compact_ui, qa_mode, canary_cli}",
|
|
summary: "Collections keep structural hints visible so support work stays fast without raw CLI fallback.",
|
|
},
|
|
];
|
|
|
|
const databases = ["db0", "db1", "db2", "db5"];
|
|
|
|
const fallbackBootstrap: BackendBootstrap = {
|
|
app_name: "Redis GUI Foundation",
|
|
surface: "desktop",
|
|
persistence: {
|
|
connection_catalog: "not_configured",
|
|
secrets: "memory_only",
|
|
},
|
|
command_execution: "single_instance_enabled",
|
|
key_browsing: "scan_metadata_enabled",
|
|
next_backend_milestone: "value_surface_and_catalog_persistence",
|
|
};
|
|
|
|
const initialConsoleHistory: ConsoleEntry[] = [
|
|
{
|
|
command: "TTL cache:homepage",
|
|
output: "(integer) 48",
|
|
status: "ok",
|
|
source: "mock",
|
|
},
|
|
{
|
|
command: "TYPE session:42",
|
|
output: "hash",
|
|
status: "ok",
|
|
source: "mock",
|
|
},
|
|
{
|
|
command: "FLUSHALL",
|
|
output: "Blocked in V1. Dangerous administrative commands are intentionally unavailable.",
|
|
status: "blocked",
|
|
source: "mock",
|
|
},
|
|
];
|
|
|
|
function App() {
|
|
const [connections, setConnections] = useState(initialConnections);
|
|
const [demoKeys, setDemoKeys] = useState(initialDemoKeys);
|
|
const [activeConnectionId, setActiveConnectionId] = useState(initialConnections[0].id);
|
|
const [activeDb, setActiveDb] = useState(databases[0]);
|
|
const [search, setSearch] = useState("");
|
|
const [selectedKeyId, setSelectedKeyId] = useState<string | null>(initialDemoKeys[1].id);
|
|
const [draftValue, setDraftValue] = useState(initialDemoKeys[1].value);
|
|
const [consoleInput, setConsoleInput] = useState("GET cache:homepage");
|
|
const [consoleHistory, setConsoleHistory] = useState(initialConsoleHistory);
|
|
const [bootstrap, setBootstrap] = useState(fallbackBootstrap);
|
|
const [banner, setBanner] = useState<OperatorNotice>(
|
|
createNeutralNotice(
|
|
"String edits stay local to the shell until the backend write contract is wired.",
|
|
),
|
|
);
|
|
const [connectionDialogOpen, setConnectionDialogOpen] = useState(false);
|
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
|
const [isConnectionTestRunning, setIsConnectionTestRunning] = useState(false);
|
|
const [isCommandRunning, setIsCommandRunning] = useState(false);
|
|
const [isBrowseLoading, setIsBrowseLoading] = useState(false);
|
|
const [isInspectorLoading, setIsInspectorLoading] = useState(false);
|
|
const [isSaveRunning, setIsSaveRunning] = useState(false);
|
|
const [isTtlUpdateRunning, setIsTtlUpdateRunning] = useState(false);
|
|
const [copyFeedback, setCopyFeedback] = useState<"idle" | "copied" | "unavailable">("idle");
|
|
const [liveKeys, setLiveKeys] = useState<KeyRecord[]>([]);
|
|
const [liveNextCursor, setLiveNextCursor] = useState("0");
|
|
const [liveHasMore, setLiveHasMore] = useState(false);
|
|
const [selectedValueRecord, setSelectedValueRecord] = useState<RedisValueRecord | null>(null);
|
|
const [ttlDraft, setTtlDraft] = useState("");
|
|
const [connectionDraft, setConnectionDraft] = useState<ConnectionDraft>({
|
|
name: "redis-staging-preview",
|
|
host: "10.42.8.12",
|
|
port: "6379",
|
|
database: "0",
|
|
user: "readonly",
|
|
password: "",
|
|
});
|
|
const deferredSearch = useDeferredValue(search);
|
|
const tauriAvailable = isTauriRuntimeAvailable();
|
|
const appVersion = __APP_VERSION__;
|
|
const buildMode = import.meta.env.DEV ? "dev" : "production";
|
|
const runtimeMode = tauriAvailable ? "live desktop bridge" : "browser demo fallback";
|
|
const showNeutralBanner = (message: string) => setBanner(createNeutralNotice(message));
|
|
const showAccentBanner = (message: string) => setBanner(createAccentNotice(message));
|
|
const showBackendErrorBanner = (error: unknown, context: BackendErrorContext) =>
|
|
setBanner(toBackendErrorNotice(error, context));
|
|
|
|
const activeConnection =
|
|
connections.find((connection) => connection.id === activeConnectionId) ??
|
|
connections[0];
|
|
|
|
const searchState = resolveKeyBrowserSearchState(search);
|
|
const deferredSearchState = resolveKeyBrowserSearchState(deferredSearch);
|
|
const filteredDemoKeys = filterWorkspaceKeys(demoKeys, deferredSearchState.trimmedQuery);
|
|
const filteredKeys = tauriAvailable ? liveKeys : filteredDemoKeys;
|
|
const draftErrors = validateConnectionDraft(connectionDraft, connections);
|
|
const draftErrorList = Object.values(draftErrors);
|
|
|
|
const selectedKey = resolveSelectedWorkspaceKey(filteredKeys, selectedKeyId);
|
|
|
|
const isEditable = tauriAvailable
|
|
? selectedValueRecord?.write_capability === "replace_string" &&
|
|
selectedValueRecord.data.kind === "string"
|
|
: selectedKey?.editable === true && selectedKey.type === "string";
|
|
|
|
const inspectorText = tauriAvailable
|
|
? formatInspectorValue(selectedValueRecord, selectedKey, isInspectorLoading)
|
|
: (selectedKey?.value ?? "");
|
|
const structuredInspectorSections = tauriAvailable
|
|
? buildStructuredInspectorSections(selectedValueRecord)
|
|
: [];
|
|
const showStructuredInspector =
|
|
selectedKey !== null &&
|
|
!isInspectorLoading &&
|
|
structuredInspectorSections.length > 0 &&
|
|
!isEditable;
|
|
|
|
const supportNote = tauriAvailable
|
|
? buildLiveSupportNote(selectedValueRecord, selectedKey)
|
|
: (selectedKey?.summary ??
|
|
`Search currently returns zero keys in ${activeDb}. This is an intentional empty state, not a backend failure.`);
|
|
const ttlWriteSupported = tauriAvailable
|
|
? selectedValueRecord?.ttl_write_supported === true
|
|
: selectedKey !== null;
|
|
const localSmokeFixtureActive = isLocalSmokeFixtureConnection(activeConnection);
|
|
const localSmokeFilterActive = search.trim() === localSmokeFixtureSearchText;
|
|
const ttlDraftError = ttlDraft.trim() ? validateTtlDraft(ttlDraft) : null;
|
|
const canSubmitTtlUpdate =
|
|
selectedKey !== null &&
|
|
ttlWriteSupported &&
|
|
ttlDraft.trim() !== "" &&
|
|
ttlDraftError === null &&
|
|
!isTtlUpdateRunning;
|
|
const canPersistTtl =
|
|
selectedKey !== null &&
|
|
ttlWriteSupported &&
|
|
!isTtlUpdateRunning &&
|
|
(tauriAvailable
|
|
? selectedValueRecord?.metadata.ttl.kind !== "persistent"
|
|
: selectedKey?.ttl !== "persistent");
|
|
const latestConsoleEntry = consoleHistory[0] ?? null;
|
|
const keyBrowserStatusMessage = buildKeyBrowserStatusMessage({
|
|
databaseLabel: activeDb,
|
|
visibleCount: filteredKeys.length,
|
|
hasMore: tauriAvailable && liveHasMore,
|
|
isLoading: isBrowseLoading,
|
|
search: deferredSearchState,
|
|
});
|
|
const keyBrowserEmptyStateMessage = buildKeyBrowserEmptyStateMessage({
|
|
databaseLabel: activeDb,
|
|
isLoading: isBrowseLoading,
|
|
search: deferredSearchState,
|
|
});
|
|
const inspectorEmptyStateMessage = buildInspectorEmptyStateMessage(
|
|
activeDb,
|
|
deferredSearchState,
|
|
);
|
|
const qaSnapshot = buildQaSnapshot({
|
|
appName: bootstrap.app_name,
|
|
appVersion,
|
|
runtimeMode,
|
|
buildMode,
|
|
connectionName: activeConnection.name,
|
|
endpoint: formatEndpoint(activeConnection.target),
|
|
databaseLabel: activeDb,
|
|
smokeFixtureActive: localSmokeFixtureActive,
|
|
smokeFilter: localSmokeFilterActive ? localSmokeFixtureSearchText : search,
|
|
selectedKeyName: selectedKey?.name ?? null,
|
|
selectedKeyType: selectedKey?.type ?? null,
|
|
selectedKeyTtl: selectedKey?.ttl ?? null,
|
|
browseVisibleCount: `${filteredKeys.length}${tauriAvailable && liveHasMore ? "+" : ""}`,
|
|
latestCommand: latestConsoleEntry?.command ?? null,
|
|
latestCommandSource: latestConsoleEntry?.source ?? null,
|
|
latestCommandStatus: latestConsoleEntry?.status ?? null,
|
|
bannerMessage: banner.message,
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (!tauriAvailable) {
|
|
startTransition(() => {
|
|
setBootstrap(fallbackBootstrap);
|
|
});
|
|
return;
|
|
}
|
|
|
|
let active = true;
|
|
|
|
invoke<BackendBootstrap>("backend_bootstrap")
|
|
.then((result) => {
|
|
if (!active) {
|
|
return;
|
|
}
|
|
|
|
startTransition(() => {
|
|
setBootstrap(result);
|
|
});
|
|
})
|
|
.catch(() => {
|
|
if (!active) {
|
|
return;
|
|
}
|
|
|
|
startTransition(() => {
|
|
setBootstrap(fallbackBootstrap);
|
|
});
|
|
});
|
|
|
|
return () => {
|
|
active = false;
|
|
};
|
|
}, [tauriAvailable]);
|
|
|
|
useEffect(() => {
|
|
if (!tauriAvailable) {
|
|
startTransition(() => {
|
|
setLiveKeys([]);
|
|
setLiveNextCursor("0");
|
|
setLiveHasMore(false);
|
|
setSelectedValueRecord(null);
|
|
setDraftValue(selectedKey?.value ?? "");
|
|
setTtlDraft("");
|
|
});
|
|
return;
|
|
}
|
|
|
|
let active = true;
|
|
const request: RedisKeyBrowseRequest = {
|
|
connection: toRedisConnectionRequest(activeConnection, activeDb),
|
|
cursor: "0",
|
|
pattern: deferredSearchState.requestPattern,
|
|
page_size: browsePageSize,
|
|
};
|
|
|
|
startTransition(() => {
|
|
setIsBrowseLoading(true);
|
|
setLiveKeys([]);
|
|
setLiveNextCursor("0");
|
|
setLiveHasMore(false);
|
|
setSelectedValueRecord(null);
|
|
setDraftValue("");
|
|
setTtlDraft("");
|
|
});
|
|
|
|
invoke<RedisKeyBrowseResult>("browse_redis_keys", { request })
|
|
.then((result) => {
|
|
if (!active) {
|
|
return;
|
|
}
|
|
|
|
const nextKeys = result.keys.map(toLiveKeyRecordFromMetadata);
|
|
startTransition(() => {
|
|
setLiveKeys(nextKeys);
|
|
setLiveNextCursor(result.next_cursor);
|
|
setLiveHasMore(result.has_more);
|
|
setSelectedKeyId((current) => resolveSelectedWorkspaceKeyId(nextKeys, current));
|
|
setIsBrowseLoading(false);
|
|
});
|
|
})
|
|
.catch((error) => {
|
|
if (!active) {
|
|
return;
|
|
}
|
|
|
|
startTransition(() => {
|
|
setLiveKeys([]);
|
|
setLiveNextCursor("0");
|
|
setLiveHasMore(false);
|
|
setSelectedKeyId(null);
|
|
setSelectedValueRecord(null);
|
|
setDraftValue("");
|
|
setTtlDraft("");
|
|
setIsBrowseLoading(false);
|
|
showBackendErrorBanner(error, {
|
|
operation: "key_browse",
|
|
connectionName: activeConnection.name,
|
|
databaseLabel: activeDb,
|
|
});
|
|
});
|
|
});
|
|
|
|
return () => {
|
|
active = false;
|
|
};
|
|
}, [activeConnectionId, activeDb, deferredSearchState.requestPattern, tauriAvailable]);
|
|
|
|
useEffect(() => {
|
|
if (!tauriAvailable) {
|
|
startTransition(() => {
|
|
setSelectedValueRecord(null);
|
|
setDraftValue(selectedKey?.value ?? "");
|
|
setTtlDraft("");
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (!selectedKey) {
|
|
startTransition(() => {
|
|
setSelectedValueRecord(null);
|
|
setDraftValue("");
|
|
setTtlDraft("");
|
|
setIsInspectorLoading(false);
|
|
});
|
|
return;
|
|
}
|
|
|
|
let active = true;
|
|
const request: RedisValueReadRequest = {
|
|
connection: toRedisConnectionRequest(activeConnection, activeDb),
|
|
key: selectedKey.name,
|
|
};
|
|
|
|
startTransition(() => {
|
|
setSelectedValueRecord(null);
|
|
setDraftValue("");
|
|
setTtlDraft("");
|
|
setIsInspectorLoading(true);
|
|
});
|
|
|
|
invoke<RedisValueRecord>("read_redis_value", { request })
|
|
.then((record) => {
|
|
if (!active) {
|
|
return;
|
|
}
|
|
|
|
const nextRecord = toLiveKeyRecordFromValue(record);
|
|
startTransition(() => {
|
|
setSelectedValueRecord(record);
|
|
setLiveKeys((current) => replaceWorkspaceKeyRecord(current, nextRecord));
|
|
setDraftValue(
|
|
record.data.kind === "string" && record.write_capability === "replace_string"
|
|
? formatRedisValueContent(record.data.value)
|
|
: formatRedisValueData(record.data),
|
|
);
|
|
setTtlDraft(record.metadata.ttl.kind === "expires_in_millis" ? String(record.metadata.ttl.value) : "");
|
|
setIsInspectorLoading(false);
|
|
});
|
|
})
|
|
.catch((error) => {
|
|
if (!active) {
|
|
return;
|
|
}
|
|
|
|
startTransition(() => {
|
|
setSelectedValueRecord(null);
|
|
setDraftValue("");
|
|
setTtlDraft("");
|
|
setIsInspectorLoading(false);
|
|
showBackendErrorBanner(error, {
|
|
operation: "value_read",
|
|
connectionName: activeConnection.name,
|
|
databaseLabel: activeDb,
|
|
keyName: selectedKey.name,
|
|
});
|
|
});
|
|
});
|
|
|
|
return () => {
|
|
active = false;
|
|
};
|
|
}, [activeConnectionId, activeDb, selectedKey?.id, tauriAvailable]);
|
|
|
|
const handleConnectionDraftChange =
|
|
(field: keyof typeof connectionDraft) =>
|
|
(event: ChangeEvent<HTMLInputElement>) => {
|
|
const value = event.target.value;
|
|
|
|
setConnectionDraft((current) => ({
|
|
...current,
|
|
[field]: value,
|
|
}));
|
|
};
|
|
|
|
const handleSelectConnection = (connection: ConnectionProfile) => {
|
|
startTransition(() => {
|
|
setActiveConnectionId(connection.id);
|
|
setActiveDb(formatDatabaseLabel(connection.target.database));
|
|
setSelectedKeyId(null);
|
|
showNeutralBanner(
|
|
`Switched workspace context to ${connection.name} on ${formatDatabaseLabel(connection.target.database)}.`,
|
|
);
|
|
});
|
|
};
|
|
|
|
const handleSelectDatabase = (database: string) => {
|
|
const nextDatabase = parseDatabaseLabel(database);
|
|
|
|
startTransition(() => {
|
|
setActiveDb(database);
|
|
setSelectedKeyId(null);
|
|
setConnections((current) =>
|
|
current.map((connection) =>
|
|
connection.id === activeConnectionId
|
|
? {
|
|
...connection,
|
|
target: {
|
|
...connection.target,
|
|
database: nextDatabase,
|
|
},
|
|
}
|
|
: connection,
|
|
),
|
|
);
|
|
showNeutralBanner(`Scoped the workspace to ${database} for ${activeConnection.name}.`);
|
|
});
|
|
};
|
|
|
|
const handleUseLocalSmokeFixture = () => {
|
|
const existingFixture = connections.find(isLocalSmokeFixtureConnection);
|
|
const fixtureConnection =
|
|
existingFixture ??
|
|
{
|
|
id: localSmokeFixtureProfileId,
|
|
name: localSmokeFixtureProfileId,
|
|
environment: "local" as const,
|
|
detail: "Fixture profile",
|
|
status: "idle" as const,
|
|
password: "",
|
|
target: {
|
|
host: "127.0.0.1",
|
|
port: 6380,
|
|
database: 0,
|
|
username: null,
|
|
tls_mode: "disabled" as const,
|
|
},
|
|
};
|
|
|
|
startTransition(() => {
|
|
if (!existingFixture) {
|
|
setConnections((current) => [fixtureConnection, ...current]);
|
|
}
|
|
setActiveConnectionId(fixtureConnection.id);
|
|
setActiveDb(formatDatabaseLabel(fixtureConnection.target.database));
|
|
setSelectedKeyId(null);
|
|
setSearch(localSmokeFixtureSearchText);
|
|
setConsoleInput(localSmokeFixtureCommandPresets[0].command);
|
|
showAccentBanner(
|
|
"Loaded the local smoke fixture profile, narrowed the browser to smoke:* keys, and primed the command tray with PING.",
|
|
);
|
|
});
|
|
};
|
|
|
|
const handleFocusLocalSmokeKeys = () => {
|
|
startTransition(() => {
|
|
setSearch(localSmokeFixtureSearchText);
|
|
setSelectedKeyId(null);
|
|
showNeutralBanner(
|
|
`Focused the browser on ${localSmokeFixtureSearchText} for local smoke verification in ${activeDb}.`,
|
|
);
|
|
});
|
|
};
|
|
|
|
const handleApplyLocalSmokeCommandPreset = (command: string) => {
|
|
startTransition(() => {
|
|
setConsoleInput(command);
|
|
showNeutralBanner(
|
|
`Loaded ${command} into the command tray so QA can execute the next safe smoke step without retyping.`,
|
|
);
|
|
});
|
|
};
|
|
|
|
const handleCreateDraftConnection = () => {
|
|
if (draftErrorList.length > 0) {
|
|
showNeutralBanner(`Connection draft is incomplete: ${draftErrorList[0]}`);
|
|
return;
|
|
}
|
|
|
|
const nextDatabase = parseDatabaseInput(connectionDraft.database);
|
|
const nextName = connectionDraft.name.trim();
|
|
const nextConnection: ConnectionProfile = {
|
|
id: nextName,
|
|
name: nextName,
|
|
environment: "dev",
|
|
detail: "Draft profile",
|
|
status: "idle",
|
|
password: connectionDraft.password,
|
|
target: {
|
|
host: connectionDraft.host.trim() || "127.0.0.1",
|
|
port: parsePortInput(connectionDraft.port),
|
|
database: nextDatabase,
|
|
username: normalizeOptionalString(connectionDraft.user),
|
|
tls_mode: "disabled",
|
|
},
|
|
};
|
|
|
|
startTransition(() => {
|
|
setConnections((current) => [nextConnection, ...current]);
|
|
setActiveConnectionId(nextConnection.id);
|
|
setActiveDb(formatDatabaseLabel(nextDatabase));
|
|
setSelectedKeyId(null);
|
|
showAccentBanner(
|
|
"A draft connection profile was added to the shell. Credentials remain memory-only.",
|
|
);
|
|
setConnectionDialogOpen(false);
|
|
});
|
|
};
|
|
|
|
const handleSaveString = async () => {
|
|
if (!selectedKey) {
|
|
showNeutralBanner(`Select a key in ${activeDb} before attempting a shell-level save.`);
|
|
return;
|
|
}
|
|
|
|
if (!tauriAvailable) {
|
|
startTransition(() => {
|
|
setDemoKeys((current) => updateDemoStringValue(current, selectedKey.name, draftValue));
|
|
showNeutralBanner(
|
|
`Saved session-local mock string value for ${selectedKey.name} in ${activeDb}. Backend persistence is still unavailable in browser demo mode.`,
|
|
);
|
|
});
|
|
return;
|
|
}
|
|
|
|
startTransition(() => {
|
|
setIsSaveRunning(true);
|
|
});
|
|
|
|
try {
|
|
const result = await invoke<RedisValueRecord>("write_redis_value", {
|
|
request: {
|
|
connection: toRedisConnectionRequest(activeConnection, activeDb),
|
|
key: selectedKey.name,
|
|
value: {
|
|
kind: "string",
|
|
value: draftValue,
|
|
},
|
|
} satisfies RedisValueWriteRequest,
|
|
});
|
|
|
|
const nextRecord = toLiveKeyRecordFromValue(result);
|
|
startTransition(() => {
|
|
setSelectedValueRecord(result);
|
|
setLiveKeys((current) => replaceWorkspaceKeyRecord(current, nextRecord));
|
|
setDraftValue(
|
|
result.data.kind === "string"
|
|
? formatRedisValueContent(result.data.value)
|
|
: formatRedisValueData(result.data),
|
|
);
|
|
setIsSaveRunning(false);
|
|
showAccentBanner(
|
|
`Saved live string value for ${selectedKey.name} in ${activeDb} and preserved the current TTL boundary.`,
|
|
);
|
|
});
|
|
} catch (error) {
|
|
startTransition(() => {
|
|
setIsSaveRunning(false);
|
|
showBackendErrorBanner(error, {
|
|
operation: "value_write",
|
|
connectionName: activeConnection.name,
|
|
databaseLabel: activeDb,
|
|
keyName: selectedKey.name,
|
|
});
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleDeleteKey = () => {
|
|
if (!selectedKey) {
|
|
return;
|
|
}
|
|
|
|
setDeleteDialogOpen(false);
|
|
showNeutralBanner(
|
|
`Delete confirmation completed for ${selectedKey.name} in ${activeDb}. Mutation stays mocked until a delete contract is approved.`,
|
|
);
|
|
};
|
|
|
|
const handleApplyTtl = async () => {
|
|
if (!selectedKey) {
|
|
showNeutralBanner(`Select a key in ${activeDb} before attempting a TTL update.`);
|
|
return;
|
|
}
|
|
|
|
const validationError = validateTtlDraft(ttlDraft);
|
|
if (validationError) {
|
|
showNeutralBanner(`TTL update is invalid: ${validationError}`);
|
|
return;
|
|
}
|
|
|
|
if (!tauriAvailable) {
|
|
startTransition(() => {
|
|
setDemoKeys((current) =>
|
|
updateDemoKeyTtl(current, selectedKey.name, Number.parseInt(ttlDraft, 10)),
|
|
);
|
|
showNeutralBanner(
|
|
`Updated session-local mock TTL for ${selectedKey.name} in ${activeDb}. Use desktop runtime for a live TTL change.`,
|
|
);
|
|
});
|
|
return;
|
|
}
|
|
|
|
startTransition(() => {
|
|
setIsTtlUpdateRunning(true);
|
|
});
|
|
|
|
try {
|
|
const result = await invoke<RedisKeyTtlUpdateResult>("update_redis_key_ttl", {
|
|
request: {
|
|
connection: toRedisConnectionRequest(activeConnection, activeDb),
|
|
key: selectedKey.name,
|
|
operation: {
|
|
kind: "expires_in_millis",
|
|
value: Number.parseInt(ttlDraft, 10),
|
|
},
|
|
} satisfies RedisKeyTtlUpdateRequest,
|
|
});
|
|
|
|
startTransition(() => {
|
|
syncUpdatedMetadata(result.metadata);
|
|
setTtlDraft(
|
|
result.metadata.ttl.kind === "expires_in_millis"
|
|
? String(result.metadata.ttl.value)
|
|
: "",
|
|
);
|
|
setIsTtlUpdateRunning(false);
|
|
showAccentBanner(
|
|
`Updated live TTL for ${selectedKey.name} to ${formatRedisKeyTtl(result.metadata.ttl)} in ${activeDb}.`,
|
|
);
|
|
});
|
|
} catch (error) {
|
|
startTransition(() => {
|
|
setIsTtlUpdateRunning(false);
|
|
showBackendErrorBanner(error, {
|
|
operation: "ttl_update",
|
|
connectionName: activeConnection.name,
|
|
databaseLabel: activeDb,
|
|
keyName: selectedKey.name,
|
|
});
|
|
});
|
|
}
|
|
};
|
|
|
|
const handlePersistTtl = async () => {
|
|
if (!selectedKey) {
|
|
showNeutralBanner(`Select a key in ${activeDb} before attempting to remove TTL.`);
|
|
return;
|
|
}
|
|
|
|
if (!tauriAvailable) {
|
|
startTransition(() => {
|
|
setDemoKeys((current) => persistDemoKeyTtl(current, selectedKey.name));
|
|
setTtlDraft("");
|
|
showNeutralBanner(
|
|
`Removed session-local mock TTL for ${selectedKey.name} in ${activeDb}. Use desktop runtime for a live TTL change.`,
|
|
);
|
|
});
|
|
return;
|
|
}
|
|
|
|
startTransition(() => {
|
|
setIsTtlUpdateRunning(true);
|
|
});
|
|
|
|
try {
|
|
const result = await invoke<RedisKeyTtlUpdateResult>("update_redis_key_ttl", {
|
|
request: {
|
|
connection: toRedisConnectionRequest(activeConnection, activeDb),
|
|
key: selectedKey.name,
|
|
operation: {
|
|
kind: "persist",
|
|
},
|
|
} satisfies RedisKeyTtlUpdateRequest,
|
|
});
|
|
|
|
startTransition(() => {
|
|
syncUpdatedMetadata(result.metadata);
|
|
setTtlDraft("");
|
|
setIsTtlUpdateRunning(false);
|
|
showAccentBanner(
|
|
`Removed TTL for ${selectedKey.name}; the key is now ${formatRedisKeyTtl(result.metadata.ttl)}.`,
|
|
);
|
|
});
|
|
} catch (error) {
|
|
startTransition(() => {
|
|
setIsTtlUpdateRunning(false);
|
|
showBackendErrorBanner(error, {
|
|
operation: "ttl_remove",
|
|
connectionName: activeConnection.name,
|
|
databaseLabel: activeDb,
|
|
keyName: selectedKey.name,
|
|
});
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleRefreshInspector = async () => {
|
|
if (!selectedKey) {
|
|
showNeutralBanner(
|
|
`No key is selected in ${activeDb}. Adjust or clear the current search pattern first.`,
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (!tauriAvailable) {
|
|
showNeutralBanner(
|
|
`Refreshed shell metadata for ${selectedKey.name} in ${activeDb}. Live value reload remains downstream of backend browse contracts.`,
|
|
);
|
|
return;
|
|
}
|
|
|
|
startTransition(() => {
|
|
setIsInspectorLoading(true);
|
|
setSelectedValueRecord(null);
|
|
setDraftValue("");
|
|
});
|
|
|
|
try {
|
|
const result = await invoke<RedisValueRecord>("read_redis_value", {
|
|
request: {
|
|
connection: toRedisConnectionRequest(activeConnection, activeDb),
|
|
key: selectedKey.name,
|
|
} satisfies RedisValueReadRequest,
|
|
});
|
|
|
|
const nextRecord = toLiveKeyRecordFromValue(result);
|
|
startTransition(() => {
|
|
setSelectedValueRecord(result);
|
|
setLiveKeys((current) => replaceWorkspaceKeyRecord(current, nextRecord));
|
|
setDraftValue(
|
|
result.data.kind === "string" && result.write_capability === "replace_string"
|
|
? formatRedisValueContent(result.data.value)
|
|
: formatRedisValueData(result.data),
|
|
);
|
|
setTtlDraft(result.metadata.ttl.kind === "expires_in_millis" ? String(result.metadata.ttl.value) : "");
|
|
setIsInspectorLoading(false);
|
|
showAccentBanner(
|
|
`Refreshed live metadata and value surface for ${selectedKey.name} in ${activeDb}.`,
|
|
);
|
|
});
|
|
} catch (error) {
|
|
startTransition(() => {
|
|
setIsInspectorLoading(false);
|
|
showBackendErrorBanner(error, {
|
|
operation: "inspector_refresh",
|
|
connectionName: activeConnection.name,
|
|
databaseLabel: activeDb,
|
|
keyName: selectedKey.name,
|
|
});
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleCopyQaSnapshot = async () => {
|
|
if (typeof navigator === "undefined" || !navigator.clipboard?.writeText) {
|
|
setCopyFeedback("unavailable");
|
|
showNeutralBanner(
|
|
"Clipboard export is unavailable in this runtime. Copy the QA snapshot text manually from the side rail.",
|
|
);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await navigator.clipboard.writeText(qaSnapshot);
|
|
setCopyFeedback("copied");
|
|
showAccentBanner("Copied the current QA snapshot to the clipboard for smoke evidence.");
|
|
} catch {
|
|
setCopyFeedback("unavailable");
|
|
showNeutralBanner(
|
|
"Clipboard export failed in this runtime. Copy the QA snapshot text manually from the side rail.",
|
|
);
|
|
}
|
|
};
|
|
|
|
const syncUpdatedMetadata = (metadata: RedisKeyMetadata) => {
|
|
if (selectedValueRecord) {
|
|
const nextRecord = {
|
|
...selectedValueRecord,
|
|
metadata,
|
|
};
|
|
setSelectedValueRecord(nextRecord);
|
|
setLiveKeys((current) =>
|
|
replaceWorkspaceKeyRecord(current, toLiveKeyRecordFromValue(nextRecord)),
|
|
);
|
|
return;
|
|
}
|
|
|
|
setLiveKeys((current) =>
|
|
replaceWorkspaceKeyRecord(current, toLiveKeyRecordFromMetadata(metadata)),
|
|
);
|
|
};
|
|
|
|
const handleLoadMoreKeys = async () => {
|
|
if (!tauriAvailable || !liveHasMore || isBrowseLoading) {
|
|
return;
|
|
}
|
|
|
|
startTransition(() => {
|
|
setIsBrowseLoading(true);
|
|
});
|
|
|
|
try {
|
|
const result = await invoke<RedisKeyBrowseResult>("browse_redis_keys", {
|
|
request: {
|
|
connection: toRedisConnectionRequest(activeConnection, activeDb),
|
|
cursor: liveNextCursor,
|
|
pattern: deferredSearchState.requestPattern,
|
|
page_size: browsePageSize,
|
|
} satisfies RedisKeyBrowseRequest,
|
|
});
|
|
|
|
const nextKeys = result.keys.map(toLiveKeyRecordFromMetadata);
|
|
startTransition(() => {
|
|
setLiveKeys((current) => appendWorkspaceKeyRecords(current, nextKeys));
|
|
setLiveNextCursor(result.next_cursor);
|
|
setLiveHasMore(result.has_more);
|
|
setIsBrowseLoading(false);
|
|
});
|
|
} catch (error) {
|
|
startTransition(() => {
|
|
setIsBrowseLoading(false);
|
|
showBackendErrorBanner(error, {
|
|
operation: "key_browse",
|
|
connectionName: activeConnection.name,
|
|
databaseLabel: activeDb,
|
|
});
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleTestConnection = async () => {
|
|
const connection = activeConnection;
|
|
const request = toRedisConnectionRequest(connection, activeDb);
|
|
|
|
startTransition(() => {
|
|
setIsConnectionTestRunning(true);
|
|
setConnections((current) =>
|
|
current.map((item) =>
|
|
item.id === connection.id
|
|
? {
|
|
...item,
|
|
status: "checking",
|
|
detail: "Live test in progress",
|
|
}
|
|
: item,
|
|
),
|
|
);
|
|
showNeutralBanner(
|
|
`Testing ${connection.name} against ${activeDb} using the current desktop contract.`,
|
|
);
|
|
});
|
|
|
|
if (!tauriAvailable) {
|
|
startTransition(() => {
|
|
setConnections((current) =>
|
|
current.map((item) =>
|
|
item.id === connection.id
|
|
? {
|
|
...item,
|
|
status: "idle",
|
|
detail: "Demo fallback",
|
|
}
|
|
: item,
|
|
),
|
|
);
|
|
showNeutralBanner(
|
|
`Tauri runtime is unavailable in browser dev mode, so connection testing stayed in demo fallback for ${connection.name}.`,
|
|
);
|
|
setIsConnectionTestRunning(false);
|
|
});
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const result = await invoke<ConnectionTestResult>("test_redis_connection", {
|
|
request,
|
|
});
|
|
|
|
startTransition(() => {
|
|
setConnections((current) =>
|
|
current.map((item) =>
|
|
item.id === connection.id
|
|
? {
|
|
...item,
|
|
status: "healthy",
|
|
detail: result.round_trip_status,
|
|
target: {
|
|
...item.target,
|
|
database: result.selected_database,
|
|
username: result.authenticated_as ?? item.target.username,
|
|
},
|
|
}
|
|
: item,
|
|
),
|
|
);
|
|
setActiveDb(formatDatabaseLabel(result.selected_database));
|
|
showAccentBanner(
|
|
`Live Redis test passed for ${connection.name} on ${formatDatabaseLabel(result.selected_database)}. Round-trip status: ${result.round_trip_status}.`,
|
|
);
|
|
setIsConnectionTestRunning(false);
|
|
});
|
|
} catch (error) {
|
|
startTransition(() => {
|
|
setConnections((current) =>
|
|
current.map((item) =>
|
|
item.id === connection.id
|
|
? {
|
|
...item,
|
|
status: "error",
|
|
detail: formatBackendErrorCodeLabel(toBackendError(error).code),
|
|
}
|
|
: item,
|
|
),
|
|
);
|
|
showBackendErrorBanner(error, {
|
|
operation: "connection_test",
|
|
connectionName: connection.name,
|
|
databaseLabel: activeDb,
|
|
});
|
|
setIsConnectionTestRunning(false);
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleConsoleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
|
event.preventDefault();
|
|
|
|
const trimmed = consoleInput.trim();
|
|
if (!trimmed) {
|
|
return;
|
|
}
|
|
|
|
const tokens = tokenizeCommand(trimmed);
|
|
if (tokens.length === 0) {
|
|
return;
|
|
}
|
|
|
|
const [command, ...argumentsList] = tokens;
|
|
const upper = command.toUpperCase();
|
|
const blocked = upper.includes("FLUSH") || upper.includes("CONFIG") || upper.includes("SHUTDOWN");
|
|
if (blocked) {
|
|
startTransition(() => {
|
|
setConsoleHistory((current) => [
|
|
{
|
|
command: trimmed,
|
|
output: "Blocked in V1. Destructive or administrative commands remain outside the visible shell.",
|
|
status: "blocked",
|
|
source: "mock",
|
|
},
|
|
...current.slice(0, 4),
|
|
]);
|
|
showNeutralBanner(
|
|
`Blocked ${command.toUpperCase()} in ${activeDb}. Administrative commands remain outside the visible shell.`,
|
|
);
|
|
});
|
|
return;
|
|
}
|
|
|
|
startTransition(() => {
|
|
setIsCommandRunning(true);
|
|
});
|
|
|
|
if (!tauriAvailable) {
|
|
startTransition(() => {
|
|
setConsoleHistory((current) => [
|
|
{
|
|
command: trimmed,
|
|
output: buildMockCommandOutput(command, selectedKey),
|
|
status: "ok",
|
|
source: "mock",
|
|
},
|
|
...current.slice(0, 4),
|
|
]);
|
|
showNeutralBanner(
|
|
`Tauri runtime is unavailable in browser dev mode, so ${command.toUpperCase()} ran against the visible demo data only.`,
|
|
);
|
|
setConsoleInput(trimmed);
|
|
setIsCommandRunning(false);
|
|
});
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const result = await invoke<CommandExecutionResult>("execute_redis_command", {
|
|
request: {
|
|
connection: toRedisConnectionRequest(activeConnection, activeDb),
|
|
command,
|
|
arguments: argumentsList,
|
|
} satisfies RedisCommandRequest,
|
|
});
|
|
|
|
startTransition(() => {
|
|
setConsoleHistory((current) => [
|
|
{
|
|
command: [result.command, ...result.arguments].join(" "),
|
|
output: formatRedisResponse(result.response),
|
|
status: "ok",
|
|
source: "live",
|
|
},
|
|
...current.slice(0, 4),
|
|
]);
|
|
showAccentBanner(
|
|
`Live Redis command completed on ${activeConnection.name} / ${formatDatabaseLabel(result.database)}.`,
|
|
);
|
|
setConsoleInput(trimmed);
|
|
setIsCommandRunning(false);
|
|
});
|
|
} catch (error) {
|
|
startTransition(() => {
|
|
setConsoleHistory((current) => [
|
|
{
|
|
command: trimmed,
|
|
output: toBackendErrorNotice(error, {
|
|
operation: "command_execution",
|
|
connectionName: activeConnection.name,
|
|
databaseLabel: activeDb,
|
|
commandName: command,
|
|
}).message,
|
|
status: "error",
|
|
source: "live",
|
|
},
|
|
...current.slice(0, 4),
|
|
]);
|
|
showBackendErrorBanner(error, {
|
|
operation: "command_execution",
|
|
connectionName: activeConnection.name,
|
|
databaseLabel: activeDb,
|
|
commandName: command,
|
|
});
|
|
setConsoleInput(trimmed);
|
|
setIsCommandRunning(false);
|
|
});
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="min-h-screen bg-[var(--surface-base)] text-[var(--ink-0)]">
|
|
<div className="mx-auto flex min-h-screen max-w-[1600px] flex-col px-4 py-4 sm:px-6 lg:px-8">
|
|
<header className="mb-4 flex flex-col gap-3 rounded-[28px] border border-[var(--line-soft)] bg-[var(--surface-raised)] px-5 py-4 shadow-[var(--shadow-panel)] lg:flex-row lg:items-center lg:justify-between">
|
|
<div className="flex items-start gap-3">
|
|
<div className="mt-0.5 rounded-2xl border border-[var(--line-strong)] bg-[var(--surface-strong)] p-3 text-[var(--accent-strong)]">
|
|
<PanelLeft className="h-5 w-5" />
|
|
</div>
|
|
<div>
|
|
<p className="font-mono text-xs uppercase tracking-[0.28em] text-[var(--ink-muted)]">
|
|
{bootstrap.app_name}
|
|
</p>
|
|
<h1 className="mt-1 text-2xl font-semibold tracking-[-0.03em]">
|
|
Quiet desktop workspace for Redis inspection and low-risk edits
|
|
</h1>
|
|
<div className="mt-3 flex flex-wrap gap-2">
|
|
<Badge variant="neutral">v{appVersion}</Badge>
|
|
<Badge variant={tauriAvailable ? "accent" : "neutral"}>{runtimeMode}</Badge>
|
|
<Badge variant="neutral">{buildMode}</Badge>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid gap-2 text-sm text-[var(--ink-soft)] sm:grid-cols-2 xl:grid-cols-4">
|
|
<StatusPill
|
|
icon={<PlugZap className="h-4 w-4" />}
|
|
label="Connection"
|
|
value={activeConnection.name}
|
|
/>
|
|
<StatusPill
|
|
icon={<Database className="h-4 w-4" />}
|
|
label="Database"
|
|
value={activeDb}
|
|
/>
|
|
<StatusPill
|
|
icon={<Activity className="h-4 w-4" />}
|
|
label="Status"
|
|
value={formatConnectionStatus(activeConnection)}
|
|
/>
|
|
<StatusPill
|
|
icon={<PanelLeft className="h-4 w-4" />}
|
|
label="Build"
|
|
value={`v${appVersion}`}
|
|
/>
|
|
</div>
|
|
</header>
|
|
|
|
<div
|
|
className={cn(
|
|
"mb-4 rounded-[24px] border px-4 py-3 shadow-[var(--shadow-panel)]",
|
|
banner.tone === "danger"
|
|
? "border-[var(--danger-soft)] bg-[var(--danger-faint)]"
|
|
: banner.tone === "accent"
|
|
? "border-[var(--accent-strong)] bg-[var(--accent-faint)]"
|
|
: "border-[var(--line-soft)] bg-[var(--surface-raised)]",
|
|
)}
|
|
>
|
|
<p
|
|
className={cn(
|
|
"text-sm",
|
|
banner.tone === "danger"
|
|
? "text-[var(--danger-strong)]"
|
|
: banner.tone === "accent"
|
|
? "text-[var(--accent-strong)]"
|
|
: "text-[var(--ink-soft)]",
|
|
)}
|
|
>
|
|
{banner.message}
|
|
</p>
|
|
</div>
|
|
|
|
<main className="grid min-h-0 flex-1 gap-4 xl:grid-cols-[310px_minmax(360px,1.1fr)_minmax(420px,1fr)]">
|
|
<aside className="rounded-[28px] border border-[var(--line-soft)] bg-[var(--surface-raised)] p-4 shadow-[var(--shadow-panel)]">
|
|
<div className="space-y-5">
|
|
<section>
|
|
<SectionEyebrow icon={<PlugZap className="h-4 w-4" />} label="Connection library" />
|
|
<div className="mt-3 space-y-2">
|
|
{connections.map((connection) => {
|
|
const active = connection.id === activeConnection.id;
|
|
|
|
return (
|
|
<button
|
|
key={connection.id}
|
|
type="button"
|
|
onClick={() => handleSelectConnection(connection)}
|
|
className={cn(
|
|
"w-full rounded-[22px] border px-4 py-3 text-left transition",
|
|
active
|
|
? "border-[var(--accent-strong)] bg-[var(--accent-faint)]"
|
|
: "border-[var(--line-soft)] bg-[var(--surface-base)] hover:border-[var(--line-strong)]",
|
|
)}
|
|
>
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div>
|
|
<p className="text-sm font-medium text-[var(--ink-0)]">{connection.name}</p>
|
|
<p className="mt-1 text-sm text-[var(--ink-soft)]">{formatEndpoint(connection.target)}</p>
|
|
<p className="mt-2 font-mono text-xs uppercase tracking-[0.14em] text-[var(--ink-muted)]">
|
|
{formatConnectionCardDetail(connection)}
|
|
</p>
|
|
</div>
|
|
<Badge variant={active ? "accent" : "neutral"}>{connection.environment}</Badge>
|
|
</div>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
<div className="mt-3 grid grid-cols-2 gap-2">
|
|
<Dialog open={connectionDialogOpen} onOpenChange={setConnectionDialogOpen}>
|
|
<DialogTrigger asChild>
|
|
<Button variant="default" size="sm">
|
|
New
|
|
</Button>
|
|
</DialogTrigger>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<p className="font-mono text-xs uppercase tracking-[0.22em] text-[var(--ink-muted)]">
|
|
Connection draft
|
|
</p>
|
|
<DialogTitle>Create a visible shell profile</DialogTitle>
|
|
<DialogDescription>
|
|
This form demonstrates the entry point and keeps credentials out of persistent storage in the current foundation scope.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="grid gap-3 sm:grid-cols-2">
|
|
<div className="grid gap-2">
|
|
<label className="text-sm font-medium text-[var(--ink-0)]" htmlFor="draft-name">
|
|
Profile name
|
|
</label>
|
|
<Input
|
|
id="draft-name"
|
|
value={connectionDraft.name}
|
|
onChange={handleConnectionDraftChange("name")}
|
|
/>
|
|
</div>
|
|
<div className="grid gap-2">
|
|
<label className="text-sm font-medium text-[var(--ink-0)]" htmlFor="draft-user">
|
|
User
|
|
</label>
|
|
<Input
|
|
id="draft-user"
|
|
value={connectionDraft.user}
|
|
onChange={handleConnectionDraftChange("user")}
|
|
/>
|
|
</div>
|
|
<div className="grid gap-2">
|
|
<label className="text-sm font-medium text-[var(--ink-0)]" htmlFor="draft-host">
|
|
Host
|
|
</label>
|
|
<Input
|
|
id="draft-host"
|
|
value={connectionDraft.host}
|
|
onChange={handleConnectionDraftChange("host")}
|
|
/>
|
|
</div>
|
|
<div className="grid gap-2">
|
|
<label className="text-sm font-medium text-[var(--ink-0)]" htmlFor="draft-port">
|
|
Port
|
|
</label>
|
|
<Input
|
|
id="draft-port"
|
|
value={connectionDraft.port}
|
|
onChange={handleConnectionDraftChange("port")}
|
|
/>
|
|
</div>
|
|
<div className="grid gap-2 sm:col-span-2">
|
|
<label className="text-sm font-medium text-[var(--ink-0)]" htmlFor="draft-db">
|
|
Default DB
|
|
</label>
|
|
<Input
|
|
id="draft-db"
|
|
value={connectionDraft.database}
|
|
onChange={handleConnectionDraftChange("database")}
|
|
/>
|
|
</div>
|
|
<div className="grid gap-2 sm:col-span-2">
|
|
<label className="text-sm font-medium text-[var(--ink-0)]" htmlFor="draft-password">
|
|
Password
|
|
</label>
|
|
<Input
|
|
id="draft-password"
|
|
type="password"
|
|
value={connectionDraft.password}
|
|
onChange={handleConnectionDraftChange("password")}
|
|
placeholder="Optional, memory-only"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="rounded-[22px] border border-[var(--line-soft)] bg-[var(--surface-base)] px-4 py-3 text-sm text-[var(--ink-soft)]">
|
|
Secrets stay memory-only in the foundation phase. This dialog is intentionally limited to a visible draft flow, and TLS remains fixed to disabled until the product scope asks for transport controls.
|
|
</div>
|
|
|
|
{draftErrorList.length > 0 ? (
|
|
<div className="rounded-[22px] border border-[var(--danger-soft)] bg-[var(--danger-faint)] px-4 py-3 text-sm text-[var(--danger-strong)]">
|
|
{draftErrorList[0]}
|
|
</div>
|
|
) : null}
|
|
|
|
<DialogFooter>
|
|
<DialogClose asChild>
|
|
<Button variant="outline">Cancel</Button>
|
|
</DialogClose>
|
|
<Button onClick={handleCreateDraftConnection} disabled={draftErrorList.length > 0}>
|
|
Create draft
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={handleTestConnection}
|
|
disabled={isConnectionTestRunning}
|
|
>
|
|
{isConnectionTestRunning ? "Testing..." : tauriAvailable ? "Test live" : "Test demo"}
|
|
</Button>
|
|
</div>
|
|
</section>
|
|
|
|
<section>
|
|
<SectionEyebrow icon={<Database className="h-4 w-4" />} label="Database switcher" />
|
|
<div className="mt-3 grid gap-2">
|
|
{databases.map((database) => {
|
|
const active = database === activeDb;
|
|
|
|
return (
|
|
<button
|
|
key={database}
|
|
type="button"
|
|
onClick={() => handleSelectDatabase(database)}
|
|
className={cn(
|
|
"flex items-center justify-between rounded-2xl border px-3 py-2 text-left transition",
|
|
active
|
|
? "border-[var(--accent-strong)] bg-[var(--accent-faint)] text-[var(--ink-0)]"
|
|
: "border-[var(--line-soft)] bg-[var(--surface-base)] text-[var(--ink-soft)] hover:border-[var(--line-strong)] hover:text-[var(--ink-0)]",
|
|
)}
|
|
>
|
|
<span className="font-medium">{database}</span>
|
|
<span className="font-mono text-xs">{active ? "active" : "idle"}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</section>
|
|
|
|
<section>
|
|
<SectionEyebrow icon={<ShieldAlert className="h-4 w-4" />} label="Safety posture" />
|
|
<div className="mt-3 space-y-3 rounded-[22px] border border-[var(--line-soft)] bg-[var(--surface-base)] p-4 text-sm text-[var(--ink-soft)]">
|
|
<InfoLine label="Surface" value={bootstrap.surface} />
|
|
<InfoLine label="Connection catalog" value={bootstrap.persistence.connection_catalog} />
|
|
<InfoLine label="Secrets" value={bootstrap.persistence.secrets} />
|
|
<InfoLine label="Command execution" value={bootstrap.command_execution} />
|
|
<InfoLine label="Key browsing" value={bootstrap.key_browsing} />
|
|
<InfoLine label="Next backend milestone" value={bootstrap.next_backend_milestone} />
|
|
<p>Delete and rename stay behind an explicit confirmation step.</p>
|
|
<p>Administrative commands stay blocked in both the UI and backend scope.</p>
|
|
</div>
|
|
</section>
|
|
|
|
<section>
|
|
<SectionEyebrow icon={<Activity className="h-4 w-4" />} label="Local smoke assist" />
|
|
<div className="mt-3 rounded-[22px] border border-[var(--line-soft)] bg-[var(--surface-base)] p-4">
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div>
|
|
<p className="text-sm font-medium text-[var(--ink-0)]">
|
|
Seeded fixture path for desktop smoke runs
|
|
</p>
|
|
<p className="mt-1 text-sm text-[var(--ink-soft)]">
|
|
Mirrors `docs/qa/local-desktop-smoke.md` and keeps the safe validation path visible inside the shell.
|
|
</p>
|
|
</div>
|
|
<Badge variant={localSmokeFixtureActive ? "accent" : "neutral"}>
|
|
{localSmokeFixtureActive ? "active" : "inactive"}
|
|
</Badge>
|
|
</div>
|
|
|
|
<div className="mt-3 flex flex-wrap gap-2">
|
|
<Badge variant="neutral">127.0.0.1:6380</Badge>
|
|
<Badge variant="neutral">db0</Badge>
|
|
<Badge variant="neutral">{localSmokeFixtureExpectedKeys.length} seeded keys</Badge>
|
|
</div>
|
|
|
|
<div className="mt-3 grid grid-cols-2 gap-2">
|
|
<Button variant="outline" size="sm" onClick={handleUseLocalSmokeFixture}>
|
|
Use fixture
|
|
</Button>
|
|
<Button variant="outline" size="sm" onClick={handleFocusLocalSmokeKeys}>
|
|
Focus smoke:*
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="mt-3 space-y-2">
|
|
{localSmokeFixtureCommandPresets.map((preset) => (
|
|
<button
|
|
key={preset.id}
|
|
type="button"
|
|
onClick={() => handleApplyLocalSmokeCommandPreset(preset.command)}
|
|
className="w-full rounded-[18px] border border-[var(--line-soft)] bg-[var(--surface-muted)] px-3 py-2 text-left transition hover:border-[var(--line-strong)]"
|
|
>
|
|
<p className="font-mono text-sm text-[var(--ink-0)]">{preset.label}</p>
|
|
<p className="mt-1 text-xs text-[var(--ink-soft)]">{preset.summary}</p>
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
<div className="mt-3 rounded-[18px] border border-[var(--line-soft)] bg-[var(--surface-muted)] px-3 py-3">
|
|
<p className="font-mono text-[11px] uppercase tracking-[0.18em] text-[var(--ink-muted)]">
|
|
Seeded keys
|
|
</p>
|
|
<div className="mt-2 flex flex-wrap gap-2">
|
|
{localSmokeFixtureExpectedKeys.map((keyName) => (
|
|
<Badge key={keyName} variant="neutral" className="normal-case tracking-normal">
|
|
{keyName}
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section>
|
|
<SectionEyebrow icon={<ClipboardList className="h-4 w-4" />} label="QA snapshot" />
|
|
<div className="mt-3 rounded-[22px] border border-[var(--line-soft)] bg-[var(--surface-base)] p-4">
|
|
<p className="text-sm text-[var(--ink-soft)]">
|
|
Copy this summary into a smoke log when screenshots are unavailable.
|
|
</p>
|
|
<Textarea
|
|
readOnly
|
|
value={qaSnapshot}
|
|
className="mt-3 h-40 resize-none border-[var(--line-soft)] bg-[var(--surface-muted)] font-mono text-xs text-[var(--ink-soft)]"
|
|
/>
|
|
<div className="mt-3 flex items-center justify-between gap-3">
|
|
<p className="text-xs text-[var(--ink-muted)]">
|
|
{copyFeedback === "copied"
|
|
? "Snapshot copied."
|
|
: copyFeedback === "unavailable"
|
|
? "Clipboard unavailable in this runtime."
|
|
: "Includes version, runtime, context, and latest command state."}
|
|
</p>
|
|
<Button variant="outline" size="sm" onClick={handleCopyQaSnapshot}>
|
|
<Copy className="h-4 w-4" />
|
|
Copy snapshot
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</aside>
|
|
|
|
<section className="flex min-h-0 flex-col rounded-[28px] border border-[var(--line-soft)] bg-[var(--surface-raised)] p-4 shadow-[var(--shadow-panel)]">
|
|
<div className="flex flex-col gap-4 border-b border-[var(--line-soft)] pb-4">
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div>
|
|
<SectionEyebrow icon={<KeyRound className="h-4 w-4" />} label="Key browser" />
|
|
<h2 className="mt-2 text-xl font-semibold tracking-[-0.03em]">Incremental browse with fast pattern search</h2>
|
|
</div>
|
|
<Badge variant="neutral">
|
|
{tauriAvailable
|
|
? `${filteredKeys.length}${liveHasMore ? "+" : ""} visible in ${activeDb}`
|
|
: `${filteredKeys.length} of ${demoKeys.length} keys in ${activeDb}`}
|
|
</Badge>
|
|
</div>
|
|
|
|
<label className="flex items-center gap-3 rounded-2xl border border-[var(--line-soft)] bg-[var(--surface-base)] px-3 py-3">
|
|
<Search className="h-4 w-4 text-[var(--ink-muted)]" />
|
|
<input
|
|
value={search}
|
|
onChange={(event) => setSearch(event.target.value)}
|
|
className="w-full bg-transparent text-sm text-[var(--ink-0)] outline-none placeholder:text-[var(--ink-muted)]"
|
|
placeholder="Search key name or pattern"
|
|
/>
|
|
{searchState.mode === "all" ? null : (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setSearch("")}
|
|
className="shrink-0 px-2 py-1 text-xs"
|
|
>
|
|
Clear
|
|
</Button>
|
|
)}
|
|
</label>
|
|
<div className="flex items-center justify-between gap-3 text-xs text-[var(--ink-muted)]">
|
|
<div className="flex items-center gap-2">
|
|
<Badge variant="neutral" className="normal-case tracking-normal">
|
|
{deferredSearchState.mode === "all" ? "all keys" : deferredSearchState.mode === "pattern" ? "pattern" : "contains"}
|
|
</Badge>
|
|
<p>{keyBrowserStatusMessage}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-4 min-h-0 flex-1 space-y-2 overflow-auto pr-1">
|
|
{filteredKeys.map((item) => {
|
|
const active = item.id === selectedKey?.id;
|
|
|
|
return (
|
|
<button
|
|
key={item.id}
|
|
type="button"
|
|
onClick={() => {
|
|
setSelectedKeyId(item.id);
|
|
setDraftValue(item.value);
|
|
}}
|
|
className={cn(
|
|
"w-full rounded-[24px] border px-4 py-4 text-left transition",
|
|
active
|
|
? "border-[var(--accent-strong)] bg-[var(--accent-faint)]"
|
|
: "border-[var(--line-soft)] bg-[var(--surface-base)] hover:border-[var(--line-strong)]",
|
|
)}
|
|
>
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div>
|
|
<p className="font-mono text-sm text-[var(--ink-0)]">{item.name}</p>
|
|
<p className="mt-1 text-sm text-[var(--ink-soft)]">{item.preview}</p>
|
|
</div>
|
|
<div className="flex flex-col items-end gap-2">
|
|
<Badge variant={active ? "accent" : "neutral"}>{item.type}</Badge>
|
|
<span className="font-mono text-xs text-[var(--ink-muted)]">{item.ttl}</span>
|
|
</div>
|
|
</div>
|
|
</button>
|
|
);
|
|
})}
|
|
|
|
{filteredKeys.length === 0 ? (
|
|
<div className="rounded-[24px] border border-dashed border-[var(--line-strong)] bg-[var(--surface-base)] px-4 py-8 text-center text-sm text-[var(--ink-soft)]">
|
|
{keyBrowserEmptyStateMessage}
|
|
</div>
|
|
) : null}
|
|
|
|
{tauriAvailable && liveHasMore ? (
|
|
<Button
|
|
variant="outline"
|
|
onClick={handleLoadMoreKeys}
|
|
disabled={isBrowseLoading}
|
|
className="w-full"
|
|
>
|
|
{isBrowseLoading ? "Loading..." : "Load more keys"}
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
</section>
|
|
|
|
<section className="flex min-h-0 flex-col gap-4">
|
|
<div className="flex min-h-0 flex-1 flex-col rounded-[28px] border border-[var(--line-soft)] bg-[var(--surface-raised)] p-4 shadow-[var(--shadow-panel)]">
|
|
<div className="flex items-start justify-between gap-3 border-b border-[var(--line-soft)] pb-4">
|
|
<div>
|
|
<SectionEyebrow icon={<Database className="h-4 w-4" />} label="Inspector" />
|
|
<h2 className="mt-2 text-xl font-semibold tracking-[-0.03em]">
|
|
{selectedKey?.name ?? "No key selected"}
|
|
</h2>
|
|
</div>
|
|
|
|
{selectedKey ? (
|
|
<div className="flex items-center gap-2">
|
|
<Badge variant="neutral">{selectedKey.type}</Badge>
|
|
<Badge variant="neutral">TTL {selectedKey.ttl}</Badge>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
|
|
<div className="grid gap-4 py-4 lg:grid-cols-[1.05fr_0.95fr]">
|
|
<div className="rounded-[24px] border border-[var(--line-soft)] bg-[var(--surface-base)] p-4">
|
|
<div className="flex items-center justify-between gap-3">
|
|
<p className="text-sm font-medium text-[var(--ink-0)]">Value surface</p>
|
|
<span className="font-mono text-xs text-[var(--ink-muted)]">
|
|
{selectedKey
|
|
? isInspectorLoading
|
|
? "loading"
|
|
: isEditable
|
|
? "editable"
|
|
: "read only"
|
|
: "awaiting selection"}
|
|
</span>
|
|
</div>
|
|
|
|
{selectedKey ? (
|
|
showStructuredInspector ? (
|
|
<StructuredInspectorPanels sections={structuredInspectorSections} />
|
|
) : (
|
|
<Textarea
|
|
value={isEditable ? draftValue : inspectorText}
|
|
onChange={(event) => setDraftValue(event.target.value)}
|
|
readOnly={!isEditable}
|
|
className={cn(
|
|
"mt-3 h-64 resize-none font-mono",
|
|
!isEditable &&
|
|
"border-[var(--line-soft)] bg-[var(--surface-muted)] text-[var(--ink-soft)]",
|
|
)}
|
|
/>
|
|
)
|
|
) : (
|
|
<div className="mt-3 flex h-64 items-center justify-center rounded-[22px] border border-dashed border-[var(--line-strong)] bg-[var(--surface-muted)] px-6 text-center text-sm text-[var(--ink-soft)]">
|
|
{inspectorEmptyStateMessage}
|
|
</div>
|
|
)}
|
|
|
|
<div className="mt-3 flex flex-wrap gap-2">
|
|
<Button
|
|
onClick={handleSaveString}
|
|
disabled={!selectedKey || !isEditable || isSaveRunning || isInspectorLoading}
|
|
>
|
|
{isSaveRunning ? "Saving..." : "Save string value"}
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
onClick={handleRefreshInspector}
|
|
disabled={!selectedKey || isInspectorLoading}
|
|
>
|
|
{isInspectorLoading ? "Refreshing..." : "Refresh metadata"}
|
|
</Button>
|
|
|
|
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
|
<DialogTrigger asChild>
|
|
<Button
|
|
variant="subtle"
|
|
disabled={!selectedKey}
|
|
className="border-[var(--danger-soft)] bg-[var(--danger-faint)] text-[var(--danger-strong)] hover:border-[var(--danger-soft)]"
|
|
>
|
|
Delete key
|
|
</Button>
|
|
</DialogTrigger>
|
|
<DialogContent className="border-[var(--danger-soft)]">
|
|
<DialogHeader>
|
|
<div className="flex items-start gap-3">
|
|
<div className="rounded-2xl border border-[var(--danger-soft)] bg-[var(--danger-faint)] p-3 text-[var(--danger-strong)]">
|
|
<Trash2 className="h-5 w-5" />
|
|
</div>
|
|
<div>
|
|
<p className="font-mono text-xs uppercase tracking-[0.22em] text-[var(--ink-muted)]">
|
|
Confirm delete
|
|
</p>
|
|
<DialogTitle className="mt-2">
|
|
Remove <span className="font-mono">{selectedKey?.name ?? "the selected key"}</span> from {activeDb}?
|
|
</DialogTitle>
|
|
</div>
|
|
</div>
|
|
<DialogDescription>
|
|
This step stays separate from the browser and repeats both key name and DB context before mutation.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<DialogFooter>
|
|
<DialogClose asChild>
|
|
<Button variant="outline">Cancel</Button>
|
|
</DialogClose>
|
|
<Button variant="danger" onClick={handleDeleteKey}>
|
|
Delete key
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-4">
|
|
<InfoCard
|
|
title="Key metadata"
|
|
icon={<TimerReset className="h-4 w-4" />}
|
|
lines={[
|
|
`Active DB: ${activeDb}`,
|
|
`TTL: ${selectedKey?.ttl ?? "no active key"}`,
|
|
`Editor support: ${selectedKey ? (isEditable ? "string write enabled" : "inspection only") : "awaiting matching selection"}`,
|
|
]}
|
|
/>
|
|
<div className="rounded-[24px] border border-[var(--line-soft)] bg-[var(--surface-base)] p-4">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-[var(--accent-strong)]">
|
|
<TimerReset className="h-4 w-4" />
|
|
</span>
|
|
<p className="text-sm font-medium text-[var(--ink-0)]">TTL control</p>
|
|
</div>
|
|
|
|
<div className="mt-3 space-y-3">
|
|
<div>
|
|
<p className="font-mono text-[11px] uppercase tracking-[0.18em] text-[var(--ink-muted)]">
|
|
Current state
|
|
</p>
|
|
<p className="mt-1 text-sm text-[var(--ink-0)]">
|
|
{selectedKey ? `TTL ${selectedKey.ttl}` : "Select a key to manage TTL."}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="grid gap-2">
|
|
<label className="text-sm font-medium text-[var(--ink-0)]" htmlFor="ttl-millis">
|
|
Expire in milliseconds
|
|
</label>
|
|
<Input
|
|
id="ttl-millis"
|
|
inputMode="numeric"
|
|
placeholder="60000"
|
|
value={ttlDraft}
|
|
onChange={(event) => setTtlDraft(event.target.value)}
|
|
disabled={!selectedKey || isTtlUpdateRunning}
|
|
/>
|
|
</div>
|
|
|
|
<p
|
|
className={cn(
|
|
"text-sm",
|
|
ttlDraftError ? "text-[var(--danger-strong)]" : "text-[var(--ink-soft)]",
|
|
)}
|
|
>
|
|
{ttlDraftError ??
|
|
(tauriAvailable
|
|
? "Uses the existing desktop TTL contract. String saves preserve the active TTL."
|
|
: "Browser dev mode keeps TTL controls in explicit demo fallback.")}
|
|
</p>
|
|
|
|
<div className="flex flex-wrap gap-2">
|
|
<Button
|
|
size="sm"
|
|
onClick={handleApplyTtl}
|
|
disabled={!canSubmitTtlUpdate}
|
|
>
|
|
{isTtlUpdateRunning ? "Updating..." : "Set TTL"}
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={handlePersistTtl}
|
|
disabled={!canPersistTtl}
|
|
>
|
|
Remove TTL
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<InfoCard
|
|
title="Support note"
|
|
icon={<CircleAlert className="h-4 w-4" />}
|
|
lines={[supportNote]}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<section className="rounded-[28px] border border-[var(--line-soft)] bg-[var(--surface-raised)] p-4 shadow-[var(--shadow-panel)]">
|
|
<div className="flex items-start justify-between gap-3 border-b border-[var(--line-soft)] pb-4">
|
|
<div>
|
|
<SectionEyebrow icon={<TerminalSquare className="h-4 w-4" />} label="Command tray" />
|
|
<h2 className="mt-2 text-xl font-semibold tracking-[-0.03em]">
|
|
Scoped console with visible safety boundary
|
|
</h2>
|
|
</div>
|
|
<Badge variant="neutral">
|
|
{activeConnection.name} / {activeDb}
|
|
</Badge>
|
|
</div>
|
|
|
|
<div className="mt-4 flex flex-col gap-3">
|
|
<form className="flex flex-col gap-3 sm:flex-row" onSubmit={handleConsoleSubmit}>
|
|
<Input
|
|
value={consoleInput}
|
|
onChange={(event) => setConsoleInput(event.target.value)}
|
|
className="font-mono"
|
|
/>
|
|
<Button type="submit" className="sm:self-start" disabled={isCommandRunning}>
|
|
{isCommandRunning ? "Running..." : tauriAvailable ? "Run live command" : "Run demo command"}
|
|
</Button>
|
|
</form>
|
|
|
|
<div className="grid gap-2">
|
|
{consoleHistory.map((entry) => (
|
|
<div
|
|
key={`${entry.command}-${entry.output}`}
|
|
className={cn(
|
|
"rounded-[22px] border px-4 py-3",
|
|
entry.status === "blocked"
|
|
? "border-[var(--danger-soft)] bg-[var(--danger-faint)]"
|
|
: "border-[var(--line-soft)] bg-[var(--surface-base)]",
|
|
)}
|
|
>
|
|
<div className="flex items-start justify-between gap-3">
|
|
<p className="font-mono text-sm text-[var(--ink-0)]">{entry.command}</p>
|
|
<div className="flex items-center gap-2">
|
|
<Badge variant={entry.source === "live" ? "accent" : "neutral"}>
|
|
{entry.source}
|
|
</Badge>
|
|
<Badge
|
|
variant={entry.status === "ok" ? "neutral" : "danger"}
|
|
>
|
|
{entry.status}
|
|
</Badge>
|
|
</div>
|
|
</div>
|
|
<p className="mt-1 text-sm text-[var(--ink-soft)]">{entry.output}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</section>
|
|
</main>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SectionEyebrow({ icon, label }: { icon: ReactElement; label: string }) {
|
|
return (
|
|
<div className="flex items-center gap-2 font-mono text-xs uppercase tracking-[0.24em] text-[var(--ink-muted)]">
|
|
<span className="text-[var(--accent-strong)]">{icon}</span>
|
|
<span>{label}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function StatusPill({
|
|
icon,
|
|
label,
|
|
value,
|
|
}: {
|
|
icon: ReactElement;
|
|
label: string;
|
|
value: string;
|
|
}) {
|
|
return (
|
|
<div className="flex items-center gap-3 rounded-full border border-[var(--line-soft)] bg-[var(--surface-base)] px-3 py-2">
|
|
<span className="text-[var(--accent-strong)]">{icon}</span>
|
|
<div>
|
|
<p className="text-[11px] uppercase tracking-[0.2em] text-[var(--ink-muted)]">{label}</p>
|
|
<p className="font-medium text-[var(--ink-0)]">{value}</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function InfoLine({ label, value }: { label: string; value: string }) {
|
|
return (
|
|
<div className="flex flex-col gap-1">
|
|
<p className="font-mono text-[11px] uppercase tracking-[0.18em] text-[var(--ink-muted)]">
|
|
{label}
|
|
</p>
|
|
<p className="text-sm text-[var(--ink-0)]">{value}</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function InfoCard({
|
|
title,
|
|
icon,
|
|
lines,
|
|
}: {
|
|
title: string;
|
|
icon: ReactElement;
|
|
lines: string[];
|
|
}) {
|
|
return (
|
|
<div className="rounded-[24px] border border-[var(--line-soft)] bg-[var(--surface-base)] p-4">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-[var(--accent-strong)]">{icon}</span>
|
|
<p className="text-sm font-medium text-[var(--ink-0)]">{title}</p>
|
|
</div>
|
|
|
|
<div className="mt-3 space-y-2 text-sm text-[var(--ink-soft)]">
|
|
{lines.map((line) => (
|
|
<p key={line}>{line}</p>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function StructuredInspectorPanels({
|
|
sections,
|
|
}: {
|
|
sections: StructuredInspectorSection[];
|
|
}) {
|
|
return (
|
|
<div className="mt-3 h-64 space-y-3 overflow-auto pr-1">
|
|
{sections.map((section) => (
|
|
<section
|
|
key={section.id}
|
|
className="rounded-[22px] border border-[var(--line-soft)] bg-[var(--surface-muted)] p-4"
|
|
>
|
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
|
<div>
|
|
<p className="text-sm font-medium text-[var(--ink-0)]">{section.title}</p>
|
|
<p className="mt-1 text-sm text-[var(--ink-soft)]">{section.summary}</p>
|
|
</div>
|
|
<Badge variant="neutral">{section.rows.length} rows</Badge>
|
|
</div>
|
|
|
|
<div className="mt-4 hidden grid-cols-[minmax(0,0.38fr)_minmax(0,0.62fr)] gap-3 px-1 sm:grid">
|
|
<p className="font-mono text-[11px] uppercase tracking-[0.18em] text-[var(--ink-muted)]">
|
|
{section.primaryLabel}
|
|
</p>
|
|
<p className="font-mono text-[11px] uppercase tracking-[0.18em] text-[var(--ink-muted)]">
|
|
{section.secondaryLabel}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="mt-3 space-y-2">
|
|
{section.rows.map((row) => (
|
|
<div
|
|
key={row.id}
|
|
className="grid gap-3 rounded-[18px] border border-[var(--line-soft)] bg-[var(--surface-base)] p-3 sm:grid-cols-[minmax(0,0.38fr)_minmax(0,0.62fr)]"
|
|
>
|
|
<div>
|
|
<p className="font-mono text-[11px] uppercase tracking-[0.18em] text-[var(--ink-muted)] sm:hidden">
|
|
{section.primaryLabel}
|
|
</p>
|
|
<p className="mt-1 break-all font-mono text-sm text-[var(--ink-0)] sm:mt-0">
|
|
{row.primary}
|
|
</p>
|
|
</div>
|
|
|
|
<div>
|
|
<p className="font-mono text-[11px] uppercase tracking-[0.18em] text-[var(--ink-muted)] sm:hidden">
|
|
{section.secondaryLabel}
|
|
</p>
|
|
<p className="mt-1 break-all font-mono text-sm text-[var(--ink-0)] sm:mt-0">
|
|
{row.secondary}
|
|
</p>
|
|
</div>
|
|
|
|
{row.detailLines && row.detailLines.length > 0 ? (
|
|
<div className="sm:col-span-2">
|
|
<p className="font-mono text-[11px] uppercase tracking-[0.18em] text-[var(--ink-muted)]">
|
|
Payload
|
|
</p>
|
|
<div className="mt-2 space-y-2">
|
|
{row.detailLines.map((detail) => (
|
|
<div
|
|
key={`${row.id}-${detail}`}
|
|
className="rounded-2xl border border-[var(--line-soft)] bg-[var(--surface-muted)] px-3 py-2 font-mono text-xs text-[var(--ink-soft)]"
|
|
>
|
|
{detail}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</section>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function isTauriRuntimeAvailable() {
|
|
return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
|
|
}
|
|
|
|
function normalizeOptionalString(value: string) {
|
|
const normalized = value.trim();
|
|
return normalized ? normalized : null;
|
|
}
|
|
|
|
function parseDatabaseLabel(value: string) {
|
|
const parsed = Number.parseInt(value.replace(/^db/i, ""), 10);
|
|
return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0;
|
|
}
|
|
|
|
function parseDatabaseInput(value: string) {
|
|
const parsed = Number.parseInt(value, 10);
|
|
return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0;
|
|
}
|
|
|
|
function parsePortInput(value: string) {
|
|
const parsed = Number.parseInt(value, 10);
|
|
return Number.isFinite(parsed) && parsed > 0 && parsed <= 65535 ? parsed : 6379;
|
|
}
|
|
|
|
function formatDatabaseLabel(database: number) {
|
|
return `db${database}`;
|
|
}
|
|
|
|
function formatEndpoint(target: ConnectionProfile["target"]) {
|
|
return `${target.host}:${target.port}`;
|
|
}
|
|
|
|
function formatConnectionStatus(connection: ConnectionProfile) {
|
|
if (connection.status === "healthy") {
|
|
return `Healthy · ${connection.detail}`;
|
|
}
|
|
|
|
if (connection.status === "checking") {
|
|
return "Testing live connection";
|
|
}
|
|
|
|
if (connection.status === "error") {
|
|
return `Error · ${connection.detail}`;
|
|
}
|
|
|
|
return connection.detail;
|
|
}
|
|
|
|
function formatConnectionCardDetail(connection: ConnectionProfile) {
|
|
if (connection.status === "checking") {
|
|
return "live test in progress";
|
|
}
|
|
|
|
if (connection.status === "healthy") {
|
|
return `checked ${connection.detail}`;
|
|
}
|
|
|
|
if (connection.status === "error") {
|
|
return `failed ${connection.detail}`;
|
|
}
|
|
|
|
return connection.detail;
|
|
}
|
|
|
|
function formatBackendErrorCodeLabel(code: string) {
|
|
return code.replace(/_/g, " ");
|
|
}
|
|
|
|
function toRedisConnectionRequest(
|
|
connection: ConnectionProfile,
|
|
activeDb: string,
|
|
): RedisConnectionRequest {
|
|
return {
|
|
target: {
|
|
...connection.target,
|
|
database: parseDatabaseLabel(activeDb),
|
|
username: connection.target.username,
|
|
},
|
|
password: normalizeOptionalString(connection.password),
|
|
};
|
|
}
|
|
|
|
function tokenizeCommand(value: string) {
|
|
return (value.match(/"[^"]*"|'[^']*'|\S+/g) ?? []).map((part) => {
|
|
if (
|
|
(part.startsWith("\"") && part.endsWith("\"")) ||
|
|
(part.startsWith("'") && part.endsWith("'"))
|
|
) {
|
|
return part.slice(1, -1);
|
|
}
|
|
|
|
return part;
|
|
});
|
|
}
|
|
|
|
function buildMockCommandOutput(command: string, selectedKey: KeyRecord | null) {
|
|
if (!selectedKey) {
|
|
return "No key is selected in the visible shell.";
|
|
}
|
|
|
|
const upper = command.toUpperCase();
|
|
|
|
if (upper === "GET") {
|
|
return selectedKey.value;
|
|
}
|
|
|
|
if (upper === "TTL") {
|
|
return `(integer) ${parseDemoTtlToSeconds(selectedKey.ttl)}`;
|
|
}
|
|
|
|
if (upper === "TYPE") {
|
|
return selectedKey.type;
|
|
}
|
|
|
|
return "OK (demo result)";
|
|
}
|
|
|
|
function validateConnectionDraft(
|
|
draft: ConnectionDraft,
|
|
connections: ConnectionProfile[],
|
|
): ConnectionDraftErrors {
|
|
const errors: ConnectionDraftErrors = {};
|
|
const trimmedName = draft.name.trim();
|
|
const trimmedHost = draft.host.trim();
|
|
const port = Number.parseInt(draft.port, 10);
|
|
const database = Number.parseInt(draft.database, 10);
|
|
|
|
if (!trimmedName) {
|
|
errors.name = "Profile name is required.";
|
|
} else if (
|
|
connections.some((connection) => connection.name.toLowerCase() === trimmedName.toLowerCase())
|
|
) {
|
|
errors.name = "Profile name must stay unique inside the visible shell.";
|
|
}
|
|
|
|
if (!trimmedHost) {
|
|
errors.host = "Host is required for a connection draft.";
|
|
}
|
|
|
|
if (!Number.isFinite(port) || port < 1 || port > 65535) {
|
|
errors.port = "Port must be a number between 1 and 65535.";
|
|
}
|
|
|
|
if (!Number.isFinite(database) || database < 0) {
|
|
errors.database = "Default DB must be a non-negative integer.";
|
|
}
|
|
|
|
return errors;
|
|
}
|
|
|
|
function formatRedisResponse(response: RedisResponse) {
|
|
const normalized = normalizeRedisResponse(response);
|
|
return typeof normalized === "string"
|
|
? normalized
|
|
: JSON.stringify(normalized, null, 2);
|
|
}
|
|
|
|
function normalizeRedisResponse(response: RedisResponse): unknown {
|
|
switch (response.kind) {
|
|
case "null":
|
|
return null;
|
|
case "integer":
|
|
return response.value;
|
|
case "string":
|
|
return response.value;
|
|
case "binary":
|
|
return `[binary ${response.bytes.length} bytes]`;
|
|
case "array":
|
|
return response.items.map(normalizeRedisResponse);
|
|
default:
|
|
return response;
|
|
}
|
|
}
|
|
|
|
function toRedisSearchPattern(value: string) {
|
|
const trimmed = value.trim();
|
|
if (!trimmed) {
|
|
return null;
|
|
}
|
|
|
|
if (/[*?\[\]]/.test(trimmed)) {
|
|
return trimmed;
|
|
}
|
|
|
|
return `*${trimmed}*`;
|
|
}
|
|
|
|
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 toLiveKeyRecordFromMetadata(metadata: RedisKeyMetadata): KeyRecord {
|
|
return {
|
|
id: metadata.name,
|
|
name: metadata.name,
|
|
type: metadata.key_type,
|
|
ttl: formatRedisKeyTtl(metadata.ttl),
|
|
preview: buildMetadataPreview(metadata),
|
|
editable: false,
|
|
value: "",
|
|
summary: buildMetadataSummary(metadata),
|
|
};
|
|
}
|
|
|
|
function toLiveKeyRecordFromValue(record: RedisValueRecord): KeyRecord {
|
|
return {
|
|
id: record.metadata.name,
|
|
name: record.metadata.name,
|
|
type: record.metadata.key_type,
|
|
ttl: formatRedisKeyTtl(record.metadata.ttl),
|
|
preview: buildValuePreview(record),
|
|
editable: record.write_capability === "replace_string",
|
|
value: formatRedisValueData(record.data),
|
|
summary: buildLiveSupportNote(record, null),
|
|
};
|
|
}
|
|
|
|
function buildMetadataPreview(metadata: RedisKeyMetadata) {
|
|
return `${formatRedisKeyTypeLabel(metadata.key_type)} key · TTL ${formatRedisKeyTtl(metadata.ttl)}`;
|
|
}
|
|
|
|
function buildMetadataSummary(metadata: RedisKeyMetadata) {
|
|
if (!metadata.exists) {
|
|
return "The selected key disappeared before the inspector could read its value.";
|
|
}
|
|
|
|
return `${formatRedisKeyTypeLabel(metadata.key_type)} metadata is available. Open the inspector to load the live value surface.`;
|
|
}
|
|
|
|
function buildValuePreview(record: RedisValueRecord) {
|
|
switch (record.data.kind) {
|
|
case "missing":
|
|
return "key disappeared before value read";
|
|
case "string":
|
|
return truncateText(formatRedisValueContent(record.data.value), 48);
|
|
case "hash":
|
|
return `${record.data.entries.length} field${record.data.entries.length === 1 ? "" : "s"}`;
|
|
case "list":
|
|
return `${record.data.items.length} list item${record.data.items.length === 1 ? "" : "s"}`;
|
|
case "set":
|
|
return `${record.data.members.length} set member${record.data.members.length === 1 ? "" : "s"}`;
|
|
case "sorted_set":
|
|
return `${record.data.entries.length} scored member${record.data.entries.length === 1 ? "" : "s"}`;
|
|
case "stream":
|
|
return `${record.data.entries.length} stream entr${record.data.entries.length === 1 ? "y" : "ies"}`;
|
|
default:
|
|
return "live value loaded";
|
|
}
|
|
}
|
|
|
|
function buildLiveSupportNote(record: RedisValueRecord | null, selectedKey: KeyRecord | null) {
|
|
if (!selectedKey) {
|
|
return "Search currently returns zero keys in the active DB. This is an intentional empty state, not a backend failure.";
|
|
}
|
|
|
|
if (!record) {
|
|
return "Loading live metadata and value surface for the current key.";
|
|
}
|
|
|
|
switch (record.data.kind) {
|
|
case "missing":
|
|
return "The key disappeared between browse and read. Refresh the browser list to clear stale state safely.";
|
|
case "string":
|
|
return record.write_capability === "replace_string"
|
|
? "String values are editable through the current desktop contract and preserve their existing TTL on save."
|
|
: "String value is currently inspection only in the desktop contract.";
|
|
case "hash":
|
|
case "list":
|
|
case "set":
|
|
case "sorted_set":
|
|
case "stream":
|
|
return `${formatRedisKeyTypeLabel(record.metadata.key_type)} values are loaded live into structured read-only panels in the current desktop contract.`;
|
|
default:
|
|
return "The current key is available for live inspection through the desktop backend contract.";
|
|
}
|
|
}
|
|
|
|
function formatInspectorValue(
|
|
record: RedisValueRecord | null,
|
|
selectedKey: KeyRecord | null,
|
|
isLoading: boolean,
|
|
) {
|
|
if (!selectedKey) {
|
|
return "";
|
|
}
|
|
|
|
if (isLoading || !record) {
|
|
return "Loading live value...";
|
|
}
|
|
|
|
return formatRedisValueData(record.data);
|
|
}
|
|
|
|
function formatRedisValueData(data: RedisValueData) {
|
|
switch (data.kind) {
|
|
case "missing":
|
|
return "Key no longer exists.";
|
|
case "string":
|
|
return formatRedisValueContent(data.value);
|
|
case "hash":
|
|
return JSON.stringify(
|
|
data.entries.map((entry) => ({
|
|
field: normalizeRedisValueContent(entry.field),
|
|
value: normalizeRedisValueContent(entry.value),
|
|
})),
|
|
null,
|
|
2,
|
|
);
|
|
case "list":
|
|
return JSON.stringify(data.items.map(normalizeRedisValueContent), null, 2);
|
|
case "set":
|
|
return JSON.stringify(data.members.map(normalizeRedisValueContent), null, 2);
|
|
case "sorted_set":
|
|
return JSON.stringify(
|
|
data.entries.map((entry) => ({
|
|
member: normalizeRedisValueContent(entry.member),
|
|
score: entry.score,
|
|
})),
|
|
null,
|
|
2,
|
|
);
|
|
case "stream":
|
|
return JSON.stringify(
|
|
data.entries.map((entry) => ({
|
|
id: entry.id,
|
|
fields: entry.fields.map((field) => ({
|
|
field: normalizeRedisValueContent(field.field),
|
|
value: normalizeRedisValueContent(field.value),
|
|
})),
|
|
})),
|
|
null,
|
|
2,
|
|
);
|
|
default:
|
|
return "Unsupported value payload.";
|
|
}
|
|
}
|
|
|
|
function formatRedisValueContent(content: RedisValueContent) {
|
|
switch (content.kind) {
|
|
case "string":
|
|
return content.value;
|
|
case "binary":
|
|
return `[binary ${content.bytes.length} bytes]`;
|
|
default:
|
|
return "unsupported";
|
|
}
|
|
}
|
|
|
|
function normalizeRedisValueContent(content: RedisValueContent) {
|
|
switch (content.kind) {
|
|
case "string":
|
|
return content.value;
|
|
case "binary":
|
|
return `[binary ${content.bytes.length} bytes]`;
|
|
default:
|
|
return content;
|
|
}
|
|
}
|
|
|
|
function formatRedisKeyTypeLabel(value: string) {
|
|
switch (value) {
|
|
case "zset":
|
|
return "sorted set";
|
|
default:
|
|
return value || "unknown";
|
|
}
|
|
}
|
|
|
|
function truncateText(value: string, maxLength: number) {
|
|
if (value.length <= maxLength) {
|
|
return value;
|
|
}
|
|
|
|
return `${value.slice(0, maxLength - 1)}…`;
|
|
}
|
|
|
|
function validateTtlDraft(value: string) {
|
|
const trimmed = value.trim();
|
|
const parsed = Number.parseInt(trimmed, 10);
|
|
|
|
if (!trimmed) {
|
|
return "TTL must be provided in milliseconds.";
|
|
}
|
|
|
|
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
return "TTL must be a positive integer number of milliseconds.";
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
export default App;
|