- add a visible connection session summary for ready/testing/demo/retry states - prevent stale live connection tests from overwriting a newer active DB selection - include the missing frontend helpers already referenced by App.tsx so the branch builds cleanly Co-Authored-By: Paperclip <noreply@paperclip.ing>
3288 lines
114 KiB
TypeScript
3288 lines
114 KiB
TypeScript
import { invoke } from "@tauri-apps/api/core";
|
|
import {
|
|
startTransition,
|
|
useDeferredValue,
|
|
useEffect,
|
|
useRef,
|
|
useState,
|
|
type ChangeEvent,
|
|
type FormEvent,
|
|
type ReactElement,
|
|
} from "react";
|
|
import {
|
|
Activity,
|
|
CircleAlert,
|
|
ClipboardList,
|
|
Copy,
|
|
Database,
|
|
KeyRound,
|
|
PanelLeft,
|
|
Plus,
|
|
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 {
|
|
buildInspectorReadabilitySummary,
|
|
type InspectorFact,
|
|
} from "./lib/inspector-readability";
|
|
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 { buildDatabaseSwitcherLabels } from "./lib/database-switcher";
|
|
import {
|
|
applyConnectionTestFailure,
|
|
applyConnectionTestSuccess,
|
|
buildConnectionSessionSummary,
|
|
formatConnectionCardDetailLabel,
|
|
formatConnectionStatusLabel,
|
|
markConnectionTesting,
|
|
} from "./lib/connection-session";
|
|
import {
|
|
createDemoKeyRecord,
|
|
findDuplicateKeyName,
|
|
parseOptionalTtlMillis,
|
|
validateAddKeyDraft,
|
|
type AddKeyDraft,
|
|
} from "./lib/add-key-draft";
|
|
import {
|
|
buildMutationGuardDialogContent,
|
|
canRemoveTtl,
|
|
hasMeaningfulStringChange,
|
|
hasMeaningfulTtlChange,
|
|
type ComparableTtlState,
|
|
type MutationGuardRuntimeMode,
|
|
} from "./lib/mutation-guardrails";
|
|
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 RedisValueCreateRequest = {
|
|
connection: RedisConnectionRequest;
|
|
key: string;
|
|
value: {
|
|
kind: "string";
|
|
value: string;
|
|
};
|
|
ttl_millis: number | null;
|
|
};
|
|
|
|
type RedisValueCreateResult = {
|
|
record: RedisValueRecord;
|
|
};
|
|
|
|
type PendingMutationGuard =
|
|
| {
|
|
kind: "value_write";
|
|
runtimeMode: MutationGuardRuntimeMode;
|
|
connection: RedisConnectionRequest;
|
|
connectionName: string;
|
|
databaseLabel: string;
|
|
keyName: string;
|
|
currentValue: string;
|
|
nextValue: string;
|
|
}
|
|
| {
|
|
kind: "ttl_update";
|
|
runtimeMode: MutationGuardRuntimeMode;
|
|
connection: RedisConnectionRequest;
|
|
connectionName: string;
|
|
databaseLabel: string;
|
|
keyName: string;
|
|
currentTtl: string;
|
|
nextTtl: string;
|
|
nextTtlMillis: number;
|
|
}
|
|
| {
|
|
kind: "ttl_remove";
|
|
runtimeMode: MutationGuardRuntimeMode;
|
|
connection: RedisConnectionRequest;
|
|
connectionName: string;
|
|
databaseLabel: string;
|
|
keyName: string;
|
|
currentTtl: string;
|
|
};
|
|
|
|
type PendingStringWrite = Extract<PendingMutationGuard, { kind: "value_write" }>;
|
|
type PendingTtlUpdate = Extract<PendingMutationGuard, { kind: "ttl_update" }>;
|
|
type PendingTtlRemoval = Extract<PendingMutationGuard, { kind: "ttl_remove" }>;
|
|
|
|
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 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",
|
|
},
|
|
];
|
|
|
|
const initialAddKeyDraft: AddKeyDraft = {
|
|
name: "",
|
|
value: "",
|
|
ttlMillis: "",
|
|
};
|
|
|
|
function App() {
|
|
const [connections, setConnections] = useState(initialConnections);
|
|
const [demoKeys, setDemoKeys] = useState(initialDemoKeys);
|
|
const [activeConnectionId, setActiveConnectionId] = useState(initialConnections[0].id);
|
|
const [activeDb, setActiveDb] = useState("db0");
|
|
const [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 [addKeyDialogOpen, setAddKeyDialogOpen] = useState(false);
|
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
|
const [pendingMutation, setPendingMutation] = useState<PendingMutationGuard | null>(null);
|
|
const [connectionTestState, setConnectionTestState] = useState<{ connectionId: string } | null>(
|
|
null,
|
|
);
|
|
const [isCommandRunning, setIsCommandRunning] = useState(false);
|
|
const [isBrowseLoading, setIsBrowseLoading] = useState(false);
|
|
const [isInspectorLoading, setIsInspectorLoading] = useState(false);
|
|
const [isSaveRunning, setIsSaveRunning] = useState(false);
|
|
const [isCreateKeyRunning, setIsCreateKeyRunning] = 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 [addKeyDraft, setAddKeyDraft] = useState<AddKeyDraft>(initialAddKeyDraft);
|
|
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 activeConnectionIdRef = useRef(activeConnectionId);
|
|
const activeConnectionNameRef = useRef(activeConnection.name);
|
|
const activeDbRef = useRef(activeDb);
|
|
const databaseOptions = buildDatabaseSwitcherLabels(parseDatabaseLabel(activeDb));
|
|
const searchState = resolveKeyBrowserSearchState(search);
|
|
const deferredSearchState = resolveKeyBrowserSearchState(deferredSearch);
|
|
|
|
const filteredDemoKeys = filterWorkspaceKeys(demoKeys, deferredSearchState.trimmedQuery);
|
|
const filteredKeys = tauriAvailable ? liveKeys : filteredDemoKeys;
|
|
const draftErrors = validateConnectionDraft(connectionDraft, connections);
|
|
const draftErrorList = Object.values(draftErrors);
|
|
const addKeyDraftErrors = validateAddKeyDraft(addKeyDraft);
|
|
const addKeyDraftErrorList = Object.values(addKeyDraftErrors);
|
|
|
|
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 ttlWriteSupported = tauriAvailable
|
|
? selectedValueRecord?.ttl_write_supported === true
|
|
: selectedKey !== null;
|
|
const selectedStringValue =
|
|
selectedKey === null
|
|
? null
|
|
: tauriAvailable
|
|
? selectedValueRecord?.data.kind === "string"
|
|
? formatRedisValueContent(selectedValueRecord.data.value)
|
|
: null
|
|
: selectedKey.value;
|
|
const currentTtlState: ComparableTtlState | null =
|
|
selectedKey === null
|
|
? null
|
|
: tauriAvailable
|
|
? (selectedValueRecord?.metadata.ttl ?? null)
|
|
: { kind: "display", value: selectedKey.ttl };
|
|
const inspectorReadability = buildInspectorReadabilitySummary({
|
|
databaseLabel: activeDb,
|
|
runtimeMode: tauriAvailable ? "live" : "demo",
|
|
selectedKey:
|
|
selectedKey === null
|
|
? null
|
|
: {
|
|
name: selectedKey.name,
|
|
type: selectedKey.type,
|
|
ttl: selectedKey.ttl,
|
|
},
|
|
record: selectedValueRecord,
|
|
isLoading: isInspectorLoading,
|
|
isEditable,
|
|
ttlWriteSupported,
|
|
});
|
|
const localSmokeFixtureActive = isLocalSmokeFixtureConnection(activeConnection);
|
|
const isConnectionTestRunning = connectionTestState !== null;
|
|
const activeConnectionTestRunning = connectionTestState?.connectionId === activeConnection.id;
|
|
const otherTestingConnectionName =
|
|
connectionTestState !== null && connectionTestState.connectionId !== activeConnection.id
|
|
? connections.find((connection) => connection.id === connectionTestState.connectionId)?.name ??
|
|
"another connection"
|
|
: null;
|
|
const connectionSessionSummary = buildConnectionSessionSummary({
|
|
connection: activeConnection,
|
|
databaseLabel: activeDb,
|
|
tauriAvailable,
|
|
activeTestRunning: activeConnectionTestRunning,
|
|
otherTestingConnectionName,
|
|
});
|
|
const localSmokeFilterActive = search.trim() === localSmokeFixtureSearchText;
|
|
const ttlDraftError = ttlDraft.trim() ? validateTtlDraft(ttlDraft) : null;
|
|
const nextTtlMillis =
|
|
ttlDraft.trim() !== "" && ttlDraftError === null ? Number.parseInt(ttlDraft, 10) : null;
|
|
const nextTtlLabel = nextTtlMillis === null ? null : formatDurationFromMillis(nextTtlMillis);
|
|
const hasPendingStringChanges =
|
|
selectedStringValue !== null && hasMeaningfulStringChange(selectedStringValue, draftValue);
|
|
const hasPendingTtlChanges =
|
|
currentTtlState !== null &&
|
|
nextTtlMillis !== null &&
|
|
hasMeaningfulTtlChange(currentTtlState, nextTtlMillis);
|
|
const canSubmitTtlUpdate =
|
|
selectedKey !== null &&
|
|
ttlWriteSupported &&
|
|
nextTtlMillis !== null &&
|
|
!isTtlUpdateRunning &&
|
|
hasPendingTtlChanges;
|
|
const canPersistTtl =
|
|
selectedKey !== null &&
|
|
ttlWriteSupported &&
|
|
!isTtlUpdateRunning &&
|
|
currentTtlState !== null &&
|
|
canRemoveTtl(currentTtlState);
|
|
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 pendingMutationDialog = pendingMutation
|
|
? buildMutationGuardDialogContent(pendingMutation)
|
|
: null;
|
|
const pendingMutationRunning =
|
|
pendingMutation === null
|
|
? false
|
|
: pendingMutation.kind === "value_write"
|
|
? isSaveRunning
|
|
: isTtlUpdateRunning;
|
|
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(() => {
|
|
activeConnectionIdRef.current = activeConnectionId;
|
|
activeConnectionNameRef.current = activeConnection.name;
|
|
activeDbRef.current = activeDb;
|
|
}, [activeConnectionId, activeConnection.name, activeDb]);
|
|
|
|
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 handleAddKeyDraftChange =
|
|
(field: keyof AddKeyDraft) =>
|
|
(event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
|
const value = event.target.value;
|
|
|
|
setAddKeyDraft((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 handleCreateKey = async () => {
|
|
const nextKeyName = addKeyDraft.name.trim();
|
|
const createContext: BackendErrorContext = {
|
|
operation: "key_create",
|
|
connectionName: activeConnection.name,
|
|
databaseLabel: activeDb,
|
|
keyName: nextKeyName || "the requested key",
|
|
};
|
|
|
|
if (addKeyDraftErrorList.length > 0) {
|
|
showBackendErrorBanner(
|
|
{
|
|
code: "invalid_connection_config",
|
|
message: addKeyDraftErrorList[0],
|
|
detail: null,
|
|
},
|
|
createContext,
|
|
);
|
|
return;
|
|
}
|
|
|
|
const ttlMillis = parseOptionalTtlMillis(addKeyDraft.ttlMillis);
|
|
|
|
if (!tauriAvailable) {
|
|
if (findDuplicateKeyName(demoKeys, nextKeyName)) {
|
|
showBackendErrorBanner(
|
|
{
|
|
code: "already_exists",
|
|
message: "Redis key creation does not overwrite an existing key.",
|
|
detail: `key \`${nextKeyName}\` already exists`,
|
|
},
|
|
createContext,
|
|
);
|
|
return;
|
|
}
|
|
|
|
const nextRecord = createDemoKeyRecord({
|
|
...addKeyDraft,
|
|
name: nextKeyName,
|
|
});
|
|
|
|
startTransition(() => {
|
|
setDemoKeys((current) => [nextRecord, ...current]);
|
|
setSearch(nextRecord.name);
|
|
setSelectedKeyId(nextRecord.id);
|
|
setDraftValue(nextRecord.value);
|
|
setTtlDraft(ttlMillis === null ? "" : String(ttlMillis));
|
|
setAddKeyDraft(initialAddKeyDraft);
|
|
setAddKeyDialogOpen(false);
|
|
showAccentBanner(
|
|
`Created session-local demo key ${nextRecord.name} in ${activeDb} and focused the browser on the new string entry.`,
|
|
);
|
|
});
|
|
return;
|
|
}
|
|
|
|
startTransition(() => {
|
|
setIsCreateKeyRunning(true);
|
|
});
|
|
|
|
try {
|
|
const result = await invoke<RedisValueCreateResult>("create_redis_value", {
|
|
request: {
|
|
connection: toRedisConnectionRequest(activeConnection, activeDb),
|
|
key: nextKeyName,
|
|
value: {
|
|
kind: "string",
|
|
value: addKeyDraft.value,
|
|
},
|
|
ttl_millis: ttlMillis,
|
|
} satisfies RedisValueCreateRequest,
|
|
});
|
|
|
|
const nextRecord = toLiveKeyRecordFromValue(result.record);
|
|
startTransition(() => {
|
|
setSelectedValueRecord(result.record);
|
|
setLiveKeys((current) => replaceWorkspaceKeyRecord(current, nextRecord));
|
|
setSearch(nextRecord.name);
|
|
setSelectedKeyId(nextRecord.id);
|
|
setDraftValue(
|
|
result.record.data.kind === "string"
|
|
? formatRedisValueContent(result.record.data.value)
|
|
: formatRedisValueData(result.record.data),
|
|
);
|
|
setTtlDraft(
|
|
result.record.metadata.ttl.kind === "expires_in_millis"
|
|
? String(result.record.metadata.ttl.value)
|
|
: "",
|
|
);
|
|
setAddKeyDraft(initialAddKeyDraft);
|
|
setAddKeyDialogOpen(false);
|
|
setIsCreateKeyRunning(false);
|
|
showAccentBanner(
|
|
`Created live string key ${nextRecord.name} in ${activeDb} and focused the browser on the new record.`,
|
|
);
|
|
});
|
|
} catch (error) {
|
|
startTransition(() => {
|
|
setIsCreateKeyRunning(false);
|
|
|
|
if (isMissingTauriCommand(error, "create_redis_value")) {
|
|
showNeutralBanner(
|
|
"The checked-out desktop bridge does not expose create_redis_value yet. Form state was preserved so this flow can be retried after the backend slice lands.",
|
|
);
|
|
return;
|
|
}
|
|
|
|
showBackendErrorBanner(error, createContext);
|
|
});
|
|
}
|
|
};
|
|
|
|
const executeStringSave = async (mutation: PendingStringWrite) => {
|
|
if (mutation.runtimeMode === "demo") {
|
|
startTransition(() => {
|
|
setDemoKeys((current) =>
|
|
updateDemoStringValue(current, mutation.keyName, mutation.nextValue),
|
|
);
|
|
showNeutralBanner(
|
|
`Saved session-local mock string value for ${mutation.keyName} in ${mutation.databaseLabel}. Backend persistence is still unavailable in browser demo mode.`,
|
|
);
|
|
});
|
|
return;
|
|
}
|
|
|
|
startTransition(() => {
|
|
setIsSaveRunning(true);
|
|
});
|
|
|
|
try {
|
|
const result = await invoke<RedisValueRecord>("write_redis_value", {
|
|
request: {
|
|
connection: mutation.connection,
|
|
key: mutation.keyName,
|
|
value: {
|
|
kind: "string",
|
|
value: mutation.nextValue,
|
|
},
|
|
} 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 ${mutation.keyName} in ${mutation.databaseLabel} and preserved the current TTL boundary.`,
|
|
);
|
|
});
|
|
} catch (error) {
|
|
startTransition(() => {
|
|
setIsSaveRunning(false);
|
|
showBackendErrorBanner(error, {
|
|
operation: "value_write",
|
|
connectionName: mutation.connectionName,
|
|
databaseLabel: mutation.databaseLabel,
|
|
keyName: mutation.keyName,
|
|
});
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleRequestSaveString = () => {
|
|
if (!selectedKey) {
|
|
showNeutralBanner(`Select a key in ${activeDb} before attempting a shell-level save.`);
|
|
return;
|
|
}
|
|
|
|
if (selectedStringValue === null || !isEditable) {
|
|
showNeutralBanner(
|
|
`The selected key in ${activeDb} is not currently on an editable string path.`,
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (!hasPendingStringChanges) {
|
|
showNeutralBanner(
|
|
`No string changes are pending for ${selectedKey.name} in ${activeDb}; the draft already matches the current value.`,
|
|
);
|
|
return;
|
|
}
|
|
|
|
startTransition(() => {
|
|
setPendingMutation({
|
|
kind: "value_write",
|
|
runtimeMode: tauriAvailable ? "live" : "demo",
|
|
connection: toRedisConnectionRequest(activeConnection, activeDb),
|
|
connectionName: activeConnection.name,
|
|
databaseLabel: activeDb,
|
|
keyName: selectedKey.name,
|
|
currentValue: selectedStringValue,
|
|
nextValue: draftValue,
|
|
});
|
|
});
|
|
};
|
|
|
|
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 executeTtlUpdate = async (mutation: PendingTtlUpdate) => {
|
|
if (mutation.runtimeMode === "demo") {
|
|
startTransition(() => {
|
|
setDemoKeys((current) =>
|
|
updateDemoKeyTtl(current, mutation.keyName, mutation.nextTtlMillis),
|
|
);
|
|
showNeutralBanner(
|
|
`Updated session-local mock TTL for ${mutation.keyName} in ${mutation.databaseLabel}. Use desktop runtime for a live TTL change.`,
|
|
);
|
|
});
|
|
return;
|
|
}
|
|
|
|
startTransition(() => {
|
|
setIsTtlUpdateRunning(true);
|
|
});
|
|
|
|
try {
|
|
const result = await invoke<RedisKeyTtlUpdateResult>("update_redis_key_ttl", {
|
|
request: {
|
|
connection: mutation.connection,
|
|
key: mutation.keyName,
|
|
operation: {
|
|
kind: "expires_in_millis",
|
|
value: mutation.nextTtlMillis,
|
|
},
|
|
} 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 ${mutation.keyName} to ${formatRedisKeyTtl(result.metadata.ttl)} in ${mutation.databaseLabel}.`,
|
|
);
|
|
});
|
|
} catch (error) {
|
|
startTransition(() => {
|
|
setIsTtlUpdateRunning(false);
|
|
showBackendErrorBanner(error, {
|
|
operation: "ttl_update",
|
|
connectionName: mutation.connectionName,
|
|
databaseLabel: mutation.databaseLabel,
|
|
keyName: mutation.keyName,
|
|
});
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleRequestTtlUpdate = () => {
|
|
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 (currentTtlState === null || nextTtlMillis === null || nextTtlLabel === null) {
|
|
showNeutralBanner(
|
|
`TTL state is not ready for ${selectedKey.name} in ${activeDb}. Refresh the inspector before retrying.`,
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (!hasPendingTtlChanges) {
|
|
showNeutralBanner(
|
|
`TTL for ${selectedKey.name} in ${activeDb} already resolves to ${nextTtlLabel}; no update was sent.`,
|
|
);
|
|
return;
|
|
}
|
|
|
|
startTransition(() => {
|
|
setPendingMutation({
|
|
kind: "ttl_update",
|
|
runtimeMode: tauriAvailable ? "live" : "demo",
|
|
connection: toRedisConnectionRequest(activeConnection, activeDb),
|
|
connectionName: activeConnection.name,
|
|
databaseLabel: activeDb,
|
|
keyName: selectedKey.name,
|
|
currentTtl: selectedKey.ttl,
|
|
nextTtl: nextTtlLabel,
|
|
nextTtlMillis,
|
|
});
|
|
});
|
|
};
|
|
|
|
const executeTtlRemoval = async (mutation: PendingTtlRemoval) => {
|
|
if (mutation.runtimeMode === "demo") {
|
|
startTransition(() => {
|
|
setDemoKeys((current) => persistDemoKeyTtl(current, mutation.keyName));
|
|
setTtlDraft("");
|
|
showNeutralBanner(
|
|
`Removed session-local mock TTL for ${mutation.keyName} in ${mutation.databaseLabel}. Use desktop runtime for a live TTL change.`,
|
|
);
|
|
});
|
|
return;
|
|
}
|
|
|
|
startTransition(() => {
|
|
setIsTtlUpdateRunning(true);
|
|
});
|
|
|
|
try {
|
|
const result = await invoke<RedisKeyTtlUpdateResult>("update_redis_key_ttl", {
|
|
request: {
|
|
connection: mutation.connection,
|
|
key: mutation.keyName,
|
|
operation: {
|
|
kind: "persist",
|
|
},
|
|
} satisfies RedisKeyTtlUpdateRequest,
|
|
});
|
|
|
|
startTransition(() => {
|
|
syncUpdatedMetadata(result.metadata);
|
|
setTtlDraft("");
|
|
setIsTtlUpdateRunning(false);
|
|
showAccentBanner(
|
|
`Removed TTL for ${mutation.keyName}; the key is now ${formatRedisKeyTtl(result.metadata.ttl)}.`,
|
|
);
|
|
});
|
|
} catch (error) {
|
|
startTransition(() => {
|
|
setIsTtlUpdateRunning(false);
|
|
showBackendErrorBanner(error, {
|
|
operation: "ttl_remove",
|
|
connectionName: mutation.connectionName,
|
|
databaseLabel: mutation.databaseLabel,
|
|
keyName: mutation.keyName,
|
|
});
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleRequestPersistTtl = () => {
|
|
if (!selectedKey) {
|
|
showNeutralBanner(`Select a key in ${activeDb} before attempting to remove TTL.`);
|
|
return;
|
|
}
|
|
|
|
if (currentTtlState === null) {
|
|
showNeutralBanner(
|
|
`TTL state is not ready for ${selectedKey.name} in ${activeDb}. Refresh the inspector before retrying.`,
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (!canRemoveTtl(currentTtlState)) {
|
|
showNeutralBanner(
|
|
`${selectedKey.name} in ${activeDb} is already persistent; no TTL removal was sent.`,
|
|
);
|
|
return;
|
|
}
|
|
|
|
startTransition(() => {
|
|
setPendingMutation({
|
|
kind: "ttl_remove",
|
|
runtimeMode: tauriAvailable ? "live" : "demo",
|
|
connection: toRedisConnectionRequest(activeConnection, activeDb),
|
|
connectionName: activeConnection.name,
|
|
databaseLabel: activeDb,
|
|
keyName: selectedKey.name,
|
|
currentTtl: selectedKey.ttl,
|
|
});
|
|
});
|
|
};
|
|
|
|
const handleConfirmPendingMutation = async () => {
|
|
if (!pendingMutation) {
|
|
return;
|
|
}
|
|
|
|
const mutation = pendingMutation;
|
|
setPendingMutation(null);
|
|
|
|
switch (mutation.kind) {
|
|
case "value_write":
|
|
await executeStringSave(mutation);
|
|
return;
|
|
case "ttl_update":
|
|
await executeTtlUpdate(mutation);
|
|
return;
|
|
case "ttl_remove":
|
|
await executeTtlRemoval(mutation);
|
|
return;
|
|
default:
|
|
return;
|
|
}
|
|
};
|
|
|
|
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 databaseLabel = activeDb;
|
|
const request = toRedisConnectionRequest(connection, databaseLabel);
|
|
|
|
startTransition(() => {
|
|
setConnectionTestState({ connectionId: connection.id });
|
|
setConnections((current) => markConnectionTesting(current, connection.id));
|
|
showNeutralBanner(
|
|
`Testing ${connection.name} against ${databaseLabel} 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}.`,
|
|
);
|
|
setConnectionTestState(null);
|
|
});
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const result = await invoke<ConnectionTestResult>("test_redis_connection", {
|
|
request,
|
|
});
|
|
|
|
startTransition(() => {
|
|
setConnections((current) =>
|
|
applyConnectionTestSuccess(current, connection.id, {
|
|
roundTripStatus: result.round_trip_status,
|
|
selectedDatabase: result.selected_database,
|
|
authenticatedAs: result.authenticated_as,
|
|
}),
|
|
);
|
|
setConnectionTestState(null);
|
|
|
|
if (activeConnectionIdRef.current === connection.id) {
|
|
setActiveDb(formatDatabaseLabel(result.selected_database));
|
|
setSelectedKeyId(null);
|
|
showAccentBanner(
|
|
`Live Redis test passed for ${connection.name} on ${formatDatabaseLabel(result.selected_database)}. Round-trip status: ${result.round_trip_status}.`,
|
|
);
|
|
return;
|
|
}
|
|
|
|
showNeutralBanner(
|
|
`Live Redis test passed for ${connection.name}; current workspace stayed on ${activeConnectionNameRef.current} / ${activeDbRef.current}.`,
|
|
);
|
|
});
|
|
} catch (error) {
|
|
const errorCode = formatBackendErrorCodeLabel(toBackendError(error).code);
|
|
|
|
startTransition(() => {
|
|
setConnections((current) => applyConnectionTestFailure(current, connection.id, errorCode));
|
|
setConnectionTestState(null);
|
|
|
|
if (activeConnectionIdRef.current === connection.id) {
|
|
showBackendErrorBanner(error, {
|
|
operation: "connection_test",
|
|
connectionName: connection.name,
|
|
databaseLabel,
|
|
});
|
|
return;
|
|
}
|
|
|
|
showNeutralBanner(
|
|
`Live Redis test failed for ${connection.name}. The current workspace stayed on ${activeConnectionNameRef.current} / ${activeDbRef.current}; reopen ${connection.name} to retry after reviewing its card status.`,
|
|
);
|
|
});
|
|
}
|
|
};
|
|
|
|
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={formatConnectionStatusLabel(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)]">
|
|
{formatConnectionCardDetailLabel(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}
|
|
>
|
|
{activeConnectionTestRunning
|
|
? "Testing..."
|
|
: isConnectionTestRunning
|
|
? "Test running elsewhere"
|
|
: tauriAvailable
|
|
? "Test live"
|
|
: "Test demo"}
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="mt-3 rounded-[22px] border border-[var(--line-soft)] bg-[var(--surface-base)] px-4 py-3">
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div>
|
|
<p className="text-sm font-medium text-[var(--ink-0)]">Connection session</p>
|
|
<p className="mt-1 text-sm text-[var(--ink-soft)]">
|
|
{connectionSessionSummary.summary}
|
|
</p>
|
|
</div>
|
|
<Badge variant={connectionSessionSummary.badgeVariant}>
|
|
{connectionSessionSummary.badgeLabel}
|
|
</Badge>
|
|
</div>
|
|
<p className="mt-3 text-sm text-[var(--ink-soft)]">
|
|
{connectionSessionSummary.hint}
|
|
</p>
|
|
</div>
|
|
</section>
|
|
|
|
<section>
|
|
<SectionEyebrow icon={<Database className="h-4 w-4" />} label="Database switcher" />
|
|
<div className="mt-3 grid gap-2">
|
|
{databaseOptions.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>
|
|
<div className="flex items-center gap-2">
|
|
<Badge variant="neutral">
|
|
{tauriAvailable
|
|
? `${filteredKeys.length}${liveHasMore ? "+" : ""} visible in ${activeDb}`
|
|
: `${filteredKeys.length} of ${demoKeys.length} keys in ${activeDb}`}
|
|
</Badge>
|
|
<Dialog open={addKeyDialogOpen} onOpenChange={setAddKeyDialogOpen}>
|
|
<DialogTrigger asChild>
|
|
<Button size="sm">
|
|
<Plus className="h-4 w-4" />
|
|
Add key
|
|
</Button>
|
|
</DialogTrigger>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<p className="font-mono text-xs uppercase tracking-[0.22em] text-[var(--ink-muted)]">
|
|
Add key
|
|
</p>
|
|
<DialogTitle>Create one bounded string key</DialogTitle>
|
|
<DialogDescription>
|
|
The create flow stays scoped to {activeConnection.name} / {activeDb}, only supports string values, and never overwrites an existing key name.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="flex flex-wrap gap-2">
|
|
<Badge variant="neutral">{activeConnection.name}</Badge>
|
|
<Badge variant="neutral">{activeDb}</Badge>
|
|
<Badge variant="neutral">string only</Badge>
|
|
</div>
|
|
|
|
<div className="grid gap-3">
|
|
<div className="grid gap-2">
|
|
<label className="text-sm font-medium text-[var(--ink-0)]" htmlFor="add-key-name">
|
|
Key name
|
|
</label>
|
|
<Input
|
|
id="add-key-name"
|
|
value={addKeyDraft.name}
|
|
onChange={handleAddKeyDraftChange("name")}
|
|
placeholder="draft:key"
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid gap-2">
|
|
<label className="text-sm font-medium text-[var(--ink-0)]" htmlFor="add-key-value">
|
|
String value
|
|
</label>
|
|
<Textarea
|
|
id="add-key-value"
|
|
value={addKeyDraft.value}
|
|
onChange={handleAddKeyDraftChange("value")}
|
|
className="min-h-32 resize-y font-mono"
|
|
placeholder="hello world"
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid gap-2">
|
|
<label className="text-sm font-medium text-[var(--ink-0)]" htmlFor="add-key-ttl">
|
|
TTL in milliseconds
|
|
</label>
|
|
<Input
|
|
id="add-key-ttl"
|
|
inputMode="numeric"
|
|
value={addKeyDraft.ttlMillis}
|
|
onChange={handleAddKeyDraftChange("ttlMillis")}
|
|
placeholder="Optional, for example 60000"
|
|
/>
|
|
</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)]">
|
|
{tauriAvailable
|
|
? "The live path uses the current create contract with optional TTL and duplicate-name rejection. Failed submits keep the typed form state intact."
|
|
: "Browser demo mode creates a session-local string key only. The same dialog keeps the later live contract shape visible without pretending to hit Redis."}
|
|
</div>
|
|
|
|
{addKeyDraftErrorList.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)]">
|
|
{addKeyDraftErrorList[0]}
|
|
</div>
|
|
) : null}
|
|
|
|
<DialogFooter>
|
|
<DialogClose asChild>
|
|
<Button variant="outline">Cancel</Button>
|
|
</DialogClose>
|
|
<Button onClick={handleCreateKey} disabled={isCreateKeyRunning}>
|
|
{isCreateKeyRunning ? "Creating..." : tauriAvailable ? "Create live key" : "Create demo key"}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
</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" ? (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setSearch("")}
|
|
className="shrink-0 px-2 py-1 text-xs"
|
|
>
|
|
Clear
|
|
</Button>
|
|
) : null}
|
|
</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>
|
|
<Badge variant="accent">{inspectorReadability.status}</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)]">
|
|
{inspectorReadability.status}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="mt-3 rounded-[20px] border border-[var(--line-soft)] bg-[var(--surface-muted)] px-4 py-3">
|
|
<p className="text-sm font-medium text-[var(--ink-0)]">
|
|
{inspectorReadability.surfaceTitle}
|
|
</p>
|
|
<p className="mt-1 text-sm text-[var(--ink-soft)]">
|
|
{inspectorReadability.surfaceDescription}
|
|
</p>
|
|
</div>
|
|
|
|
{selectedKey ? (
|
|
showStructuredInspector ? (
|
|
<StructuredInspectorPanels sections={structuredInspectorSections} />
|
|
) : (
|
|
<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={handleRequestSaveString}
|
|
disabled={
|
|
!selectedKey ||
|
|
!isEditable ||
|
|
!hasPendingStringChanges ||
|
|
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 className="mt-3 rounded-[20px] border border-dashed border-[var(--line-soft)] bg-[var(--surface-muted)] px-4 py-3">
|
|
<p className="font-mono text-[11px] uppercase tracking-[0.18em] text-[var(--ink-muted)]">
|
|
{inspectorReadability.actionTitle}
|
|
</p>
|
|
<p className="mt-2 text-sm text-[var(--ink-soft)]">
|
|
{inspectorReadability.actionDescription}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-4">
|
|
<InspectorSummaryCard
|
|
title="Inspect summary"
|
|
icon={<CircleAlert className="h-4 w-4" />}
|
|
surfaceDescription={inspectorReadability.surfaceDescription}
|
|
actionTitle={inspectorReadability.actionTitle}
|
|
actionDescription={inspectorReadability.actionDescription}
|
|
/>
|
|
<InspectorFactsCard
|
|
title="Key facts"
|
|
icon={<TimerReset className="h-4 w-4" />}
|
|
facts={inspectorReadability.facts}
|
|
/>
|
|
<div className="rounded-[24px] border border-[var(--line-soft)] bg-[var(--surface-base)] p-4">
|
|
<div className="flex items-center gap-2">
|
|
<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={handleRequestTtlUpdate}
|
|
disabled={!canSubmitTtlUpdate}
|
|
>
|
|
{isTtlUpdateRunning ? "Updating..." : "Set TTL"}
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={handleRequestPersistTtl}
|
|
disabled={!canPersistTtl}
|
|
>
|
|
Remove TTL
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</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>
|
|
|
|
<Dialog
|
|
open={pendingMutation !== null}
|
|
onOpenChange={(open) => {
|
|
if (!open) {
|
|
setPendingMutation(null);
|
|
}
|
|
}}
|
|
>
|
|
<DialogContent
|
|
className={
|
|
pendingMutation?.kind === "ttl_remove" ? "border-[var(--danger-soft)]" : undefined
|
|
}
|
|
>
|
|
{pendingMutation !== null && pendingMutationDialog !== null ? (
|
|
<>
|
|
<DialogHeader>
|
|
<div className="flex items-start gap-3">
|
|
<div
|
|
className={cn(
|
|
"rounded-2xl border p-3",
|
|
pendingMutation.kind === "ttl_remove"
|
|
? "border-[var(--danger-soft)] bg-[var(--danger-faint)] text-[var(--danger-strong)]"
|
|
: "border-[var(--accent-soft)] bg-[var(--accent-faint)] text-[var(--accent-strong)]",
|
|
)}
|
|
>
|
|
<ShieldAlert className="h-5 w-5" />
|
|
</div>
|
|
<div>
|
|
<p className="font-mono text-xs uppercase tracking-[0.22em] text-[var(--ink-muted)]">
|
|
Review mutation
|
|
</p>
|
|
<DialogTitle className="mt-2">{pendingMutationDialog.title}</DialogTitle>
|
|
</div>
|
|
</div>
|
|
<DialogDescription>{pendingMutationDialog.description}</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="flex flex-wrap gap-2">
|
|
<Badge variant="neutral">{pendingMutation.connectionName}</Badge>
|
|
<Badge variant="neutral">{pendingMutation.databaseLabel}</Badge>
|
|
<Badge variant="neutral">
|
|
{pendingMutation.runtimeMode === "live" ? "live desktop" : "browser demo"}
|
|
</Badge>
|
|
</div>
|
|
|
|
<div className="grid gap-3 sm:grid-cols-2">
|
|
<div className="rounded-[22px] border border-[var(--line-soft)] bg-[var(--surface-base)] px-4 py-3">
|
|
<p className="font-mono text-[11px] uppercase tracking-[0.18em] text-[var(--ink-muted)]">
|
|
{pendingMutationDialog.currentLabel}
|
|
</p>
|
|
<p className="mt-2 text-sm text-[var(--ink-0)]">
|
|
{pendingMutationDialog.currentValue}
|
|
</p>
|
|
</div>
|
|
<div className="rounded-[22px] border border-[var(--line-soft)] bg-[var(--surface-base)] px-4 py-3">
|
|
<p className="font-mono text-[11px] uppercase tracking-[0.18em] text-[var(--ink-muted)]">
|
|
{pendingMutationDialog.nextLabel}
|
|
</p>
|
|
<p className="mt-2 text-sm text-[var(--ink-0)]">
|
|
{pendingMutationDialog.nextValue}
|
|
</p>
|
|
</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)]">
|
|
{pendingMutationDialog.warning}
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<DialogClose asChild>
|
|
<Button variant="outline" disabled={pendingMutationRunning}>
|
|
Cancel
|
|
</Button>
|
|
</DialogClose>
|
|
<Button
|
|
variant={pendingMutation.kind === "ttl_remove" ? "danger" : "default"}
|
|
onClick={handleConfirmPendingMutation}
|
|
disabled={pendingMutationRunning}
|
|
>
|
|
{pendingMutationRunning ? "Applying..." : pendingMutationDialog.confirmLabel}
|
|
</Button>
|
|
</DialogFooter>
|
|
</>
|
|
) : null}
|
|
</DialogContent>
|
|
</Dialog>
|
|
</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 InspectorSummaryCard({
|
|
title,
|
|
icon,
|
|
surfaceDescription,
|
|
actionTitle,
|
|
actionDescription,
|
|
}: {
|
|
title: string;
|
|
icon: ReactElement;
|
|
surfaceDescription: string;
|
|
actionTitle: string;
|
|
actionDescription: string;
|
|
}) {
|
|
return (
|
|
<div className="rounded-[24px] border border-[var(--line-soft)] bg-[var(--surface-base)] p-4">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-[var(--accent-strong)]">{icon}</span>
|
|
<p className="text-sm font-medium text-[var(--ink-0)]">{title}</p>
|
|
</div>
|
|
|
|
<div className="mt-3 space-y-4">
|
|
<InfoLine label="Current path" value={surfaceDescription} />
|
|
<InfoLine label={actionTitle} value={actionDescription} />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function InspectorFactsCard({
|
|
title,
|
|
icon,
|
|
facts,
|
|
}: {
|
|
title: string;
|
|
icon: ReactElement;
|
|
facts: InspectorFact[];
|
|
}) {
|
|
return (
|
|
<div className="rounded-[24px] border border-[var(--line-soft)] bg-[var(--surface-base)] p-4">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-[var(--accent-strong)]">{icon}</span>
|
|
<p className="text-sm font-medium text-[var(--ink-0)]">{title}</p>
|
|
</div>
|
|
|
|
<div className="mt-3 grid gap-3 sm:grid-cols-2">
|
|
{facts.map((fact) => (
|
|
<InfoLine key={fact.label} label={fact.label} value={fact.value} />
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function StructuredInspectorPanels({
|
|
sections,
|
|
}: {
|
|
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 isMissingTauriCommand(error: unknown, commandName: string) {
|
|
const message =
|
|
typeof error === "object" &&
|
|
error !== null &&
|
|
"message" in error &&
|
|
typeof error.message === "string"
|
|
? error.message
|
|
: error instanceof Error
|
|
? error.message
|
|
: "";
|
|
|
|
return (
|
|
message.includes(commandName) &&
|
|
(message.includes("not found") ||
|
|
message.includes("unknown") ||
|
|
message.includes("does not exist"))
|
|
);
|
|
}
|
|
|
|
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 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 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;
|