@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,616 @@
|
|
|
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
|
+
* Logs Viewer — Split-layout view with filter controls on left (20 cols) and
|
|
9
|
+
* scrolling color-coded log stream on right. Filters combine with AND logic.
|
|
10
|
+
* Keyboard: Space toggles pause, / focuses the search field.
|
|
11
|
+
*
|
|
12
|
+
* Uses service store logs (ring buffer) and workers list for filter checkboxes.
|
|
13
|
+
* Color-coded by level using Hoox design tokens. Wrapped in ErrorBoundary.
|
|
14
|
+
*/
|
|
15
|
+
import { useState, useMemo, useCallback, useEffect, useRef } from "react";
|
|
16
|
+
import { useKeyboard } from "@opentui/react";
|
|
17
|
+
import {
|
|
18
|
+
Colors,
|
|
19
|
+
LogLevelColor,
|
|
20
|
+
useServiceStore,
|
|
21
|
+
useUIStore,
|
|
22
|
+
} from "@hoox-sh/hoox-shared";
|
|
23
|
+
import type { LogEntry, LogLevel, Alert } from "@hoox-sh/hoox-shared";
|
|
24
|
+
import { ErrorBoundary } from "../shared/error-boundary";
|
|
25
|
+
import { cliBridge } from "../../services/cli-bridge";
|
|
26
|
+
import { ViewHeader } from "../shared/view-header";
|
|
27
|
+
import { Panel } from "../shared/panel";
|
|
28
|
+
|
|
29
|
+
// ─── Color Tokens ────────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
const LEVEL_LABEL: Record<LogLevel, string> = {
|
|
32
|
+
error: "ERR",
|
|
33
|
+
warn: "WRN",
|
|
34
|
+
info: "INF",
|
|
35
|
+
debug: "DBG",
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const ALL_LEVELS: LogLevel[] = ["error", "warn", "info", "debug"];
|
|
39
|
+
|
|
40
|
+
/** Max rows rendered in the stream (store ring is 1000). */
|
|
41
|
+
export const MAX_VISIBLE_LOGS = 200;
|
|
42
|
+
|
|
43
|
+
/** Search debounce so each keystroke doesn't refilter the whole buffer. */
|
|
44
|
+
const SEARCH_DEBOUNCE_MS = 150;
|
|
45
|
+
|
|
46
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
function formatTimestamp(ms: number): string {
|
|
49
|
+
const d = new Date(ms);
|
|
50
|
+
const h = d.getHours().toString().padStart(2, "0");
|
|
51
|
+
const m = d.getMinutes().toString().padStart(2, "0");
|
|
52
|
+
const s = d.getSeconds().toString().padStart(2, "0");
|
|
53
|
+
return `${h}:${m}:${s}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function truncateRight(str: string, max: number): string {
|
|
57
|
+
if (str.length <= max) return str;
|
|
58
|
+
return str.slice(0, max - 1) + "\u2026";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Strip C0 control chars (except tab) so log payloads cannot inject
|
|
63
|
+
* terminal sequences into the TUI stream.
|
|
64
|
+
*/
|
|
65
|
+
export function sanitizeLogText(value: string, maxLen = 400): string {
|
|
66
|
+
const cleaned = value.replace(
|
|
67
|
+
/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g,
|
|
68
|
+
""
|
|
69
|
+
);
|
|
70
|
+
// Collapse newlines/tabs to spaces so each log stays one row
|
|
71
|
+
const singleLine = cleaned.replace(/[\t\n\r]+/g, " ");
|
|
72
|
+
if (singleLine.length <= maxLen) return singleLine;
|
|
73
|
+
return singleLine.slice(0, maxLen - 1) + "\u2026";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Filter logs with AND logic: level ∩ worker ∩ text search.
|
|
78
|
+
* Exported for unit tests so the view and suite stay in lockstep.
|
|
79
|
+
*/
|
|
80
|
+
export function applyLogFilters(
|
|
81
|
+
entries: readonly LogEntry[],
|
|
82
|
+
options: {
|
|
83
|
+
allLevels: boolean;
|
|
84
|
+
levels: Set<LogLevel>;
|
|
85
|
+
selectedWorkers: Set<string>;
|
|
86
|
+
searchText: string;
|
|
87
|
+
workers: ReadonlyArray<{ id: string; name: string }>;
|
|
88
|
+
}
|
|
89
|
+
): LogEntry[] {
|
|
90
|
+
const { allLevels, levels, selectedWorkers, searchText, workers } = options;
|
|
91
|
+
const activeLevels = allLevels ? new Set(ALL_LEVELS) : levels;
|
|
92
|
+
const query = searchText.toLowerCase().trim();
|
|
93
|
+
|
|
94
|
+
if (activeLevels.size === 0 && !allLevels) return [];
|
|
95
|
+
|
|
96
|
+
// Pre-index workers once for O(1) lookup
|
|
97
|
+
const workerById =
|
|
98
|
+
selectedWorkers.size > 0
|
|
99
|
+
? new Map(workers.map((w) => [w.id, w.name] as const))
|
|
100
|
+
: null;
|
|
101
|
+
|
|
102
|
+
return entries.filter((entry) => {
|
|
103
|
+
if (!activeLevels.has(entry.level)) return false;
|
|
104
|
+
|
|
105
|
+
if (workerById) {
|
|
106
|
+
const name = entry.workerId ? workerById.get(entry.workerId) : undefined;
|
|
107
|
+
if (!name || !selectedWorkers.has(name)) return false;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (query) {
|
|
111
|
+
const hay = `${entry.message} ${entry.source ?? ""}`.toLowerCase();
|
|
112
|
+
if (!hay.includes(query)) return false;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return true;
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ─── Filter Panel ────────────────────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
interface FilterPanelProps {
|
|
122
|
+
allLevels: boolean;
|
|
123
|
+
onToggleAll: () => void;
|
|
124
|
+
levels: Set<LogLevel>;
|
|
125
|
+
onToggleLevel: (l: LogLevel) => void;
|
|
126
|
+
workerNames: string[];
|
|
127
|
+
selectedWorkers: Set<string>;
|
|
128
|
+
onToggleWorker: (name: string) => void;
|
|
129
|
+
searchText: string;
|
|
130
|
+
onSearchChange: (v: string) => void;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function FilterPanel({
|
|
134
|
+
allLevels,
|
|
135
|
+
onToggleAll,
|
|
136
|
+
levels,
|
|
137
|
+
onToggleLevel,
|
|
138
|
+
workerNames,
|
|
139
|
+
selectedWorkers,
|
|
140
|
+
onToggleWorker,
|
|
141
|
+
searchText,
|
|
142
|
+
onSearchChange,
|
|
143
|
+
}: FilterPanelProps) {
|
|
144
|
+
return (
|
|
145
|
+
<Panel title="FILTERS" width={20} elevated={false}>
|
|
146
|
+
{/* ── Level Section ───────────────────────────────────────────────── */}
|
|
147
|
+
<text bold fg={Colors.muted}>
|
|
148
|
+
LEVEL
|
|
149
|
+
</text>
|
|
150
|
+
<box flexDirection="row" gap={1}>
|
|
151
|
+
<text
|
|
152
|
+
fg={allLevels ? Colors.accent : Colors.muted}
|
|
153
|
+
onMouseUp={onToggleAll}
|
|
154
|
+
>
|
|
155
|
+
{allLevels ? "[x]" : "[ ]"} ALL
|
|
156
|
+
</text>
|
|
157
|
+
</box>
|
|
158
|
+
{ALL_LEVELS.map((lvl) => (
|
|
159
|
+
<box flexDirection="row" gap={1} key={lvl}>
|
|
160
|
+
<text
|
|
161
|
+
fg={levels.has(lvl) ? Colors.accent : Colors.muted}
|
|
162
|
+
onMouseUp={() => onToggleLevel(lvl)}
|
|
163
|
+
>
|
|
164
|
+
{levels.has(lvl) ? "[x]" : "[ ]"}
|
|
165
|
+
</text>
|
|
166
|
+
<text fg={LogLevelColor[lvl]} dim={lvl === "debug"}>
|
|
167
|
+
{LEVEL_LABEL[lvl]}
|
|
168
|
+
</text>
|
|
169
|
+
</box>
|
|
170
|
+
))}
|
|
171
|
+
|
|
172
|
+
{/* ── Worker Section ──────────────────────────────────────────────── */}
|
|
173
|
+
<box height={1} />
|
|
174
|
+
<text bold fg={Colors.muted}>
|
|
175
|
+
WORKER
|
|
176
|
+
</text>
|
|
177
|
+
{workerNames.length === 0 ? (
|
|
178
|
+
<text dim fg={Colors.dim}>
|
|
179
|
+
(no workers)
|
|
180
|
+
</text>
|
|
181
|
+
) : (
|
|
182
|
+
<scrollbox height={6} border={false}>
|
|
183
|
+
{workerNames.map((name) => (
|
|
184
|
+
<box flexDirection="row" gap={1} key={name}>
|
|
185
|
+
<text
|
|
186
|
+
fg={selectedWorkers.has(name) ? Colors.accent : Colors.muted}
|
|
187
|
+
onMouseUp={() => onToggleWorker(name)}
|
|
188
|
+
>
|
|
189
|
+
{selectedWorkers.has(name) ? "[x]" : "[ ]"}
|
|
190
|
+
</text>
|
|
191
|
+
<text fg={Colors.foreground}>{truncateRight(name, 14)}</text>
|
|
192
|
+
</box>
|
|
193
|
+
))}
|
|
194
|
+
</scrollbox>
|
|
195
|
+
)}
|
|
196
|
+
|
|
197
|
+
{/* ── Search Section ──────────────────────────────────────────────── */}
|
|
198
|
+
<box height={1} />
|
|
199
|
+
<text bold fg={Colors.muted}>
|
|
200
|
+
SEARCH /
|
|
201
|
+
</text>
|
|
202
|
+
<input
|
|
203
|
+
id="log-search"
|
|
204
|
+
placeholder="Filter text..."
|
|
205
|
+
width={18}
|
|
206
|
+
textColor={Colors.foreground}
|
|
207
|
+
cursorColor={Colors.accent}
|
|
208
|
+
onInput={(v: string) => onSearchChange(v)}
|
|
209
|
+
value={searchText}
|
|
210
|
+
/>
|
|
211
|
+
</Panel>
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ─── Log Stream ──────────────────────────────────────────────────────────────
|
|
216
|
+
|
|
217
|
+
interface LogStreamProps {
|
|
218
|
+
entries: LogEntry[];
|
|
219
|
+
paused: boolean;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function LogStream({ entries, paused }: LogStreamProps) {
|
|
223
|
+
if (entries.length === 0) {
|
|
224
|
+
return (
|
|
225
|
+
<Panel title="LOG STREAM" flexGrow={1} elevated={false}>
|
|
226
|
+
<box
|
|
227
|
+
flexGrow={1}
|
|
228
|
+
justifyContent="center"
|
|
229
|
+
alignItems="center"
|
|
230
|
+
flexDirection="column"
|
|
231
|
+
>
|
|
232
|
+
<text dim fg={Colors.muted}>
|
|
233
|
+
{paused
|
|
234
|
+
? "Paused — press Space to resume"
|
|
235
|
+
: "No matching log entries"}
|
|
236
|
+
</text>
|
|
237
|
+
</box>
|
|
238
|
+
</Panel>
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return (
|
|
243
|
+
<scrollbox flexGrow={1} border={false}>
|
|
244
|
+
{entries.map((entry) => {
|
|
245
|
+
const fg = LogLevelColor[entry.level] ?? Colors.foreground;
|
|
246
|
+
const dim = entry.level === "debug";
|
|
247
|
+
const label = LEVEL_LABEL[entry.level] ?? String(entry.level);
|
|
248
|
+
const time = formatTimestamp(entry.timestamp);
|
|
249
|
+
const src = entry.source
|
|
250
|
+
? ` [${truncateRight(sanitizeLogText(entry.source, 24), 12)}]`
|
|
251
|
+
: "";
|
|
252
|
+
const workerTag = entry.workerId
|
|
253
|
+
? ` (${truncateRight(sanitizeLogText(entry.workerId, 20), 10)})`
|
|
254
|
+
: "";
|
|
255
|
+
const message = sanitizeLogText(entry.message ?? "");
|
|
256
|
+
|
|
257
|
+
return (
|
|
258
|
+
<box flexDirection="row" gap={1} key={entry.id}>
|
|
259
|
+
<text dim fg={Colors.muted} selectable>
|
|
260
|
+
{time}
|
|
261
|
+
</text>
|
|
262
|
+
<text fg={fg} bold={entry.level === "error"} dim={dim} selectable>
|
|
263
|
+
{label}
|
|
264
|
+
</text>
|
|
265
|
+
<text dim fg={Colors.muted} selectable>
|
|
266
|
+
{src}
|
|
267
|
+
{workerTag}
|
|
268
|
+
</text>
|
|
269
|
+
<text fg={dim ? Colors.dim : fg} selectable>
|
|
270
|
+
{message}
|
|
271
|
+
</text>
|
|
272
|
+
</box>
|
|
273
|
+
);
|
|
274
|
+
})}
|
|
275
|
+
</scrollbox>
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ─── Action Bar ──────────────────────────────────────────────────────────────
|
|
280
|
+
|
|
281
|
+
interface ActionBarProps {
|
|
282
|
+
paused: boolean;
|
|
283
|
+
onTogglePause: () => void;
|
|
284
|
+
onExport: () => void;
|
|
285
|
+
onClear: () => void;
|
|
286
|
+
sseConnected: boolean;
|
|
287
|
+
onFetch?: () => void;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function ActionBar({
|
|
291
|
+
paused,
|
|
292
|
+
onTogglePause,
|
|
293
|
+
onExport,
|
|
294
|
+
onClear,
|
|
295
|
+
sseConnected,
|
|
296
|
+
onFetch,
|
|
297
|
+
}: ActionBarProps) {
|
|
298
|
+
return (
|
|
299
|
+
<box
|
|
300
|
+
flexDirection="row"
|
|
301
|
+
height={1}
|
|
302
|
+
justifyContent="space-between"
|
|
303
|
+
paddingLeft={1}
|
|
304
|
+
paddingRight={1}
|
|
305
|
+
border={true}
|
|
306
|
+
borderStyle="single"
|
|
307
|
+
>
|
|
308
|
+
<box flexDirection="row" gap={2}>
|
|
309
|
+
<text
|
|
310
|
+
fg={paused ? Colors.accent : Colors.foreground}
|
|
311
|
+
onMouseUp={onTogglePause}
|
|
312
|
+
>
|
|
313
|
+
[{paused ? "\u25B6 Resume" : "\u275A\u275A Pause"}]
|
|
314
|
+
</text>
|
|
315
|
+
<text fg={Colors.foreground} onMouseUp={onExport}>
|
|
316
|
+
[Export]
|
|
317
|
+
</text>
|
|
318
|
+
<text fg={Colors.foreground} onMouseUp={onClear}>
|
|
319
|
+
[Clear]
|
|
320
|
+
</text>
|
|
321
|
+
{!sseConnected && onFetch ? (
|
|
322
|
+
<text fg={Colors.accent} onMouseUp={onFetch}>
|
|
323
|
+
[FETCH]
|
|
324
|
+
</text>
|
|
325
|
+
) : null}
|
|
326
|
+
</box>
|
|
327
|
+
<text dim fg={Colors.muted}>
|
|
328
|
+
Space:pause /:search Tab:switch
|
|
329
|
+
</text>
|
|
330
|
+
</box>
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// ─── Main View ───────────────────────────────────────────────────────────────
|
|
335
|
+
|
|
336
|
+
export function LogsViewer() {
|
|
337
|
+
// ── Local state ─────────────────────────────────────────────────────────
|
|
338
|
+
const [paused, setPaused] = useState(false);
|
|
339
|
+
const [allLevels, setAllLevels] = useState(true);
|
|
340
|
+
const [levels, setLevels] = useState<Set<LogLevel>>(new Set());
|
|
341
|
+
const [selectedWorkers, setSelectedWorkers] = useState<Set<string>>(
|
|
342
|
+
new Set()
|
|
343
|
+
);
|
|
344
|
+
const [searchText, setSearchText] = useState("");
|
|
345
|
+
/** Debounced copy used for filtering (avoids O(n) refilter per keystroke). */
|
|
346
|
+
const [debouncedSearch, setDebouncedSearch] = useState("");
|
|
347
|
+
/** When true, printable keys append to searchText instead of being ignored. */
|
|
348
|
+
const [searchActive, setSearchActive] = useState(false);
|
|
349
|
+
const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
350
|
+
|
|
351
|
+
// ── Store subscriptions (selectors only) ────────────────────────────────
|
|
352
|
+
const allLogs = useServiceStore((s) => s.logs);
|
|
353
|
+
const workers = useServiceStore((s) => s.workers);
|
|
354
|
+
const connectionStatus = useServiceStore((s) => s.connectionStatus);
|
|
355
|
+
const activeView = useUIStore((s) => s.activeView);
|
|
356
|
+
const isActive = activeView === "logs-viewer";
|
|
357
|
+
const sseConnected = connectionStatus === "connected";
|
|
358
|
+
|
|
359
|
+
// Debounce search input; clear timer on unmount
|
|
360
|
+
useEffect(() => {
|
|
361
|
+
if (searchTimerRef.current) clearTimeout(searchTimerRef.current);
|
|
362
|
+
searchTimerRef.current = setTimeout(() => {
|
|
363
|
+
setDebouncedSearch(searchText);
|
|
364
|
+
searchTimerRef.current = null;
|
|
365
|
+
}, SEARCH_DEBOUNCE_MS);
|
|
366
|
+
return () => {
|
|
367
|
+
if (searchTimerRef.current) {
|
|
368
|
+
clearTimeout(searchTimerRef.current);
|
|
369
|
+
searchTimerRef.current = null;
|
|
370
|
+
}
|
|
371
|
+
};
|
|
372
|
+
}, [searchText]);
|
|
373
|
+
|
|
374
|
+
// Derive unique worker names for checkboxes
|
|
375
|
+
const workerNames = useMemo(() => workers.map((w) => w.name), [workers]);
|
|
376
|
+
|
|
377
|
+
// ── Toggle helpers ──────────────────────────────────────────────────────
|
|
378
|
+
const toggleAll = useCallback(() => {
|
|
379
|
+
setAllLevels((prev) => !prev);
|
|
380
|
+
}, []);
|
|
381
|
+
|
|
382
|
+
const toggleLevel = useCallback((lvl: LogLevel) => {
|
|
383
|
+
setLevels((prev) => {
|
|
384
|
+
const next = new Set(prev);
|
|
385
|
+
if (next.has(lvl)) next.delete(lvl);
|
|
386
|
+
else next.add(lvl);
|
|
387
|
+
return next;
|
|
388
|
+
});
|
|
389
|
+
}, []);
|
|
390
|
+
|
|
391
|
+
const toggleWorker = useCallback((name: string) => {
|
|
392
|
+
setSelectedWorkers((prev) => {
|
|
393
|
+
const next = new Set(prev);
|
|
394
|
+
if (next.has(name)) next.delete(name);
|
|
395
|
+
else next.add(name);
|
|
396
|
+
return next;
|
|
397
|
+
});
|
|
398
|
+
}, []);
|
|
399
|
+
|
|
400
|
+
const handleExport = useCallback(async () => {
|
|
401
|
+
const ts = Date.now();
|
|
402
|
+
try {
|
|
403
|
+
const result = await cliBridge.exec<LogEntry[]>(["logs", "all"], {
|
|
404
|
+
json: true,
|
|
405
|
+
timeout: 20_000,
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
let data: LogEntry[];
|
|
409
|
+
if (result.success && result.data) {
|
|
410
|
+
data = result.data;
|
|
411
|
+
} else {
|
|
412
|
+
data = useServiceStore.getState().logs;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const homeDir = process.env.HOME || process.env.USERPROFILE || ".";
|
|
416
|
+
const dirPath = `${homeDir}/.hoox`;
|
|
417
|
+
const filePath = `${dirPath}/logs-export-${ts}.json`;
|
|
418
|
+
await Bun.write(filePath, JSON.stringify(data, null, 2));
|
|
419
|
+
|
|
420
|
+
const alert: Alert = {
|
|
421
|
+
id: `export-${ts}`,
|
|
422
|
+
type: "info",
|
|
423
|
+
severity: "info",
|
|
424
|
+
message: `Exported ${data.length} logs to ${filePath}`,
|
|
425
|
+
timestamp: ts,
|
|
426
|
+
acknowledged: false,
|
|
427
|
+
};
|
|
428
|
+
useServiceStore.getState().addAlert(alert);
|
|
429
|
+
} catch (err) {
|
|
430
|
+
const alert: Alert = {
|
|
431
|
+
id: `export-err-${Date.now()}`,
|
|
432
|
+
type: "error",
|
|
433
|
+
severity: "error",
|
|
434
|
+
message: `Export failed: ${(err as Error).message}`,
|
|
435
|
+
timestamp: Date.now(),
|
|
436
|
+
acknowledged: false,
|
|
437
|
+
};
|
|
438
|
+
useServiceStore.getState().addAlert(alert);
|
|
439
|
+
}
|
|
440
|
+
}, []);
|
|
441
|
+
|
|
442
|
+
const handleClear = useCallback(() => {
|
|
443
|
+
// Soft confirm via status alert path — clearing is local buffer only.
|
|
444
|
+
// (No dialog prop on this view; keep non-destructive.)
|
|
445
|
+
const ts = Date.now();
|
|
446
|
+
useServiceStore.setState((state) => {
|
|
447
|
+
state.logs = [];
|
|
448
|
+
});
|
|
449
|
+
const alert: Alert = {
|
|
450
|
+
id: `clear-${ts}`,
|
|
451
|
+
type: "info",
|
|
452
|
+
severity: "info",
|
|
453
|
+
message: "Log buffer cleared",
|
|
454
|
+
timestamp: ts,
|
|
455
|
+
acknowledged: false,
|
|
456
|
+
};
|
|
457
|
+
useServiceStore.getState().addAlert(alert);
|
|
458
|
+
}, []);
|
|
459
|
+
|
|
460
|
+
const handleFetch = useCallback(async () => {
|
|
461
|
+
const ts = Date.now();
|
|
462
|
+
let fetchedCount = 0;
|
|
463
|
+
const names = useServiceStore.getState().workers.map((w) => w.name);
|
|
464
|
+
if (names.length === 0) {
|
|
465
|
+
names.push("all");
|
|
466
|
+
}
|
|
467
|
+
for (const name of names) {
|
|
468
|
+
try {
|
|
469
|
+
const result = await cliBridge.workerLogs(name);
|
|
470
|
+
if (result.success && Array.isArray(result.data)) {
|
|
471
|
+
const entries = result.data as LogEntry[];
|
|
472
|
+
for (const entry of entries) {
|
|
473
|
+
useServiceStore.getState().pushLog(entry);
|
|
474
|
+
}
|
|
475
|
+
fetchedCount += entries.length;
|
|
476
|
+
}
|
|
477
|
+
} catch {
|
|
478
|
+
// skip failed worker
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
const alert: Alert = {
|
|
482
|
+
id: `fetch-${ts}`,
|
|
483
|
+
type: "info",
|
|
484
|
+
severity: "info",
|
|
485
|
+
message:
|
|
486
|
+
fetchedCount > 0
|
|
487
|
+
? `Fetched ${fetchedCount} logs from CLI`
|
|
488
|
+
: "No logs fetched from CLI",
|
|
489
|
+
timestamp: ts,
|
|
490
|
+
acknowledged: false,
|
|
491
|
+
};
|
|
492
|
+
useServiceStore.getState().addAlert(alert);
|
|
493
|
+
}, []);
|
|
494
|
+
|
|
495
|
+
// ── Filter logic (AND combination) ──────────────────────────────────────
|
|
496
|
+
const filteredLogs = useMemo(
|
|
497
|
+
() =>
|
|
498
|
+
applyLogFilters(allLogs, {
|
|
499
|
+
allLevels,
|
|
500
|
+
levels,
|
|
501
|
+
selectedWorkers,
|
|
502
|
+
searchText: debouncedSearch,
|
|
503
|
+
workers,
|
|
504
|
+
}),
|
|
505
|
+
[allLogs, allLevels, levels, selectedWorkers, debouncedSearch, workers]
|
|
506
|
+
);
|
|
507
|
+
|
|
508
|
+
// Display newest-first, capped for render performance
|
|
509
|
+
const displayed = useMemo(() => {
|
|
510
|
+
const newestFirst = filteredLogs.slice().reverse();
|
|
511
|
+
return newestFirst.slice(0, MAX_VISIBLE_LOGS);
|
|
512
|
+
}, [filteredLogs]);
|
|
513
|
+
|
|
514
|
+
// ── Paused buffer ───────────────────────────────────────────────────────
|
|
515
|
+
const [frozen, setFrozen] = useState<LogEntry[] | null>(null);
|
|
516
|
+
const visible = paused && frozen !== null ? frozen : displayed;
|
|
517
|
+
|
|
518
|
+
// Capture snapshot when pausing, clear when resuming
|
|
519
|
+
useEffect(() => {
|
|
520
|
+
if (paused && frozen === null) {
|
|
521
|
+
setFrozen(displayed);
|
|
522
|
+
}
|
|
523
|
+
if (!paused && frozen !== null) {
|
|
524
|
+
setFrozen(null);
|
|
525
|
+
}
|
|
526
|
+
}, [paused, frozen, displayed]);
|
|
527
|
+
|
|
528
|
+
// ── Keyboard (active view only) ─────────────────────────────────────────
|
|
529
|
+
useKeyboard((key: { name: string; sequence?: string; ctrl?: boolean }) => {
|
|
530
|
+
if (!isActive) return;
|
|
531
|
+
|
|
532
|
+
// Search mode: type to filter, Esc / Enter to leave
|
|
533
|
+
if (searchActive) {
|
|
534
|
+
if (key.name === "escape" || key.name === "return") {
|
|
535
|
+
setSearchActive(false);
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
if (key.name === "backspace" || key.name === "delete") {
|
|
539
|
+
setSearchText((t) => t.slice(0, -1));
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
if (key.sequence && key.sequence.length === 1 && key.sequence >= " ") {
|
|
543
|
+
setSearchText((t) => t + key.sequence);
|
|
544
|
+
}
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
if (key.name === "space") {
|
|
549
|
+
setPaused((p) => !p);
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
if (key.name === "slash" || key.name === "/") {
|
|
553
|
+
setSearchActive(true);
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
return (
|
|
559
|
+
<ErrorBoundary viewName="Logs Viewer">
|
|
560
|
+
<box flexDirection="column" flexGrow={1} padding={0} gap={1}>
|
|
561
|
+
{/* ── Title bar ────────────────────────────────────────────────────── */}
|
|
562
|
+
<ViewHeader
|
|
563
|
+
title="LOGS VIEWER"
|
|
564
|
+
showDivider={false}
|
|
565
|
+
meta={
|
|
566
|
+
<box flexDirection="row" gap={2} alignItems="center">
|
|
567
|
+
<text dim fg={Colors.muted}>
|
|
568
|
+
{filteredLogs.length}/{allLogs.length} entries
|
|
569
|
+
{filteredLogs.length > MAX_VISIBLE_LOGS
|
|
570
|
+
? ` · showing ${MAX_VISIBLE_LOGS}`
|
|
571
|
+
: ""}
|
|
572
|
+
{paused ? " [PAUSED]" : ""}
|
|
573
|
+
{searchActive ? " [SEARCH]" : ""}
|
|
574
|
+
</text>
|
|
575
|
+
<text
|
|
576
|
+
fg={sseConnected ? Colors.success : Colors.warning}
|
|
577
|
+
dim={!sseConnected}
|
|
578
|
+
>
|
|
579
|
+
{sseConnected ? "SSE" : "CLI"}
|
|
580
|
+
</text>
|
|
581
|
+
<text dim fg={Colors.dim}>
|
|
582
|
+
Space pause · / search
|
|
583
|
+
</text>
|
|
584
|
+
</box>
|
|
585
|
+
}
|
|
586
|
+
/>
|
|
587
|
+
|
|
588
|
+
{/* ── Split: Filters | Stream ──────────────────────────────────────── */}
|
|
589
|
+
<box flexDirection="row" flexGrow={1} gap={1}>
|
|
590
|
+
<FilterPanel
|
|
591
|
+
allLevels={allLevels}
|
|
592
|
+
onToggleAll={toggleAll}
|
|
593
|
+
levels={levels}
|
|
594
|
+
onToggleLevel={toggleLevel}
|
|
595
|
+
workerNames={workerNames}
|
|
596
|
+
selectedWorkers={selectedWorkers}
|
|
597
|
+
onToggleWorker={toggleWorker}
|
|
598
|
+
searchText={searchText}
|
|
599
|
+
onSearchChange={setSearchText}
|
|
600
|
+
/>
|
|
601
|
+
<LogStream entries={visible} paused={paused} />
|
|
602
|
+
</box>
|
|
603
|
+
|
|
604
|
+
{/* ── Action Bar footer ────────────────────────────────────────────── */}
|
|
605
|
+
<ActionBar
|
|
606
|
+
paused={paused}
|
|
607
|
+
onTogglePause={() => setPaused((p) => !p)}
|
|
608
|
+
onExport={handleExport}
|
|
609
|
+
onClear={handleClear}
|
|
610
|
+
sseConnected={sseConnected}
|
|
611
|
+
onFetch={handleFetch}
|
|
612
|
+
/>
|
|
613
|
+
</box>
|
|
614
|
+
</ErrorBoundary>
|
|
615
|
+
);
|
|
616
|
+
}
|