feat(desktop): add typed inspector panels
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -45,6 +45,10 @@ import {
|
||||
toBackendErrorNotice,
|
||||
type OperatorNotice,
|
||||
} from "./lib/operator-notices";
|
||||
import {
|
||||
buildStructuredInspectorSections,
|
||||
type StructuredInspectorSection,
|
||||
} from "./lib/structured-inspector";
|
||||
import { cn } from "./lib/utils";
|
||||
|
||||
declare const __APP_VERSION__: string;
|
||||
@@ -439,6 +443,14 @@ function App() {
|
||||
const inspectorText = tauriAvailable
|
||||
? formatInspectorValue(selectedValueRecord, selectedKey, isInspectorLoading)
|
||||
: (selectedKey?.value ?? "");
|
||||
const structuredInspectorSections = tauriAvailable
|
||||
? buildStructuredInspectorSections(selectedValueRecord)
|
||||
: [];
|
||||
const showStructuredInspector =
|
||||
selectedKey !== null &&
|
||||
!isInspectorLoading &&
|
||||
structuredInspectorSections.length > 0 &&
|
||||
!isEditable;
|
||||
|
||||
const supportNote = tauriAvailable
|
||||
? buildLiveSupportNote(selectedValueRecord, selectedKey)
|
||||
@@ -1662,15 +1674,20 @@ function App() {
|
||||
</div>
|
||||
|
||||
{selectedKey ? (
|
||||
<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)]",
|
||||
)}
|
||||
/>
|
||||
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)]">
|
||||
No key matches the current pattern in {activeDb}. Clear or adjust the search to restore an inspector target.
|
||||
@@ -1944,6 +1961,85 @@ function InfoCard({
|
||||
);
|
||||
}
|
||||
|
||||
function StructuredInspectorPanels({
|
||||
sections,
|
||||
}: {
|
||||
sections: StructuredInspectorSection[];
|
||||
}) {
|
||||
return (
|
||||
<div className="mt-3 h-64 space-y-3 overflow-auto pr-1">
|
||||
{sections.map((section) => (
|
||||
<section
|
||||
key={section.id}
|
||||
className="rounded-[22px] border border-[var(--line-soft)] bg-[var(--surface-muted)] p-4"
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--ink-0)]">{section.title}</p>
|
||||
<p className="mt-1 text-sm text-[var(--ink-soft)]">{section.summary}</p>
|
||||
</div>
|
||||
<Badge variant="neutral">{section.rows.length} rows</Badge>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 hidden grid-cols-[minmax(0,0.38fr)_minmax(0,0.62fr)] gap-3 px-1 sm:grid">
|
||||
<p className="font-mono text-[11px] uppercase tracking-[0.18em] text-[var(--ink-muted)]">
|
||||
{section.primaryLabel}
|
||||
</p>
|
||||
<p className="font-mono text-[11px] uppercase tracking-[0.18em] text-[var(--ink-muted)]">
|
||||
{section.secondaryLabel}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 space-y-2">
|
||||
{section.rows.map((row) => (
|
||||
<div
|
||||
key={row.id}
|
||||
className="grid gap-3 rounded-[18px] border border-[var(--line-soft)] bg-[var(--surface-base)] p-3 sm:grid-cols-[minmax(0,0.38fr)_minmax(0,0.62fr)]"
|
||||
>
|
||||
<div>
|
||||
<p className="font-mono text-[11px] uppercase tracking-[0.18em] text-[var(--ink-muted)] sm:hidden">
|
||||
{section.primaryLabel}
|
||||
</p>
|
||||
<p className="mt-1 break-all font-mono text-sm text-[var(--ink-0)] sm:mt-0">
|
||||
{row.primary}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="font-mono text-[11px] uppercase tracking-[0.18em] text-[var(--ink-muted)] sm:hidden">
|
||||
{section.secondaryLabel}
|
||||
</p>
|
||||
<p className="mt-1 break-all font-mono text-sm text-[var(--ink-0)] sm:mt-0">
|
||||
{row.secondary}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{row.detailLines && row.detailLines.length > 0 ? (
|
||||
<div className="sm:col-span-2">
|
||||
<p className="font-mono text-[11px] uppercase tracking-[0.18em] text-[var(--ink-muted)]">
|
||||
Payload
|
||||
</p>
|
||||
<div className="mt-2 space-y-2">
|
||||
{row.detailLines.map((detail) => (
|
||||
<div
|
||||
key={`${row.id}-${detail}`}
|
||||
className="rounded-2xl border border-[var(--line-soft)] bg-[var(--surface-muted)] px-3 py-2 font-mono text-xs text-[var(--ink-soft)]"
|
||||
>
|
||||
{detail}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function isTauriRuntimeAvailable() {
|
||||
return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
|
||||
}
|
||||
@@ -2281,7 +2377,7 @@ function buildLiveSupportNote(record: RedisValueRecord | null, selectedKey: KeyR
|
||||
case "set":
|
||||
case "sorted_set":
|
||||
case "stream":
|
||||
return `${formatRedisKeyTypeLabel(record.metadata.key_type)} values are loaded live and remain explicitly read only in the current desktop contract.`;
|
||||
return `${formatRedisKeyTypeLabel(record.metadata.key_type)} values are loaded live into structured read-only panels in the current desktop contract.`;
|
||||
default:
|
||||
return "The current key is available for live inspection through the desktop backend contract.";
|
||||
}
|
||||
|
||||
99
apps/desktop/src/lib/structured-inspector.test.ts
Normal file
99
apps/desktop/src/lib/structured-inspector.test.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildStructuredInspectorSections } from "./structured-inspector";
|
||||
|
||||
describe("structured inspector sections", () => {
|
||||
it("renders hash entries as field/value rows", () => {
|
||||
expect(
|
||||
buildStructuredInspectorSections({
|
||||
metadata: { key_type: "hash" },
|
||||
data: {
|
||||
kind: "hash",
|
||||
entries: [
|
||||
{
|
||||
field: { kind: "string", value: "region" },
|
||||
value: { kind: "string", value: "eu-west-1" },
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
id: "hash",
|
||||
title: "1 field",
|
||||
summary: "Live HGETALL output rendered as field/value rows.",
|
||||
primaryLabel: "Field",
|
||||
secondaryLabel: "Value",
|
||||
rows: [
|
||||
{
|
||||
id: "hash-0",
|
||||
primary: "region",
|
||||
secondary: "eu-west-1",
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("renders sorted sets and stream entries with readable secondary detail", () => {
|
||||
expect(
|
||||
buildStructuredInspectorSections({
|
||||
metadata: { key_type: "stream" },
|
||||
data: {
|
||||
kind: "stream",
|
||||
entries: [
|
||||
{
|
||||
id: "1711642800000-0",
|
||||
fields: [
|
||||
{
|
||||
field: { kind: "string", value: "event" },
|
||||
value: { kind: "string", value: "ttl-updated" },
|
||||
},
|
||||
{
|
||||
field: { kind: "string", value: "payload" },
|
||||
value: { kind: "binary", bytes: [1, 2, 3, 4] },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
id: "stream",
|
||||
title: "1 entry",
|
||||
summary: "Live XRANGE output rendered as stream entries with inline field payloads.",
|
||||
primaryLabel: "Entry",
|
||||
secondaryLabel: "Fields",
|
||||
rows: [
|
||||
{
|
||||
id: "1711642800000-0",
|
||||
primary: "1711642800000-0",
|
||||
secondary: "2 fields",
|
||||
detailLines: ["event = ttl-updated", "payload = [binary 4 bytes]"],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps string and missing payloads on the existing textarea path", () => {
|
||||
expect(
|
||||
buildStructuredInspectorSections({
|
||||
metadata: { key_type: "string" },
|
||||
data: {
|
||||
kind: "string",
|
||||
value: { kind: "string", value: "hello" },
|
||||
},
|
||||
}),
|
||||
).toEqual([]);
|
||||
|
||||
expect(
|
||||
buildStructuredInspectorSections({
|
||||
metadata: { key_type: "string" },
|
||||
data: {
|
||||
kind: "missing",
|
||||
},
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
155
apps/desktop/src/lib/structured-inspector.ts
Normal file
155
apps/desktop/src/lib/structured-inspector.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
export type RedisValueContent =
|
||||
| { kind: "string"; value: string }
|
||||
| { kind: "binary"; bytes: number[] };
|
||||
|
||||
export type RedisFieldValueEntry = {
|
||||
field: RedisValueContent;
|
||||
value: RedisValueContent;
|
||||
};
|
||||
|
||||
export type RedisSortedSetEntry = {
|
||||
member: RedisValueContent;
|
||||
score: string;
|
||||
};
|
||||
|
||||
export type RedisStreamEntry = {
|
||||
id: string;
|
||||
fields: RedisFieldValueEntry[];
|
||||
};
|
||||
|
||||
export type RedisValueData =
|
||||
| { kind: "missing" }
|
||||
| { kind: "string"; value: RedisValueContent }
|
||||
| { kind: "hash"; entries: RedisFieldValueEntry[] }
|
||||
| { kind: "list"; items: RedisValueContent[] }
|
||||
| { kind: "set"; members: RedisValueContent[] }
|
||||
| { kind: "sorted_set"; entries: RedisSortedSetEntry[] }
|
||||
| { kind: "stream"; entries: RedisStreamEntry[] };
|
||||
|
||||
export type StructuredValueRecord = {
|
||||
metadata: {
|
||||
key_type: string;
|
||||
};
|
||||
data: RedisValueData;
|
||||
};
|
||||
|
||||
export type StructuredInspectorRow = {
|
||||
id: string;
|
||||
primary: string;
|
||||
secondary: string;
|
||||
detailLines?: string[];
|
||||
};
|
||||
|
||||
export type StructuredInspectorSection = {
|
||||
id: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
primaryLabel: string;
|
||||
secondaryLabel: string;
|
||||
rows: StructuredInspectorRow[];
|
||||
};
|
||||
|
||||
export function buildStructuredInspectorSections(
|
||||
record: StructuredValueRecord | null,
|
||||
): StructuredInspectorSection[] {
|
||||
if (!record) {
|
||||
return [];
|
||||
}
|
||||
|
||||
switch (record.data.kind) {
|
||||
case "hash":
|
||||
return [
|
||||
{
|
||||
id: "hash",
|
||||
title: `${record.data.entries.length} field${record.data.entries.length === 1 ? "" : "s"}`,
|
||||
summary: "Live HGETALL output rendered as field/value rows.",
|
||||
primaryLabel: "Field",
|
||||
secondaryLabel: "Value",
|
||||
rows: record.data.entries.map((entry, index) => ({
|
||||
id: `hash-${index}`,
|
||||
primary: formatStructuredContent(entry.field),
|
||||
secondary: formatStructuredContent(entry.value),
|
||||
})),
|
||||
},
|
||||
];
|
||||
case "list":
|
||||
return [
|
||||
{
|
||||
id: "list",
|
||||
title: `${record.data.items.length} item${record.data.items.length === 1 ? "" : "s"}`,
|
||||
summary: "Live LRANGE output rendered in the preserved list order.",
|
||||
primaryLabel: "Index",
|
||||
secondaryLabel: "Value",
|
||||
rows: record.data.items.map((item, index) => ({
|
||||
id: `list-${index}`,
|
||||
primary: `[${index}]`,
|
||||
secondary: formatStructuredContent(item),
|
||||
})),
|
||||
},
|
||||
];
|
||||
case "set":
|
||||
return [
|
||||
{
|
||||
id: "set",
|
||||
title: `${record.data.members.length} member${record.data.members.length === 1 ? "" : "s"}`,
|
||||
summary: "Live SMEMBERS output rendered as unique set members.",
|
||||
primaryLabel: "Member",
|
||||
secondaryLabel: "Value",
|
||||
rows: record.data.members.map((member, index) => ({
|
||||
id: `set-${index}`,
|
||||
primary: `member ${index + 1}`,
|
||||
secondary: formatStructuredContent(member),
|
||||
})),
|
||||
},
|
||||
];
|
||||
case "sorted_set":
|
||||
return [
|
||||
{
|
||||
id: "sorted-set",
|
||||
title: `${record.data.entries.length} scored member${record.data.entries.length === 1 ? "" : "s"}`,
|
||||
summary: "Live ZRANGE ... WITHSCORES output rendered as member/score pairs.",
|
||||
primaryLabel: "Member",
|
||||
secondaryLabel: "Score",
|
||||
rows: record.data.entries.map((entry, index) => ({
|
||||
id: `sorted-set-${index}`,
|
||||
primary: formatStructuredContent(entry.member),
|
||||
secondary: entry.score,
|
||||
})),
|
||||
},
|
||||
];
|
||||
case "stream":
|
||||
return [
|
||||
{
|
||||
id: "stream",
|
||||
title: `${record.data.entries.length} entr${record.data.entries.length === 1 ? "y" : "ies"}`,
|
||||
summary: "Live XRANGE output rendered as stream entries with inline field payloads.",
|
||||
primaryLabel: "Entry",
|
||||
secondaryLabel: "Fields",
|
||||
rows: record.data.entries.map((entry) => ({
|
||||
id: entry.id,
|
||||
primary: entry.id,
|
||||
secondary: `${entry.fields.length} field${entry.fields.length === 1 ? "" : "s"}`,
|
||||
detailLines: entry.fields.map(
|
||||
(field) =>
|
||||
`${formatStructuredContent(field.field)} = ${formatStructuredContent(field.value)}`,
|
||||
),
|
||||
})),
|
||||
},
|
||||
];
|
||||
case "missing":
|
||||
case "string":
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function formatStructuredContent(content: RedisValueContent) {
|
||||
switch (content.kind) {
|
||||
case "string":
|
||||
return content.value;
|
||||
case "binary":
|
||||
return `[binary ${content.bytes.length} bytes]`;
|
||||
default:
|
||||
return "unsupported";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user