@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,548 @@
|
|
|
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
|
+
* Secrets Viewer — Read-only dashboard for Cloudflare Workers secrets.
|
|
9
|
+
*
|
|
10
|
+
* Layout:
|
|
11
|
+
* ┌───────────────────────────────────────────────────────────────┐
|
|
12
|
+
* │ SECRETS VIEWER 12 secrets · ◉ 5s auto [⚠ READ-ONLY] │
|
|
13
|
+
* │───────────────────────────────────────────────────────────────
|
|
14
|
+
* │ [! SECURITY !] Values are hidden for security reasons. │
|
|
15
|
+
* │ Use `hoox config secrets set` in CLI to manage│
|
|
16
|
+
* │───────────────────────────────────────────────────────────────
|
|
17
|
+
* │ [search…] Last sampled 12:34:56 │
|
|
18
|
+
* │───────────────────────────────────────────────────────────────
|
|
19
|
+
* │ SECRET NAME TYPE SOURCE STATUS │
|
|
20
|
+
* │ BINANCE_KEY_BINDING api_key config OK │
|
|
21
|
+
* │ OPENAI_API_KEY api_key config OK │
|
|
22
|
+
* │ TELEGRAM_BOT_TOKEN token config OK │
|
|
23
|
+
* │ … │
|
|
24
|
+
* │───────────────────────────────────────────────────────────────
|
|
25
|
+
* │ [REFRESH] │
|
|
26
|
+
* └───────────────────────────────────────────────────────────────┘
|
|
27
|
+
*
|
|
28
|
+
* Pattern established for the TUI feature-parity batch:
|
|
29
|
+
* - Pure function component, no props required
|
|
30
|
+
* - Subscribes to useUIStore (so auto-refresh can pause when not active)
|
|
31
|
+
* - Calls cliBridge.configSecretsList() on mount + every 5s while active
|
|
32
|
+
* - Wraps content in <ErrorBoundary viewName="Secrets Viewer">
|
|
33
|
+
* - Renders an empty/error state instead of throwing
|
|
34
|
+
* - Read-only: write operations are intentionally *not* exposed
|
|
35
|
+
* here. The TUI never sends `secrets set` / `secrets delete` —
|
|
36
|
+
* those are CLI-only.
|
|
37
|
+
*
|
|
38
|
+
* Security:
|
|
39
|
+
* - Values are NEVER fetched or displayed — this is strictly read-only
|
|
40
|
+
* - The prominent security warning banner is always visible
|
|
41
|
+
* - Only secret names, inferred types, and sources are shown
|
|
42
|
+
* - No ability to reveal or copy secret values
|
|
43
|
+
*/
|
|
44
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
45
|
+
import { useKeyboard } from "@opentui/react";
|
|
46
|
+
import { Colors, useUIStore } from "@hoox-sh/hoox-shared";
|
|
47
|
+
import { ErrorBoundary } from "../shared/error-boundary";
|
|
48
|
+
import { Spinner, EmptyState } from "../shared/spinner";
|
|
49
|
+
import { ViewHeader } from "../shared/view-header";
|
|
50
|
+
import { cliBridge } from "../../services/cli-bridge";
|
|
51
|
+
import type {
|
|
52
|
+
SecretMetadata,
|
|
53
|
+
SecretsSnapshot,
|
|
54
|
+
} from "../../services/cli-bridge";
|
|
55
|
+
|
|
56
|
+
/** Auto-refresh interval in milliseconds. */
|
|
57
|
+
export const REFRESH_INTERVAL_MS = 5_000;
|
|
58
|
+
|
|
59
|
+
/** Cap rendered secret rows for large worker graphs. */
|
|
60
|
+
export const MAX_VISIBLE_SECRETS = 200;
|
|
61
|
+
|
|
62
|
+
const SEARCH_DEBOUNCE_MS = 150;
|
|
63
|
+
|
|
64
|
+
/** Case-insensitive name filter — values are never involved. */
|
|
65
|
+
export function filterSecrets(
|
|
66
|
+
secrets: readonly SecretMetadata[],
|
|
67
|
+
search: string
|
|
68
|
+
): SecretMetadata[] {
|
|
69
|
+
const q = search.trim().toLowerCase();
|
|
70
|
+
if (q.length === 0) return secrets as SecretMetadata[];
|
|
71
|
+
return secrets.filter((s) => s.name.toLowerCase().includes(q));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Format a secret type for display in the TYPE column.
|
|
76
|
+
*/
|
|
77
|
+
function formatType(type: SecretMetadata["type"]): string {
|
|
78
|
+
return type;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Color for the source badge.
|
|
83
|
+
*/
|
|
84
|
+
function sourceColor(source: SecretMetadata["source"]): string {
|
|
85
|
+
return source === "Cloudflare" ? Colors.info : Colors.muted;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Pick a color for a row based on secret type severity.
|
|
90
|
+
*/
|
|
91
|
+
function rowColor(type: SecretMetadata["type"]): string {
|
|
92
|
+
if (type === "api_key" || type === "token") {
|
|
93
|
+
return Colors.warning;
|
|
94
|
+
}
|
|
95
|
+
return Colors.foreground;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ─── Sub-component: Single Secret Row ─────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
interface SecretRowProps {
|
|
101
|
+
secret: SecretMetadata;
|
|
102
|
+
isSelected: boolean;
|
|
103
|
+
onSelect: (name: string) => void;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function SecretRow({ secret, isSelected, onSelect }: SecretRowProps) {
|
|
107
|
+
const fg = isSelected ? Colors.accent : rowColor(secret.type);
|
|
108
|
+
return (
|
|
109
|
+
<box
|
|
110
|
+
flexDirection="row"
|
|
111
|
+
gap={2}
|
|
112
|
+
paddingLeft={1}
|
|
113
|
+
paddingRight={1}
|
|
114
|
+
alignItems="center"
|
|
115
|
+
backgroundColor={isSelected ? Colors.card : undefined}
|
|
116
|
+
onMouseUp={() => onSelect(secret.name)}
|
|
117
|
+
>
|
|
118
|
+
<text fg={fg} bold={isSelected}>
|
|
119
|
+
{secret.name}
|
|
120
|
+
</text>
|
|
121
|
+
<text fg={Colors.muted}>{formatType(secret.type).padStart(8, " ")}</text>
|
|
122
|
+
<text fg={sourceColor(secret.source)} dim>
|
|
123
|
+
{secret.source.padEnd(10, " ")}
|
|
124
|
+
</text>
|
|
125
|
+
</box>
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ─── Sub-component: Search Box ─────────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
interface SearchBoxProps {
|
|
132
|
+
query: string;
|
|
133
|
+
onChange: (next: string) => void;
|
|
134
|
+
active: boolean;
|
|
135
|
+
onActivate: () => void;
|
|
136
|
+
onDeactivate: () => void;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function SearchBox({
|
|
140
|
+
query,
|
|
141
|
+
onChange,
|
|
142
|
+
active,
|
|
143
|
+
onActivate,
|
|
144
|
+
onDeactivate,
|
|
145
|
+
}: SearchBoxProps) {
|
|
146
|
+
// When active, capture printable keys for filtering (same pattern as KV Viewer).
|
|
147
|
+
useKeyboard((key) => {
|
|
148
|
+
if (!active) return;
|
|
149
|
+
if (key.name === "escape") {
|
|
150
|
+
onDeactivate();
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
if (key.name === "backspace" || key.name === "delete") {
|
|
154
|
+
onChange(query.slice(0, -1));
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
if (key.name === "return") {
|
|
158
|
+
onDeactivate();
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
if (key.sequence && key.sequence.length === 1 && key.sequence >= " ") {
|
|
162
|
+
onChange(query + key.sequence);
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
return (
|
|
167
|
+
<box
|
|
168
|
+
flexDirection="row"
|
|
169
|
+
gap={1}
|
|
170
|
+
paddingLeft={1}
|
|
171
|
+
paddingRight={1}
|
|
172
|
+
alignItems="center"
|
|
173
|
+
border={true}
|
|
174
|
+
borderStyle="single"
|
|
175
|
+
borderColor={active ? Colors.accent : Colors.border}
|
|
176
|
+
onMouseUp={onActivate}
|
|
177
|
+
>
|
|
178
|
+
<text fg={Colors.muted} dim>
|
|
179
|
+
search:
|
|
180
|
+
</text>
|
|
181
|
+
<text fg={Colors.foreground}>
|
|
182
|
+
{query.length > 0 ? query : active ? "_" : "(press / to filter)"}
|
|
183
|
+
</text>
|
|
184
|
+
</box>
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ─── Main SecretsViewer View ───────────────────────────────────────────────────
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* SecretsViewer — Main view showing the declared secrets across all workers.
|
|
192
|
+
*
|
|
193
|
+
* The view follows the same architectural pattern as the KV viewer
|
|
194
|
+
* (see `kv-viewer.tsx` for the original):
|
|
195
|
+
* 1. Pure function component, no required props
|
|
196
|
+
* 2. Subscribes to `useUIStore.activeView` to pause auto-refresh
|
|
197
|
+
* when the user navigates away
|
|
198
|
+
* 3. Calls `cliBridge.configSecretsList()` on mount + on a fixed
|
|
199
|
+
* interval (5s) — refreshes can also be triggered manually via
|
|
200
|
+
* a button
|
|
201
|
+
* 4. Renders an explicit empty/error state instead of throwing
|
|
202
|
+
* 5. Wraps in <ErrorBoundary viewName="Secrets Viewer"> so a render
|
|
203
|
+
* bug in this view never crashes the whole TUI
|
|
204
|
+
*
|
|
205
|
+
* Security guarantees:
|
|
206
|
+
* - Values are NEVER fetched — only names and metadata are retrieved
|
|
207
|
+
* - The security warning banner is prominently displayed at all times
|
|
208
|
+
* - No "reveal" or "copy" functionality exists
|
|
209
|
+
* - Write operations are intentionally not exposed
|
|
210
|
+
*/
|
|
211
|
+
export function SecretsViewer() {
|
|
212
|
+
const activeView = useUIStore((s) => s.activeView);
|
|
213
|
+
const isActive = activeView === "secrets-viewer";
|
|
214
|
+
|
|
215
|
+
const [snapshot, setSnapshot] = useState<SecretsSnapshot | null>(null);
|
|
216
|
+
const [loading, setLoading] = useState(false);
|
|
217
|
+
const [error, setError] = useState<string | null>(null);
|
|
218
|
+
const [search, setSearch] = useState("");
|
|
219
|
+
const [debouncedSearch, setDebouncedSearch] = useState("");
|
|
220
|
+
const [searchActive, setSearchActive] = useState(false);
|
|
221
|
+
const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
222
|
+
const listGenRef = useRef(0);
|
|
223
|
+
|
|
224
|
+
const [selectedSecretName, setSelectedSecretName] = useState<string | null>(
|
|
225
|
+
null
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
// Debounce search filter
|
|
229
|
+
useEffect(() => {
|
|
230
|
+
if (searchTimerRef.current) clearTimeout(searchTimerRef.current);
|
|
231
|
+
searchTimerRef.current = setTimeout(() => {
|
|
232
|
+
setDebouncedSearch(search);
|
|
233
|
+
searchTimerRef.current = null;
|
|
234
|
+
}, SEARCH_DEBOUNCE_MS);
|
|
235
|
+
return () => {
|
|
236
|
+
if (searchTimerRef.current) {
|
|
237
|
+
clearTimeout(searchTimerRef.current);
|
|
238
|
+
searchTimerRef.current = null;
|
|
239
|
+
}
|
|
240
|
+
};
|
|
241
|
+
}, [search]);
|
|
242
|
+
|
|
243
|
+
// ── Fetch handler — metadata only; NEVER requests secret values ───────────
|
|
244
|
+
const refresh = useCallback(async (opts?: { soft?: boolean }) => {
|
|
245
|
+
const soft = opts?.soft === true;
|
|
246
|
+
const gen = ++listGenRef.current;
|
|
247
|
+
if (!soft) setLoading(true);
|
|
248
|
+
// Security: only configSecretsList (names/types). No get/reveal API.
|
|
249
|
+
const result = await cliBridge.configSecretsList();
|
|
250
|
+
if (gen !== listGenRef.current) return;
|
|
251
|
+
if (result.success && result.data) {
|
|
252
|
+
setSnapshot(result.data);
|
|
253
|
+
setError(null);
|
|
254
|
+
} else {
|
|
255
|
+
setError(result.stderr || result.stdout || "Failed to read secrets");
|
|
256
|
+
}
|
|
257
|
+
setLoading(false);
|
|
258
|
+
}, []);
|
|
259
|
+
|
|
260
|
+
// Initial load on mount + invalidate in-flight on unmount.
|
|
261
|
+
useEffect(() => {
|
|
262
|
+
void refresh({ soft: false });
|
|
263
|
+
return () => {
|
|
264
|
+
listGenRef.current += 1;
|
|
265
|
+
};
|
|
266
|
+
}, [refresh]);
|
|
267
|
+
|
|
268
|
+
// Auto-refresh every 5s while the view is the active view (soft = no flicker).
|
|
269
|
+
useEffect(() => {
|
|
270
|
+
if (!isActive) return;
|
|
271
|
+
const handle = setInterval(() => {
|
|
272
|
+
void refresh({ soft: true });
|
|
273
|
+
}, REFRESH_INTERVAL_MS);
|
|
274
|
+
return () => clearInterval(handle);
|
|
275
|
+
}, [isActive, refresh]);
|
|
276
|
+
|
|
277
|
+
// ── Derived data ───────────────────────────────────────────────────────────
|
|
278
|
+
const allSecrets = snapshot?.secrets ?? [];
|
|
279
|
+
const filteredSecrets = useMemo(
|
|
280
|
+
() => filterSecrets(allSecrets, debouncedSearch),
|
|
281
|
+
[allSecrets, debouncedSearch]
|
|
282
|
+
);
|
|
283
|
+
const visibleSecrets = useMemo(
|
|
284
|
+
() => filteredSecrets.slice(0, MAX_VISIBLE_SECRETS),
|
|
285
|
+
[filteredSecrets]
|
|
286
|
+
);
|
|
287
|
+
|
|
288
|
+
const selectedSecret = useMemo(() => {
|
|
289
|
+
if (!selectedSecretName) return null;
|
|
290
|
+
return allSecrets.find((s) => s.name === selectedSecretName) ?? null;
|
|
291
|
+
}, [allSecrets, selectedSecretName]);
|
|
292
|
+
|
|
293
|
+
// ── Keyboard: list nav + open search (inactive while search is active) ───
|
|
294
|
+
useKeyboard((key) => {
|
|
295
|
+
if (!isActive) return;
|
|
296
|
+
if (searchActive) return; // SearchBox owns keys while active
|
|
297
|
+
if (key.name === "slash" || (key.ctrl && key.name === "f")) {
|
|
298
|
+
setSearchActive(true);
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
if (key.name === "escape") {
|
|
302
|
+
if (selectedSecretName) setSelectedSecretName(null);
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
if (key.name === "up") {
|
|
306
|
+
if (visibleSecrets.length === 0) return;
|
|
307
|
+
const idx = selectedSecretName
|
|
308
|
+
? visibleSecrets.findIndex((s) => s.name === selectedSecretName)
|
|
309
|
+
: -1;
|
|
310
|
+
const next = Math.max(0, idx - 1);
|
|
311
|
+
setSelectedSecretName(visibleSecrets[next]?.name ?? null);
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
if (key.name === "down") {
|
|
315
|
+
if (visibleSecrets.length === 0) return;
|
|
316
|
+
const idx = selectedSecretName
|
|
317
|
+
? visibleSecrets.findIndex((s) => s.name === selectedSecretName)
|
|
318
|
+
: -1;
|
|
319
|
+
const next = Math.min(visibleSecrets.length - 1, idx + 1);
|
|
320
|
+
setSelectedSecretName(visibleSecrets[next]?.name ?? null);
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
// ── Render ────────────────────────────────────────────────────────────────
|
|
326
|
+
return (
|
|
327
|
+
<ErrorBoundary viewName="Secrets Viewer">
|
|
328
|
+
<box flexDirection="column" flexGrow={1} padding={1} gap={1}>
|
|
329
|
+
<ViewHeader
|
|
330
|
+
title="SECRETS VIEWER"
|
|
331
|
+
showDivider={false}
|
|
332
|
+
meta={
|
|
333
|
+
<box flexDirection="row" gap={2} alignItems="center">
|
|
334
|
+
<text fg={Colors.muted} dim>
|
|
335
|
+
{`${allSecrets.length} secret${allSecrets.length === 1 ? "" : "s"}`}
|
|
336
|
+
</text>
|
|
337
|
+
{debouncedSearch.length > 0 ? (
|
|
338
|
+
<text fg={Colors.info} dim>
|
|
339
|
+
{`(${filteredSecrets.length} match${filteredSecrets.length === 1 ? "" : "es"})`}
|
|
340
|
+
</text>
|
|
341
|
+
) : null}
|
|
342
|
+
{filteredSecrets.length > MAX_VISIBLE_SECRETS ? (
|
|
343
|
+
<text fg={Colors.muted} dim>
|
|
344
|
+
{`showing ${MAX_VISIBLE_SECRETS}`}
|
|
345
|
+
</text>
|
|
346
|
+
) : null}
|
|
347
|
+
<text fg={Colors.warning} bold>
|
|
348
|
+
⚠ READ-ONLY
|
|
349
|
+
</text>
|
|
350
|
+
<text fg={Colors.info} dim>
|
|
351
|
+
{`◉ ${REFRESH_INTERVAL_MS / 1000}s auto`}
|
|
352
|
+
</text>
|
|
353
|
+
</box>
|
|
354
|
+
}
|
|
355
|
+
/>
|
|
356
|
+
|
|
357
|
+
{/* Security warning banner — ALWAYS visible */}
|
|
358
|
+
<box
|
|
359
|
+
flexDirection="column"
|
|
360
|
+
padding={1}
|
|
361
|
+
gap={0}
|
|
362
|
+
border={true}
|
|
363
|
+
borderStyle="single"
|
|
364
|
+
borderColor={Colors.warning}
|
|
365
|
+
backgroundColor={Colors.card}
|
|
366
|
+
>
|
|
367
|
+
<text fg={Colors.warning} bold>
|
|
368
|
+
⚠ SECURITY: Values are hidden for security reasons.
|
|
369
|
+
</text>
|
|
370
|
+
<box flexDirection="row" gap={1}>
|
|
371
|
+
<text fg={Colors.muted} dim>
|
|
372
|
+
This view is strictly read-only. Use
|
|
373
|
+
</text>
|
|
374
|
+
<text fg={Colors.accent}>hoox config secrets set</text>
|
|
375
|
+
<text fg={Colors.muted} dim>
|
|
376
|
+
in CLI to manage secrets.
|
|
377
|
+
</text>
|
|
378
|
+
</box>
|
|
379
|
+
</box>
|
|
380
|
+
|
|
381
|
+
{/* Search + last sampled row */}
|
|
382
|
+
<box flexDirection="row" gap={1} alignItems="center">
|
|
383
|
+
<SearchBox
|
|
384
|
+
query={search}
|
|
385
|
+
onChange={setSearch}
|
|
386
|
+
active={searchActive}
|
|
387
|
+
onActivate={() => setSearchActive(true)}
|
|
388
|
+
onDeactivate={() => setSearchActive(false)}
|
|
389
|
+
/>
|
|
390
|
+
<text fg={Colors.muted} dim>
|
|
391
|
+
{snapshot
|
|
392
|
+
? `Last sampled ${snapshot.timestamp.slice(11, 19)}`
|
|
393
|
+
: "Last sampled —"}
|
|
394
|
+
</text>
|
|
395
|
+
</box>
|
|
396
|
+
|
|
397
|
+
{/* Main content: secret list + detail */}
|
|
398
|
+
<box flexDirection="row" flexGrow={1} gap={1}>
|
|
399
|
+
{/* Left pane: scrollable list */}
|
|
400
|
+
<box
|
|
401
|
+
flexDirection="column"
|
|
402
|
+
flexGrow={1}
|
|
403
|
+
border={true}
|
|
404
|
+
borderStyle="single"
|
|
405
|
+
borderColor={Colors.border}
|
|
406
|
+
>
|
|
407
|
+
{/* Column header */}
|
|
408
|
+
<box flexDirection="row" gap={2} paddingLeft={1} paddingRight={1}>
|
|
409
|
+
<text fg={Colors.muted} dim>
|
|
410
|
+
SECRET NAME
|
|
411
|
+
</text>
|
|
412
|
+
<text fg={Colors.muted} dim>
|
|
413
|
+
{"TYPE".padStart(8, " ")}
|
|
414
|
+
</text>
|
|
415
|
+
<text fg={Colors.muted} dim>
|
|
416
|
+
{"SOURCE".padEnd(10, " ")}
|
|
417
|
+
</text>
|
|
418
|
+
</box>
|
|
419
|
+
<text fg={Colors.border} dim>
|
|
420
|
+
{"─".repeat(80)}
|
|
421
|
+
</text>
|
|
422
|
+
|
|
423
|
+
{/* Body */}
|
|
424
|
+
{loading && allSecrets.length === 0 ? (
|
|
425
|
+
<box padding={1} flexGrow={1} justifyContent="center">
|
|
426
|
+
<Spinner label="Loading secrets…" />
|
|
427
|
+
</box>
|
|
428
|
+
) : error && allSecrets.length === 0 ? (
|
|
429
|
+
<box padding={1} flexDirection="column" gap={0}>
|
|
430
|
+
<text fg={Colors.error} bold>
|
|
431
|
+
! {error.length > 60 ? error.slice(0, 57) + "…" : error}
|
|
432
|
+
</text>
|
|
433
|
+
<text fg={Colors.muted} dim>
|
|
434
|
+
Make sure hoox CLI is installed and configured.
|
|
435
|
+
</text>
|
|
436
|
+
</box>
|
|
437
|
+
) : allSecrets.length === 0 ? (
|
|
438
|
+
<box padding={1} flexGrow={1}>
|
|
439
|
+
<EmptyState
|
|
440
|
+
message="No secrets declared."
|
|
441
|
+
suggestion="Add secrets to wrangler.jsonc · hoox config secrets set"
|
|
442
|
+
icon="🔐"
|
|
443
|
+
/>
|
|
444
|
+
</box>
|
|
445
|
+
) : filteredSecrets.length === 0 ? (
|
|
446
|
+
<box padding={1} flexGrow={1}>
|
|
447
|
+
<EmptyState
|
|
448
|
+
message={`No secrets match "${search}".`}
|
|
449
|
+
suggestion="Press / to clear."
|
|
450
|
+
icon="🔍"
|
|
451
|
+
/>
|
|
452
|
+
</box>
|
|
453
|
+
) : (
|
|
454
|
+
<scrollbox width="100%" flexGrow={1}>
|
|
455
|
+
{visibleSecrets.map((s) => (
|
|
456
|
+
<SecretRow
|
|
457
|
+
key={s.name}
|
|
458
|
+
secret={s}
|
|
459
|
+
isSelected={s.name === selectedSecretName}
|
|
460
|
+
onSelect={(name) => setSelectedSecretName(name)}
|
|
461
|
+
/>
|
|
462
|
+
))}
|
|
463
|
+
</scrollbox>
|
|
464
|
+
)}
|
|
465
|
+
</box>
|
|
466
|
+
|
|
467
|
+
{/* Right pane: detail / info viewer */}
|
|
468
|
+
<box
|
|
469
|
+
flexDirection="column"
|
|
470
|
+
flexGrow={1}
|
|
471
|
+
border={true}
|
|
472
|
+
borderStyle="single"
|
|
473
|
+
borderColor={Colors.border}
|
|
474
|
+
>
|
|
475
|
+
{selectedSecret ? (
|
|
476
|
+
<box flexDirection="column" gap={1} padding={2}>
|
|
477
|
+
<text fg={Colors.accent} bold>
|
|
478
|
+
{selectedSecret.name}
|
|
479
|
+
</text>
|
|
480
|
+
<text fg={Colors.border} dim>
|
|
481
|
+
{"─".repeat(40)}
|
|
482
|
+
</text>
|
|
483
|
+
<box flexDirection="column" gap={0}>
|
|
484
|
+
<box flexDirection="row" gap={1}>
|
|
485
|
+
<text fg={Colors.muted}>Type:</text>
|
|
486
|
+
<text fg={Colors.foreground}>{selectedSecret.type}</text>
|
|
487
|
+
</box>
|
|
488
|
+
<box flexDirection="row" gap={1}>
|
|
489
|
+
<text fg={Colors.muted}>Source:</text>
|
|
490
|
+
<text fg={sourceColor(selectedSecret.source)}>
|
|
491
|
+
{selectedSecret.source}
|
|
492
|
+
</text>
|
|
493
|
+
</box>
|
|
494
|
+
</box>
|
|
495
|
+
<text fg={Colors.muted} dim>
|
|
496
|
+
Values are not available for viewing.
|
|
497
|
+
</text>
|
|
498
|
+
</box>
|
|
499
|
+
) : (
|
|
500
|
+
<box flexDirection="column" flexGrow={1} padding={2} gap={1}>
|
|
501
|
+
<text fg={Colors.muted} dim>
|
|
502
|
+
Select a secret to view its metadata.
|
|
503
|
+
</text>
|
|
504
|
+
<text fg={Colors.dim} dim>
|
|
505
|
+
↑↓ navigate · values hidden
|
|
506
|
+
</text>
|
|
507
|
+
</box>
|
|
508
|
+
)}
|
|
509
|
+
</box>
|
|
510
|
+
</box>
|
|
511
|
+
|
|
512
|
+
{/* Footer: refresh + read-only warning */}
|
|
513
|
+
<box
|
|
514
|
+
flexDirection="row"
|
|
515
|
+
justifyContent="space-between"
|
|
516
|
+
paddingLeft={1}
|
|
517
|
+
paddingRight={1}
|
|
518
|
+
alignItems="center"
|
|
519
|
+
>
|
|
520
|
+
<box flexDirection="row" gap={2} alignItems="center">
|
|
521
|
+
<text
|
|
522
|
+
fg={loading ? Colors.muted : Colors.accent}
|
|
523
|
+
bg={Colors.card}
|
|
524
|
+
dim={loading}
|
|
525
|
+
onMouseUp={loading ? undefined : () => void refresh()}
|
|
526
|
+
>
|
|
527
|
+
{loading ? " ... " : " [REFRESH] "}
|
|
528
|
+
</text>
|
|
529
|
+
{error && allSecrets.length > 0 && (
|
|
530
|
+
<text fg={Colors.warning} dim>
|
|
531
|
+
! {error.length > 50 ? error.slice(0, 47) + "…" : error}
|
|
532
|
+
</text>
|
|
533
|
+
)}
|
|
534
|
+
</box>
|
|
535
|
+
<box flexDirection="row" gap={1}>
|
|
536
|
+
<text fg={Colors.muted} dim>
|
|
537
|
+
Use
|
|
538
|
+
</text>
|
|
539
|
+
<text fg={Colors.accent}>hoox config secrets set|delete</text>
|
|
540
|
+
<text fg={Colors.muted} dim>
|
|
541
|
+
in CLI for writes
|
|
542
|
+
</text>
|
|
543
|
+
</box>
|
|
544
|
+
</box>
|
|
545
|
+
</box>
|
|
546
|
+
</ErrorBoundary>
|
|
547
|
+
);
|
|
548
|
+
}
|