@hoox-sh/hoox-tui 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +247 -0
- package/bin/hoox-tui.js +35 -0
- package/dist/main.js +17594 -0
- package/package.json +72 -0
- package/src/app.tsx +696 -0
- package/src/cli-bridge-test-double.ts +212 -0
- package/src/components/layout/layout.test.tsx +205 -0
- package/src/components/layout/sidebar.tsx +69 -0
- package/src/components/layout/statusbar.test.tsx +423 -0
- package/src/components/layout/statusbar.tsx +348 -0
- package/src/components/shared/animated-border.tsx +58 -0
- package/src/components/shared/chrome.test.tsx +29 -0
- package/src/components/shared/command-palette.tsx +361 -0
- package/src/components/shared/cool-brackets.tsx +75 -0
- package/src/components/shared/crash-screen.tsx +167 -0
- package/src/components/shared/error-boundary.tsx +99 -0
- package/src/components/shared/keybinding-hint.tsx +37 -0
- package/src/components/shared/panel.tsx +61 -0
- package/src/components/shared/quit-modal.tsx +65 -0
- package/src/components/shared/shared.test.tsx +538 -0
- package/src/components/shared/spinner.tsx +85 -0
- package/src/components/shared/status-dot.tsx +54 -0
- package/src/components/shared/view-header.tsx +53 -0
- package/src/components/ui/connection-toasts.test.ts +50 -0
- package/src/components/ui/connection-toasts.ts +215 -0
- package/src/components/ui/dialog.tsx +281 -0
- package/src/components/ui/select.tsx +197 -0
- package/src/components/ui/toast.tsx +185 -0
- package/src/components/views/ai-chat.test.tsx +81 -0
- package/src/components/views/ai-chat.tsx +576 -0
- package/src/components/views/config-editor/action-bar.tsx +102 -0
- package/src/components/views/config-editor/code-editor.tsx +597 -0
- package/src/components/views/config-editor/file-tree.tsx +113 -0
- package/src/components/views/config-editor/types.ts +37 -0
- package/src/components/views/config-editor.test.tsx +465 -0
- package/src/components/views/config-editor.tsx +709 -0
- package/src/components/views/dashboard/alerts-panel.tsx +154 -0
- package/src/components/views/dashboard/auto-repair-panel.tsx +229 -0
- package/src/components/views/dashboard/model-health-section.tsx +276 -0
- package/src/components/views/dashboard/pyne-health-section.tsx +112 -0
- package/src/components/views/dashboard.test.tsx +347 -0
- package/src/components/views/dashboard.tsx +647 -0
- package/src/components/views/db-query.test.tsx +115 -0
- package/src/components/views/db-query.tsx +849 -0
- package/src/components/views/edge-topology.test.tsx +198 -0
- package/src/components/views/edge-topology.tsx +572 -0
- package/src/components/views/kv-viewer.test.tsx +241 -0
- package/src/components/views/kv-viewer.tsx +717 -0
- package/src/components/views/logs-viewer.test.tsx +273 -0
- package/src/components/views/logs-viewer.tsx +616 -0
- package/src/components/views/queue-depth.test.tsx +221 -0
- package/src/components/views/queue-depth.tsx +376 -0
- package/src/components/views/secrets-viewer.test.tsx +65 -0
- package/src/components/views/secrets-viewer.tsx +548 -0
- package/src/components/views/service-manager.test.tsx +358 -0
- package/src/components/views/service-manager.tsx +946 -0
- package/src/components/views/settings.test.tsx +648 -0
- package/src/components/views/settings.tsx +1497 -0
- package/src/components/views/setup-wizard.test.tsx +297 -0
- package/src/components/views/setup-wizard.tsx +1321 -0
- package/src/components/views/trade-monitor.test.tsx +592 -0
- package/src/components/views/trade-monitor.tsx +745 -0
- package/src/components/views/worker-detail.test.tsx +289 -0
- package/src/components/views/worker-detail.tsx +676 -0
- package/src/components/views/worker-settings.test.tsx +43 -0
- package/src/components/views/worker-settings.tsx +563 -0
- package/src/components/views/workers-overview.test.tsx +260 -0
- package/src/components/views/workers-overview.tsx +631 -0
- package/src/hooks/hooks.test.ts +72 -0
- package/src/hooks/index.ts +11 -0
- package/src/hooks/renderer-ref.ts +22 -0
- package/src/hooks/use-keyboard.ts +130 -0
- package/src/hooks/use-polling.ts +73 -0
- package/src/hooks/use-service-data.ts +18 -0
- package/src/main.tsx +117 -0
- package/src/network-test-double.ts +133 -0
- package/src/opentui-augment.d.ts +45 -0
- package/src/opentui-jsx.d.ts +70 -0
- package/src/services/cli-bridge/index.ts +1662 -0
- package/src/services/cli-bridge/standalone.ts +315 -0
- package/src/services/cli-bridge/types.ts +307 -0
- package/src/services/cli-bridge.test.ts +406 -0
- package/src/services/clipboard.test.ts +57 -0
- package/src/services/clipboard.ts +180 -0
- package/src/services/dashboard-settings-loader.ts +31 -0
- package/src/services/dev-log.test.ts +168 -0
- package/src/services/dev-log.ts +194 -0
- package/src/services/hoox-path-service.test.ts +183 -0
- package/src/services/hoox-path-service.ts +185 -0
- package/src/services/tui-connection.test.ts +212 -0
- package/src/services/tui-connection.ts +236 -0
- package/src/services/tui-storage.test.ts +92 -0
- package/src/services/tui-storage.ts +86 -0
- package/src/stores/config-store.test.ts +360 -0
- package/src/stores/service-store.test.ts +1026 -0
- package/src/stores/session-restore.test.ts +134 -0
- package/src/stores/ui-store.test.ts +276 -0
- package/src/test-setup.ts +265 -0
- package/src/test-utils.ts +179 -0
- package/src/types.ts +153 -0
- package/src/utils/colors.test.ts +326 -0
- package/src/utils/formatters.test.ts +236 -0
- package/src/view-registry.test.ts +132 -0
- package/src/view-registry.tsx +368 -0
|
@@ -0,0 +1,717 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2026 HOOX · HOOX · jango-blockchained (hoox-sh)
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** @jsxImportSource @opentui/react */
|
|
7
|
+
/**
|
|
8
|
+
* KV Viewer — Read-only dashboard for the Cloudflare CONFIG_KV namespace.
|
|
9
|
+
*
|
|
10
|
+
* Layout:
|
|
11
|
+
* ┌───────────────────────────────────────────────────────────┐
|
|
12
|
+
* │ KV VIEWER 12 keys · 3 secret · ◉ 5s auto [⚠ read-only]
|
|
13
|
+
* │───────────────────────────────────────────────────────────
|
|
14
|
+
* │ [search…] Last sampled 12:34:56
|
|
15
|
+
* │───────────────────────────────────────────────────────────
|
|
16
|
+
* │ KEY NAME SIZE TYPE MOD STATUS
|
|
17
|
+
* │ trade:kill_switch 4 B bool — SECRET
|
|
18
|
+
* │ agent:openai_key 51 B str — SECRET
|
|
19
|
+
* │ routing:dynamic:enabled 4 B bool — OK
|
|
20
|
+
* │ … │
|
|
21
|
+
* │───────────────────────────────────────────────────────────
|
|
22
|
+
* │ ! Values are read-only in the TUI. Use `hoox config kv …` │
|
|
23
|
+
* │ for writes. Secret values are flagged with a banner. │
|
|
24
|
+
* │ [REFRESH] │
|
|
25
|
+
* └───────────────────────────────────────────────────────────┘
|
|
26
|
+
*
|
|
27
|
+
* Pattern established for the TUI feature-parity batch:
|
|
28
|
+
* - Pure function component, no props required
|
|
29
|
+
* - Subscribes to useUIStore (so auto-refresh can pause when not active)
|
|
30
|
+
* - Calls cliBridge.configKvList() on mount + every 5s while active
|
|
31
|
+
* - Wraps content in <ErrorBoundary viewName="KV Viewer">
|
|
32
|
+
* - Renders an empty/error state instead of throwing
|
|
33
|
+
* - Read-only: write operations are intentionally *not* exposed
|
|
34
|
+
* here. The TUI never sends `kv set` / `kv delete` — those are
|
|
35
|
+
* CLI-only.
|
|
36
|
+
*
|
|
37
|
+
* Security:
|
|
38
|
+
* - Secret keys (per the KV manifest) are flagged with a banner.
|
|
39
|
+
* - Values are read on-demand via cliBridge.configKvGet(); the
|
|
40
|
+
* banner is shown before the first reveal.
|
|
41
|
+
* - We deliberately do not mask values — the user explicitly opted
|
|
42
|
+
* in to viewing them. The CLI equivalent (`hoox config kv get`)
|
|
43
|
+
* is the same: no masking.
|
|
44
|
+
*
|
|
45
|
+
* Search/filter:
|
|
46
|
+
* - Case-insensitive substring match against the key name.
|
|
47
|
+
* - The total key count in the header reflects the *unfiltered* list
|
|
48
|
+
* while the table shows the filtered count.
|
|
49
|
+
*/
|
|
50
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
51
|
+
import { useKeyboard } from "@opentui/react";
|
|
52
|
+
import { Colors, useUIStore } from "@hoox-sh/hoox-shared";
|
|
53
|
+
import { ErrorBoundary } from "../shared/error-boundary";
|
|
54
|
+
import { Spinner, EmptyState } from "../shared/spinner";
|
|
55
|
+
import { ViewHeader } from "../shared/view-header";
|
|
56
|
+
import { cliBridge } from "../../services/cli-bridge";
|
|
57
|
+
import type { KvKey, KvKeySnapshot } from "../../services/cli-bridge";
|
|
58
|
+
|
|
59
|
+
/** Auto-refresh interval in milliseconds. */
|
|
60
|
+
export const REFRESH_INTERVAL_MS = 5_000;
|
|
61
|
+
|
|
62
|
+
/** Cap rendered rows so large namespaces stay responsive. */
|
|
63
|
+
export const MAX_VISIBLE_KEYS = 200;
|
|
64
|
+
|
|
65
|
+
/** Search debounce for filter recompute. */
|
|
66
|
+
const SEARCH_DEBOUNCE_MS = 150;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Strip control characters from KV values before terminal display.
|
|
70
|
+
* Values are user-requested (reveal), but must not inject ESC sequences.
|
|
71
|
+
*/
|
|
72
|
+
export function sanitizeKvValue(value: string, maxLen = 4_000): string {
|
|
73
|
+
const cleaned = value.replace(
|
|
74
|
+
/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g,
|
|
75
|
+
""
|
|
76
|
+
);
|
|
77
|
+
if (cleaned.length <= maxLen) return cleaned;
|
|
78
|
+
return cleaned.slice(0, maxLen - 1) + "\u2026";
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Case-insensitive key name filter. */
|
|
82
|
+
export function filterKvKeys(keys: readonly KvKey[], search: string): KvKey[] {
|
|
83
|
+
const q = search.trim().toLowerCase();
|
|
84
|
+
if (q.length === 0) return keys as KvKey[];
|
|
85
|
+
return keys.filter((k) => k.name.toLowerCase().includes(q));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Format a byte size as a human-readable string (`4 B`, `1.2 KB`, …).
|
|
90
|
+
* Used in the SIZE column. `null` renders as `—`.
|
|
91
|
+
*/
|
|
92
|
+
function formatSize(bytes: number | null): string {
|
|
93
|
+
if (bytes === null) return "—";
|
|
94
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
95
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
96
|
+
return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Render the `lastModified` timestamp as HH:MM:SS on today's date, or `—`. */
|
|
100
|
+
function formatTime(iso: string | null): string {
|
|
101
|
+
if (iso === null) return "—";
|
|
102
|
+
const ms = Date.parse(iso);
|
|
103
|
+
if (Number.isNaN(ms)) return "—";
|
|
104
|
+
const d = new Date(ms);
|
|
105
|
+
const hh = d.getHours().toString().padStart(2, "0");
|
|
106
|
+
const mm = d.getMinutes().toString().padStart(2, "0");
|
|
107
|
+
const ss = d.getSeconds().toString().padStart(2, "0");
|
|
108
|
+
return `${hh}:${mm}:${ss}`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Render the type column. `null` becomes `—`. */
|
|
112
|
+
function formatType(t: KvKey["manifestType"]): string {
|
|
113
|
+
if (t === null) return "—";
|
|
114
|
+
return t;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Pick a color for a row based on whether the key is secret. */
|
|
118
|
+
function rowColor(isSecret: boolean): string {
|
|
119
|
+
return isSecret ? Colors.warning : Colors.foreground;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ─── Sub-component: Single KV Row ─────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
interface KvRowProps {
|
|
125
|
+
keyName: string;
|
|
126
|
+
size: string;
|
|
127
|
+
type: string;
|
|
128
|
+
modified: string;
|
|
129
|
+
isSecret: boolean;
|
|
130
|
+
isSelected: boolean;
|
|
131
|
+
onSelect: (name: string) => void;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function KvRow({
|
|
135
|
+
keyName,
|
|
136
|
+
size,
|
|
137
|
+
type,
|
|
138
|
+
modified,
|
|
139
|
+
isSecret,
|
|
140
|
+
isSelected,
|
|
141
|
+
onSelect,
|
|
142
|
+
}: KvRowProps) {
|
|
143
|
+
const fg = isSelected ? Colors.accent : rowColor(isSecret);
|
|
144
|
+
const status = isSecret ? "SECRET" : "OK";
|
|
145
|
+
const statusColor = isSecret ? Colors.warning : Colors.success;
|
|
146
|
+
return (
|
|
147
|
+
<box
|
|
148
|
+
flexDirection="row"
|
|
149
|
+
gap={2}
|
|
150
|
+
paddingLeft={1}
|
|
151
|
+
paddingRight={1}
|
|
152
|
+
alignItems="center"
|
|
153
|
+
backgroundColor={isSelected ? Colors.card : undefined}
|
|
154
|
+
onMouseUp={() => onSelect(keyName)}
|
|
155
|
+
>
|
|
156
|
+
<text fg={fg} bold={isSelected}>
|
|
157
|
+
{keyName}
|
|
158
|
+
</text>
|
|
159
|
+
<text fg={Colors.muted} dim>
|
|
160
|
+
{size.padStart(8, " ")}
|
|
161
|
+
</text>
|
|
162
|
+
<text fg={Colors.muted}>{type.padStart(4, " ")}</text>
|
|
163
|
+
<text fg={Colors.muted} dim>
|
|
164
|
+
{modified.padStart(8, " ")}
|
|
165
|
+
</text>
|
|
166
|
+
<text fg={statusColor} bold>
|
|
167
|
+
{status}
|
|
168
|
+
</text>
|
|
169
|
+
</box>
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ─── Sub-component: Detail Pane (selected key + value) ────────────────────────
|
|
174
|
+
|
|
175
|
+
interface DetailPaneProps {
|
|
176
|
+
selectedKey: KvKey | null;
|
|
177
|
+
value: string | null;
|
|
178
|
+
loading: boolean;
|
|
179
|
+
valueError: string | null;
|
|
180
|
+
onReveal: () => void;
|
|
181
|
+
onHide: () => void;
|
|
182
|
+
revealed: boolean;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function DetailPane({
|
|
186
|
+
selectedKey,
|
|
187
|
+
value,
|
|
188
|
+
loading,
|
|
189
|
+
valueError,
|
|
190
|
+
onReveal,
|
|
191
|
+
onHide,
|
|
192
|
+
revealed,
|
|
193
|
+
}: DetailPaneProps) {
|
|
194
|
+
if (!selectedKey) {
|
|
195
|
+
return (
|
|
196
|
+
<box
|
|
197
|
+
flexDirection="column"
|
|
198
|
+
flexGrow={1}
|
|
199
|
+
gap={1}
|
|
200
|
+
border={true}
|
|
201
|
+
borderStyle="single"
|
|
202
|
+
borderColor={Colors.border}
|
|
203
|
+
paddingX={1}
|
|
204
|
+
paddingY={0}
|
|
205
|
+
>
|
|
206
|
+
<text fg={Colors.muted} dim>
|
|
207
|
+
Select a key to view its value.
|
|
208
|
+
</text>
|
|
209
|
+
<text fg={Colors.dim} dim>
|
|
210
|
+
↑↓ navigate · Enter reveal · Esc clear
|
|
211
|
+
</text>
|
|
212
|
+
</box>
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return (
|
|
217
|
+
<box
|
|
218
|
+
flexDirection="column"
|
|
219
|
+
flexGrow={1}
|
|
220
|
+
gap={1}
|
|
221
|
+
border={true}
|
|
222
|
+
borderStyle="single"
|
|
223
|
+
borderColor={Colors.border}
|
|
224
|
+
paddingX={1}
|
|
225
|
+
paddingY={0}
|
|
226
|
+
>
|
|
227
|
+
{/* Selected key header */}
|
|
228
|
+
<box flexDirection="row" gap={2} alignItems="center">
|
|
229
|
+
<text fg={Colors.accent} bold>
|
|
230
|
+
{selectedKey.name}
|
|
231
|
+
</text>
|
|
232
|
+
{selectedKey.isSecret && (
|
|
233
|
+
<text fg={Colors.warning} bold>
|
|
234
|
+
⚠ SECRET
|
|
235
|
+
</text>
|
|
236
|
+
)}
|
|
237
|
+
</box>
|
|
238
|
+
<text fg={Colors.border} dim>
|
|
239
|
+
{"─".repeat(60)}
|
|
240
|
+
</text>
|
|
241
|
+
|
|
242
|
+
{/* Secret warning before reveal */}
|
|
243
|
+
{selectedKey.isSecret && !revealed && (
|
|
244
|
+
<box flexDirection="column" gap={1} paddingLeft={1}>
|
|
245
|
+
<text fg={Colors.warning} bold>
|
|
246
|
+
⚠ This key may contain a secret value (API key, password, etc.).
|
|
247
|
+
</text>
|
|
248
|
+
<box flexDirection="row" gap={1}>
|
|
249
|
+
<text fg={Colors.muted}>
|
|
250
|
+
The value will be displayed as plain text. Press
|
|
251
|
+
</text>
|
|
252
|
+
<text fg={Colors.accent}>[REVEAL]</text>
|
|
253
|
+
<text fg={Colors.muted}>to view it.</text>
|
|
254
|
+
</box>
|
|
255
|
+
</box>
|
|
256
|
+
)}
|
|
257
|
+
|
|
258
|
+
{/* Value display */}
|
|
259
|
+
{revealed ? (
|
|
260
|
+
<box flexDirection="column" gap={1} paddingLeft={1} flexGrow={1}>
|
|
261
|
+
{loading ? (
|
|
262
|
+
<box alignItems="center" justifyContent="center" flexGrow={1}>
|
|
263
|
+
<Spinner label="Loading value..." />
|
|
264
|
+
</box>
|
|
265
|
+
) : valueError ? (
|
|
266
|
+
<text fg={Colors.error}>! {valueError}</text>
|
|
267
|
+
) : value === null ? (
|
|
268
|
+
<text fg={Colors.muted} dim>
|
|
269
|
+
(empty value)
|
|
270
|
+
</text>
|
|
271
|
+
) : (
|
|
272
|
+
<text fg={Colors.foreground}>{sanitizeKvValue(value)}</text>
|
|
273
|
+
)}
|
|
274
|
+
</box>
|
|
275
|
+
) : null}
|
|
276
|
+
|
|
277
|
+
{/* Action buttons */}
|
|
278
|
+
<box flexDirection="row" gap={2} paddingLeft={1}>
|
|
279
|
+
{revealed ? (
|
|
280
|
+
<text fg={Colors.muted} onMouseUp={onHide}>
|
|
281
|
+
[HIDE]
|
|
282
|
+
</text>
|
|
283
|
+
) : (
|
|
284
|
+
<text fg={Colors.accent} bold onMouseUp={onReveal}>
|
|
285
|
+
[REVEAL]
|
|
286
|
+
</text>
|
|
287
|
+
)}
|
|
288
|
+
</box>
|
|
289
|
+
</box>
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// ─── Sub-component: Search Box ────────────────────────────────────────────────
|
|
294
|
+
|
|
295
|
+
interface SearchBoxProps {
|
|
296
|
+
query: string;
|
|
297
|
+
onChange: (next: string) => void;
|
|
298
|
+
active: boolean;
|
|
299
|
+
onActivate: () => void;
|
|
300
|
+
onDeactivate: () => void;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function SearchBox({
|
|
304
|
+
query,
|
|
305
|
+
onChange,
|
|
306
|
+
active,
|
|
307
|
+
onActivate,
|
|
308
|
+
onDeactivate,
|
|
309
|
+
}: SearchBoxProps) {
|
|
310
|
+
useKeyboard((key) => {
|
|
311
|
+
if (!active) return;
|
|
312
|
+
if (key.name === "escape") {
|
|
313
|
+
onDeactivate();
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
if (key.name === "backspace" || key.name === "delete") {
|
|
317
|
+
onChange(query.slice(0, -1));
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
if (key.name === "return") {
|
|
321
|
+
onDeactivate();
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
if (key.sequence && key.sequence.length === 1 && key.sequence >= " ") {
|
|
325
|
+
onChange(query + key.sequence);
|
|
326
|
+
}
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
return (
|
|
330
|
+
<box
|
|
331
|
+
flexDirection="row"
|
|
332
|
+
gap={1}
|
|
333
|
+
paddingLeft={1}
|
|
334
|
+
paddingRight={1}
|
|
335
|
+
alignItems="center"
|
|
336
|
+
border={true}
|
|
337
|
+
borderStyle="single"
|
|
338
|
+
borderColor={active ? Colors.accent : Colors.border}
|
|
339
|
+
onMouseUp={onActivate}
|
|
340
|
+
>
|
|
341
|
+
<text fg={Colors.muted} dim>
|
|
342
|
+
search:
|
|
343
|
+
</text>
|
|
344
|
+
<text fg={Colors.foreground}>
|
|
345
|
+
{query.length > 0 ? query : active ? "_" : "(press / to filter)"}
|
|
346
|
+
</text>
|
|
347
|
+
</box>
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// ─── Main KvViewer View ──────────────────────────────────────────────────────
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* KvViewer — Main view showing the CONFIG_KV namespace keys.
|
|
355
|
+
*
|
|
356
|
+
* The view follows the same architectural pattern as the queue-depth
|
|
357
|
+
* viewer (see `queue-depth.tsx` for the original):
|
|
358
|
+
* 1. Pure function component, no required props
|
|
359
|
+
* 2. Subscribes to `useUIStore.activeView` to pause auto-refresh
|
|
360
|
+
* when the user navigates away
|
|
361
|
+
* 3. Calls a `cliBridge.<method>()` on mount + on a fixed interval
|
|
362
|
+
* (5s here) — refreshes can also be triggered manually via a
|
|
363
|
+
* button
|
|
364
|
+
* 4. Renders an explicit empty/error state instead of throwing
|
|
365
|
+
* 5. Wraps in <ErrorBoundary viewName="KV Viewer"> so a render bug
|
|
366
|
+
* in this view never crashes the whole TUI
|
|
367
|
+
*/
|
|
368
|
+
export function KvViewer() {
|
|
369
|
+
const activeView = useUIStore((s) => s.activeView);
|
|
370
|
+
const isActive = activeView === "kv-viewer";
|
|
371
|
+
|
|
372
|
+
const [snapshot, setSnapshot] = useState<KvKeySnapshot | null>(null);
|
|
373
|
+
const [loading, setLoading] = useState(false);
|
|
374
|
+
const [error, setError] = useState<string | null>(null);
|
|
375
|
+
const [search, setSearch] = useState("");
|
|
376
|
+
const [debouncedSearch, setDebouncedSearch] = useState("");
|
|
377
|
+
const [searchActive, setSearchActive] = useState(false);
|
|
378
|
+
const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
379
|
+
|
|
380
|
+
const [selectedKeyName, setSelectedKeyName] = useState<string | null>(null);
|
|
381
|
+
const [revealed, setRevealed] = useState(false);
|
|
382
|
+
const [value, setValue] = useState<string | null>(null);
|
|
383
|
+
const [valueLoading, setValueLoading] = useState(false);
|
|
384
|
+
const [valueError, setValueError] = useState<string | null>(null);
|
|
385
|
+
|
|
386
|
+
const listGenRef = useRef(0);
|
|
387
|
+
const revealGenRef = useRef(0);
|
|
388
|
+
|
|
389
|
+
// Debounce search
|
|
390
|
+
useEffect(() => {
|
|
391
|
+
if (searchTimerRef.current) clearTimeout(searchTimerRef.current);
|
|
392
|
+
searchTimerRef.current = setTimeout(() => {
|
|
393
|
+
setDebouncedSearch(search);
|
|
394
|
+
searchTimerRef.current = null;
|
|
395
|
+
}, SEARCH_DEBOUNCE_MS);
|
|
396
|
+
return () => {
|
|
397
|
+
if (searchTimerRef.current) {
|
|
398
|
+
clearTimeout(searchTimerRef.current);
|
|
399
|
+
searchTimerRef.current = null;
|
|
400
|
+
}
|
|
401
|
+
};
|
|
402
|
+
}, [search]);
|
|
403
|
+
|
|
404
|
+
// ── Fetch handler ─────────────────────────────────────────────────────
|
|
405
|
+
const refresh = useCallback(async (opts?: { soft?: boolean }) => {
|
|
406
|
+
const soft = opts?.soft === true;
|
|
407
|
+
const gen = ++listGenRef.current;
|
|
408
|
+
if (!soft) setLoading(true);
|
|
409
|
+
const result = await cliBridge.configKvList();
|
|
410
|
+
if (gen !== listGenRef.current) return;
|
|
411
|
+
if (result.success && result.data) {
|
|
412
|
+
setSnapshot(result.data);
|
|
413
|
+
setError(null);
|
|
414
|
+
} else {
|
|
415
|
+
setError(result.stderr || result.stdout || "Failed to read KV keys");
|
|
416
|
+
}
|
|
417
|
+
setLoading(false);
|
|
418
|
+
}, []);
|
|
419
|
+
|
|
420
|
+
// Initial load on mount + invalidate in-flight on unmount.
|
|
421
|
+
useEffect(() => {
|
|
422
|
+
void refresh({ soft: false });
|
|
423
|
+
return () => {
|
|
424
|
+
listGenRef.current += 1;
|
|
425
|
+
revealGenRef.current += 1;
|
|
426
|
+
};
|
|
427
|
+
}, [refresh]);
|
|
428
|
+
|
|
429
|
+
// Auto-refresh every 5s while the view is the active view (soft = no flicker).
|
|
430
|
+
useEffect(() => {
|
|
431
|
+
if (!isActive) return;
|
|
432
|
+
const handle = setInterval(() => {
|
|
433
|
+
void refresh({ soft: true });
|
|
434
|
+
}, REFRESH_INTERVAL_MS);
|
|
435
|
+
return () => clearInterval(handle);
|
|
436
|
+
}, [isActive, refresh]);
|
|
437
|
+
|
|
438
|
+
// ── Derived data ─────────────────────────────────────────────────────
|
|
439
|
+
const allKeys = snapshot?.keys ?? [];
|
|
440
|
+
const filteredKeys = useMemo(
|
|
441
|
+
() => filterKvKeys(allKeys, debouncedSearch),
|
|
442
|
+
[allKeys, debouncedSearch]
|
|
443
|
+
);
|
|
444
|
+
const visibleKeys = useMemo(
|
|
445
|
+
() => filteredKeys.slice(0, MAX_VISIBLE_KEYS),
|
|
446
|
+
[filteredKeys]
|
|
447
|
+
);
|
|
448
|
+
|
|
449
|
+
const secretCount = useMemo(
|
|
450
|
+
() => allKeys.filter((k) => k.isSecret).length,
|
|
451
|
+
[allKeys]
|
|
452
|
+
);
|
|
453
|
+
|
|
454
|
+
const selectedKey = useMemo(() => {
|
|
455
|
+
if (!selectedKeyName) return null;
|
|
456
|
+
return allKeys.find((k) => k.name === selectedKeyName) ?? null;
|
|
457
|
+
}, [allKeys, selectedKeyName]);
|
|
458
|
+
|
|
459
|
+
// When the selected key changes, reset the reveal/value state and
|
|
460
|
+
// invalidate any in-flight value fetch for the previous key.
|
|
461
|
+
useEffect(() => {
|
|
462
|
+
revealGenRef.current += 1;
|
|
463
|
+
setRevealed(false);
|
|
464
|
+
setValue(null);
|
|
465
|
+
setValueError(null);
|
|
466
|
+
setValueLoading(false);
|
|
467
|
+
}, [selectedKeyName]);
|
|
468
|
+
|
|
469
|
+
// ── Detail pane callbacks ────────────────────────────────────────────
|
|
470
|
+
const handleReveal = useCallback(async () => {
|
|
471
|
+
if (!selectedKey) return;
|
|
472
|
+
// Secret keys require explicit reveal; non-secrets also use reveal UX
|
|
473
|
+
// so we never auto-fetch values on selection (sensitive-value handling).
|
|
474
|
+
const gen = ++revealGenRef.current;
|
|
475
|
+
setRevealed(true);
|
|
476
|
+
setValueLoading(true);
|
|
477
|
+
setValueError(null);
|
|
478
|
+
const result = await cliBridge.configKvGet(selectedKey.name);
|
|
479
|
+
if (gen !== revealGenRef.current) return;
|
|
480
|
+
if (result.success) {
|
|
481
|
+
setValue(result.data ?? null);
|
|
482
|
+
} else {
|
|
483
|
+
setValueError(result.stderr || result.stdout || "Failed to read value");
|
|
484
|
+
}
|
|
485
|
+
setValueLoading(false);
|
|
486
|
+
}, [selectedKey]);
|
|
487
|
+
|
|
488
|
+
const handleHide = useCallback(() => {
|
|
489
|
+
revealGenRef.current += 1; // cancel in-flight get
|
|
490
|
+
setRevealed(false);
|
|
491
|
+
setValue(null);
|
|
492
|
+
setValueError(null);
|
|
493
|
+
setValueLoading(false);
|
|
494
|
+
}, []);
|
|
495
|
+
|
|
496
|
+
// ── Global keyboard handling (active only when this view is on top) ──
|
|
497
|
+
useKeyboard((key) => {
|
|
498
|
+
if (!isActive) return;
|
|
499
|
+
if (searchActive) return; // SearchBox handles its own keys.
|
|
500
|
+
if (key.name === "slash" || (key.ctrl && key.name === "f")) {
|
|
501
|
+
setSearchActive(true);
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
if (key.name === "escape") {
|
|
505
|
+
if (selectedKeyName) setSelectedKeyName(null);
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
if (key.name === "up") {
|
|
509
|
+
if (visibleKeys.length === 0) return;
|
|
510
|
+
const idx = selectedKeyName
|
|
511
|
+
? visibleKeys.findIndex((k) => k.name === selectedKeyName)
|
|
512
|
+
: -1;
|
|
513
|
+
const next = Math.max(0, idx - 1);
|
|
514
|
+
setSelectedKeyName(visibleKeys[next]?.name ?? null);
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
if (key.name === "down") {
|
|
518
|
+
if (visibleKeys.length === 0) return;
|
|
519
|
+
const idx = selectedKeyName
|
|
520
|
+
? visibleKeys.findIndex((k) => k.name === selectedKeyName)
|
|
521
|
+
: -1;
|
|
522
|
+
const next = Math.min(visibleKeys.length - 1, idx + 1);
|
|
523
|
+
setSelectedKeyName(visibleKeys[next]?.name ?? null);
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
if (key.name === "return") {
|
|
527
|
+
if (selectedKey && !revealed) {
|
|
528
|
+
void handleReveal();
|
|
529
|
+
}
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
// ── Render ────────────────────────────────────────────────────────────
|
|
535
|
+
return (
|
|
536
|
+
<ErrorBoundary viewName="KV Viewer">
|
|
537
|
+
<box flexDirection="column" flexGrow={1} padding={1} gap={1}>
|
|
538
|
+
<ViewHeader
|
|
539
|
+
title="KV VIEWER"
|
|
540
|
+
showDivider={false}
|
|
541
|
+
meta={
|
|
542
|
+
<box flexDirection="row" gap={2} alignItems="center">
|
|
543
|
+
<text fg={Colors.muted} dim>
|
|
544
|
+
{`${allKeys.length} key${allKeys.length === 1 ? "" : "s"} · ${secretCount} secret`}
|
|
545
|
+
</text>
|
|
546
|
+
{debouncedSearch.length > 0 ? (
|
|
547
|
+
<text fg={Colors.info} dim>
|
|
548
|
+
{`(${filteredKeys.length} match${filteredKeys.length === 1 ? "" : "es"})`}
|
|
549
|
+
</text>
|
|
550
|
+
) : null}
|
|
551
|
+
{filteredKeys.length > MAX_VISIBLE_KEYS ? (
|
|
552
|
+
<text fg={Colors.muted} dim>
|
|
553
|
+
{`showing ${MAX_VISIBLE_KEYS}`}
|
|
554
|
+
</text>
|
|
555
|
+
) : null}
|
|
556
|
+
<text fg={Colors.warning} bold>
|
|
557
|
+
⚠ read-only
|
|
558
|
+
</text>
|
|
559
|
+
<text fg={Colors.info} dim>
|
|
560
|
+
{`◉ ${REFRESH_INTERVAL_MS / 1000}s auto`}
|
|
561
|
+
</text>
|
|
562
|
+
</box>
|
|
563
|
+
}
|
|
564
|
+
/>
|
|
565
|
+
|
|
566
|
+
{/* Search + last sampled row */}
|
|
567
|
+
<box flexDirection="row" gap={1} alignItems="center">
|
|
568
|
+
<SearchBox
|
|
569
|
+
query={search}
|
|
570
|
+
onChange={setSearch}
|
|
571
|
+
active={searchActive}
|
|
572
|
+
onActivate={() => setSearchActive(true)}
|
|
573
|
+
onDeactivate={() => setSearchActive(false)}
|
|
574
|
+
/>
|
|
575
|
+
<text fg={Colors.muted} dim>
|
|
576
|
+
{snapshot
|
|
577
|
+
? `Last sampled ${formatTime(snapshot.timestamp)}`
|
|
578
|
+
: "Last sampled —"}
|
|
579
|
+
</text>
|
|
580
|
+
</box>
|
|
581
|
+
|
|
582
|
+
{/* Main content: list + detail */}
|
|
583
|
+
<box flexDirection="row" flexGrow={1} gap={1}>
|
|
584
|
+
{/* Left pane: scrollable list */}
|
|
585
|
+
<box
|
|
586
|
+
flexDirection="column"
|
|
587
|
+
flexGrow={1}
|
|
588
|
+
border={true}
|
|
589
|
+
borderStyle="single"
|
|
590
|
+
borderColor={Colors.border}
|
|
591
|
+
paddingX={1}
|
|
592
|
+
paddingY={0}
|
|
593
|
+
>
|
|
594
|
+
{/* Column header */}
|
|
595
|
+
<box flexDirection="row" gap={2} paddingLeft={1} paddingRight={1}>
|
|
596
|
+
<text fg={Colors.muted} dim>
|
|
597
|
+
KEY NAME
|
|
598
|
+
</text>
|
|
599
|
+
<text fg={Colors.muted} dim>
|
|
600
|
+
{"SIZE".padStart(8, " ")}
|
|
601
|
+
</text>
|
|
602
|
+
<text fg={Colors.muted} dim>
|
|
603
|
+
{"TYPE".padStart(4, " ")}
|
|
604
|
+
</text>
|
|
605
|
+
<text fg={Colors.muted} dim>
|
|
606
|
+
{"MOD".padStart(8, " ")}
|
|
607
|
+
</text>
|
|
608
|
+
<text fg={Colors.muted} dim>
|
|
609
|
+
STATUS
|
|
610
|
+
</text>
|
|
611
|
+
</box>
|
|
612
|
+
<text fg={Colors.border} dim>
|
|
613
|
+
{"─".repeat(80)}
|
|
614
|
+
</text>
|
|
615
|
+
|
|
616
|
+
{/* Body */}
|
|
617
|
+
{loading && allKeys.length === 0 ? (
|
|
618
|
+
<box
|
|
619
|
+
padding={1}
|
|
620
|
+
alignItems="center"
|
|
621
|
+
justifyContent="center"
|
|
622
|
+
flexGrow={1}
|
|
623
|
+
>
|
|
624
|
+
<Spinner label="Loading KV keys..." />
|
|
625
|
+
</box>
|
|
626
|
+
) : error && allKeys.length === 0 ? (
|
|
627
|
+
<box padding={1} flexDirection="column" gap={0}>
|
|
628
|
+
<text fg={Colors.error} bold>
|
|
629
|
+
! {error.length > 60 ? error.slice(0, 57) + "…" : error}
|
|
630
|
+
</text>
|
|
631
|
+
<text fg={Colors.muted} dim>
|
|
632
|
+
Make sure wrangler is installed and authenticated.
|
|
633
|
+
</text>
|
|
634
|
+
</box>
|
|
635
|
+
) : allKeys.length === 0 ? (
|
|
636
|
+
<box padding={1} flexGrow={1}>
|
|
637
|
+
<EmptyState
|
|
638
|
+
message="No keys in CONFIG_KV."
|
|
639
|
+
suggestion="Populate with `hoox config kv apply-manifest`"
|
|
640
|
+
icon="🔑"
|
|
641
|
+
/>
|
|
642
|
+
</box>
|
|
643
|
+
) : filteredKeys.length === 0 ? (
|
|
644
|
+
<box padding={1} flexGrow={1}>
|
|
645
|
+
<EmptyState
|
|
646
|
+
message={`No keys match "${search}".`}
|
|
647
|
+
suggestion="Press / to clear."
|
|
648
|
+
icon="🔍"
|
|
649
|
+
/>
|
|
650
|
+
</box>
|
|
651
|
+
) : (
|
|
652
|
+
<scrollbox width="100%" flexGrow={1}>
|
|
653
|
+
{visibleKeys.map((k) => (
|
|
654
|
+
<KvRow
|
|
655
|
+
key={k.name}
|
|
656
|
+
keyName={k.name}
|
|
657
|
+
size={formatSize(k.valueSize)}
|
|
658
|
+
type={formatType(k.manifestType)}
|
|
659
|
+
modified={formatTime(k.lastModified)}
|
|
660
|
+
isSecret={k.isSecret}
|
|
661
|
+
isSelected={k.name === selectedKeyName}
|
|
662
|
+
onSelect={(name) => setSelectedKeyName(name)}
|
|
663
|
+
/>
|
|
664
|
+
))}
|
|
665
|
+
</scrollbox>
|
|
666
|
+
)}
|
|
667
|
+
</box>
|
|
668
|
+
|
|
669
|
+
{/* Right pane: detail / value viewer */}
|
|
670
|
+
<DetailPane
|
|
671
|
+
selectedKey={selectedKey}
|
|
672
|
+
value={value}
|
|
673
|
+
loading={valueLoading}
|
|
674
|
+
valueError={valueError}
|
|
675
|
+
onReveal={() => void handleReveal()}
|
|
676
|
+
onHide={handleHide}
|
|
677
|
+
revealed={revealed}
|
|
678
|
+
/>
|
|
679
|
+
</box>
|
|
680
|
+
|
|
681
|
+
{/* Footer: refresh + read-only warning */}
|
|
682
|
+
<box
|
|
683
|
+
flexDirection="row"
|
|
684
|
+
justifyContent="space-between"
|
|
685
|
+
paddingLeft={1}
|
|
686
|
+
paddingRight={1}
|
|
687
|
+
alignItems="center"
|
|
688
|
+
>
|
|
689
|
+
<box flexDirection="row" gap={2} alignItems="center">
|
|
690
|
+
<text
|
|
691
|
+
fg={loading ? Colors.muted : Colors.accent}
|
|
692
|
+
bg={Colors.card}
|
|
693
|
+
dim={loading}
|
|
694
|
+
onMouseUp={loading ? undefined : () => void refresh()}
|
|
695
|
+
>
|
|
696
|
+
{loading ? " ... " : " [REFRESH] "}
|
|
697
|
+
</text>
|
|
698
|
+
{error && allKeys.length > 0 && (
|
|
699
|
+
<text fg={Colors.warning} dim>
|
|
700
|
+
! {error.length > 50 ? error.slice(0, 47) + "…" : error}
|
|
701
|
+
</text>
|
|
702
|
+
)}
|
|
703
|
+
</box>
|
|
704
|
+
<box flexDirection="row" gap={1}>
|
|
705
|
+
<text fg={Colors.muted} dim>
|
|
706
|
+
Use
|
|
707
|
+
</text>
|
|
708
|
+
<text fg={Colors.accent}>hoox config kv get|set|delete</text>
|
|
709
|
+
<text fg={Colors.muted} dim>
|
|
710
|
+
in CLI for writes
|
|
711
|
+
</text>
|
|
712
|
+
</box>
|
|
713
|
+
</box>
|
|
714
|
+
</box>
|
|
715
|
+
</ErrorBoundary>
|
|
716
|
+
);
|
|
717
|
+
}
|