salvetron 0.1.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 +333 -0
- package/bin/salvetron.tsx +9 -0
- package/package.json +43 -0
- package/scripts/salvetron-sim.mjs +75 -0
- package/src/app.tsx +62 -0
- package/src/modules/dashboard/store/dashboard.store.ts +19 -0
- package/src/modules/dashboard/ui/components/metric-row/index.ts +1 -0
- package/src/modules/dashboard/ui/components/metric-row/metric-row.tsx +25 -0
- package/src/modules/dashboard/ui/components/performance-panel/index.ts +1 -0
- package/src/modules/dashboard/ui/components/performance-panel/performance-panel.tsx +67 -0
- package/src/modules/dashboard/ui/containers/dashboard-container/dashboard-container.tsx +290 -0
- package/src/modules/dashboard/ui/containers/dashboard-container/index.ts +1 -0
- package/src/modules/js-logs/store/js-logs.store.ts +21 -0
- package/src/modules/js-logs/ui/components/log-detail/index.ts +1 -0
- package/src/modules/js-logs/ui/components/log-detail/log-detail.tsx +62 -0
- package/src/modules/js-logs/ui/components/log-list/index.ts +1 -0
- package/src/modules/js-logs/ui/components/log-list/log-list.tsx +32 -0
- package/src/modules/js-logs/ui/containers/js-logs-container/index.ts +1 -0
- package/src/modules/js-logs/ui/containers/js-logs-container/js-logs-container.tsx +80 -0
- package/src/modules/native-logs/store/native-logs.store.ts +21 -0
- package/src/modules/native-logs/ui/components/native-log-detail/index.ts +1 -0
- package/src/modules/native-logs/ui/components/native-log-detail/native-log-detail.tsx +63 -0
- package/src/modules/native-logs/ui/components/native-log-list/index.ts +1 -0
- package/src/modules/native-logs/ui/components/native-log-list/native-log-list.tsx +33 -0
- package/src/modules/native-logs/ui/containers/native-logs-container/index.ts +1 -0
- package/src/modules/native-logs/ui/containers/native-logs-container/native-logs-container.tsx +80 -0
- package/src/modules/network/library/constants.ts +15 -0
- package/src/modules/network/store/network.store.ts +58 -0
- package/src/modules/network/ui/components/network-detail/index.ts +1 -0
- package/src/modules/network/ui/components/network-detail/network-detail.tsx +82 -0
- package/src/modules/network/ui/components/network-row/index.ts +1 -0
- package/src/modules/network/ui/components/network-row/network-row.tsx +23 -0
- package/src/modules/network/ui/components/network-table-header/index.ts +1 -0
- package/src/modules/network/ui/components/network-table-header/network-table-header.tsx +12 -0
- package/src/modules/network/ui/containers/network-container/index.ts +1 -0
- package/src/modules/network/ui/containers/network-container/network-container.tsx +93 -0
- package/src/server/ws-server.ts +52 -0
- package/src/shared/components/ascii-logo/ascii-logo.tsx +169 -0
- package/src/shared/components/ascii-logo/ascii.txt +0 -0
- package/src/shared/components/ascii-logo/index.ts +1 -0
- package/src/shared/components/gauge/gauge.tsx +18 -0
- package/src/shared/components/gauge/index.ts +1 -0
- package/src/shared/components/log-row/index.ts +1 -0
- package/src/shared/components/log-row/log-row.tsx +28 -0
- package/src/shared/components/panel/index.ts +1 -0
- package/src/shared/components/panel/panel.tsx +28 -0
- package/src/shared/components/sparkline/index.ts +1 -0
- package/src/shared/components/sparkline/sparkline.tsx +26 -0
- package/src/shared/components/status-bar/index.ts +1 -0
- package/src/shared/components/status-bar/status-bar.tsx +32 -0
- package/src/shared/components/tab-bar/index.ts +1 -0
- package/src/shared/components/tab-bar/tab-bar.tsx +36 -0
- package/src/shared/hooks/use-detail-panel.ts +78 -0
- package/src/shared/hooks/use-list-navigation.ts +44 -0
- package/src/shared/hooks/use-terminal-size.ts +42 -0
- package/src/shared/store/device.store.ts +25 -0
- package/src/shared/types.ts +3 -0
- package/src/shared/utils/build-curl-command.ts +19 -0
- package/src/shared/utils/clipboard.ts +27 -0
- package/src/shared/utils/format-body.ts +21 -0
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import { Box, Text, useInput } from "ink";
|
|
2
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
3
|
+
import { useTerminalSize } from "../../../../../shared/hooks/use-terminal-size.js";
|
|
4
|
+
import { useListNavigation } from "../../../../../shared/hooks/use-list-navigation.js";
|
|
5
|
+
import { useDetailPanel } from "../../../../../shared/hooks/use-detail-panel.js";
|
|
6
|
+
import { Panel } from "../../../../../shared/components/panel/index.js";
|
|
7
|
+
import { formatBody, formatPlainBody } from "../../../../../shared/utils/format-body.js";
|
|
8
|
+
import { buildCurlCommand } from "../../../../../shared/utils/build-curl-command.js";
|
|
9
|
+
import {
|
|
10
|
+
useDashboardSnapshots,
|
|
11
|
+
useLatestSnapshot,
|
|
12
|
+
} from "../../../store/dashboard.store.js";
|
|
13
|
+
import { PerformancePanel } from "../../components/performance-panel/index.js";
|
|
14
|
+
import { useJsLogs } from "../../../../js-logs/store/js-logs.store.js";
|
|
15
|
+
import { LogList } from "../../../../js-logs/ui/components/log-list/index.js";
|
|
16
|
+
import { LogDetail } from "../../../../js-logs/ui/components/log-detail/index.js";
|
|
17
|
+
import { useNetworkLogs } from "../../../../network/store/network.store.js";
|
|
18
|
+
import { NetworkTableHeader } from "../../../../network/ui/components/network-table-header/index.js";
|
|
19
|
+
import { NetworkRow } from "../../../../network/ui/components/network-row/index.js";
|
|
20
|
+
import { NetworkDetail } from "../../../../network/ui/components/network-detail/index.js";
|
|
21
|
+
import { useNativeLogs } from "../../../../native-logs/store/native-logs.store.js";
|
|
22
|
+
import { NativeLogList } from "../../../../native-logs/ui/components/native-log-list/index.js";
|
|
23
|
+
import { NativeLogDetail } from "../../../../native-logs/ui/components/native-log-detail/index.js";
|
|
24
|
+
import type { LogEvent, NativeLogEvent, NetworkLog } from "@salve-software/salvetron-types";
|
|
25
|
+
|
|
26
|
+
type FocusPanel = "logs" | "network" | "native";
|
|
27
|
+
const PANELS: FocusPanel[] = ["logs", "network", "native"];
|
|
28
|
+
|
|
29
|
+
// Chrome rendered by App around DashboardContainer's content area:
|
|
30
|
+
// AsciiLogo (6) + TabBar (marginTop 2 + content 1 + borderBottom 1 = 4) +
|
|
31
|
+
// content wrapper paddingTop (1) = 11 above; StatusBar (borderTop 1 +
|
|
32
|
+
// content 1 = 2) + content wrapper paddingBottom (1) = 3 below.
|
|
33
|
+
const APP_OVERHEAD_ROWS = 14;
|
|
34
|
+
const FOOTER_ROWS = 1;
|
|
35
|
+
const PERF_PANEL_ROWS = 7;
|
|
36
|
+
const PANEL_CHROME_ROWS = 3; // title (1) + border (2) per list Panel
|
|
37
|
+
const NETWORK_HEADER_ROWS = 1;
|
|
38
|
+
// NetworkDetail's leading content (border + header + url + req headers + res
|
|
39
|
+
// headers + body header) can take up to 6 rows, plus the wrapper Box's own
|
|
40
|
+
// top/bottom border (2 rows) — use that as the shared budget for all 3 detail
|
|
41
|
+
// types so content never exceeds maxHeight (overflow="hidden" + an
|
|
42
|
+
// over-budget frame corrupts Ink's redraw).
|
|
43
|
+
const DETAIL_FIXED_ROWS = 8;
|
|
44
|
+
|
|
45
|
+
export function DashboardContainer() {
|
|
46
|
+
const [cols, rows] = useTerminalSize();
|
|
47
|
+
const [focused, setFocused] = useState<FocusPanel>("logs");
|
|
48
|
+
|
|
49
|
+
const snapshots = useDashboardSnapshots();
|
|
50
|
+
const latest = useLatestSnapshot();
|
|
51
|
+
const jsLogs = useJsLogs();
|
|
52
|
+
const networkLogs = useNetworkLogs();
|
|
53
|
+
const nativeLogs = useNativeLogs();
|
|
54
|
+
|
|
55
|
+
const panelInner = Math.max(10, Math.floor(cols / 3) - 4);
|
|
56
|
+
const sparkWidth = Math.max(4, panelInner - 27);
|
|
57
|
+
const urlMaxWidth = Math.max(6, panelInner - 28);
|
|
58
|
+
const logsMsgWidth = Math.max(8, panelInner - 14);
|
|
59
|
+
const nativeMsgWidth = Math.max(8, panelInner - 26);
|
|
60
|
+
|
|
61
|
+
// Row budget derived only from the terminal's real row count — never from
|
|
62
|
+
// measured/rendered content, which would feed back into itself and loop.
|
|
63
|
+
const availableRows = rows - APP_OVERHEAD_ROWS - FOOTER_ROWS;
|
|
64
|
+
const logsPanelRows = Math.max(
|
|
65
|
+
1,
|
|
66
|
+
Math.floor((availableRows - PERF_PANEL_ROWS) / 3) - PANEL_CHROME_ROWS,
|
|
67
|
+
);
|
|
68
|
+
const nativePanelRows = logsPanelRows;
|
|
69
|
+
const networkPanelRows = Math.max(1, logsPanelRows - NETWORK_HEADER_ROWS);
|
|
70
|
+
|
|
71
|
+
const detailRows = availableRows;
|
|
72
|
+
const detailBodyVisibleRows = Math.max(1, detailRows - DETAIL_FIXED_ROWS);
|
|
73
|
+
|
|
74
|
+
const logsLinesRef = useRef<string[]>([]);
|
|
75
|
+
const netLinesRef = useRef<string[]>([]);
|
|
76
|
+
const nativeLinesRef = useRef<string[]>([]);
|
|
77
|
+
|
|
78
|
+
const selLogRef = useRef<LogEvent | null>(null);
|
|
79
|
+
const selNetRef = useRef<NetworkLog | null>(null);
|
|
80
|
+
const selNativeRef = useRef<NativeLogEvent | null>(null);
|
|
81
|
+
|
|
82
|
+
const onCopyLogBody = useCallback(() => {
|
|
83
|
+
const log = selLogRef.current;
|
|
84
|
+
if (!log) return "";
|
|
85
|
+
const meta = log.metadata ? formatPlainBody(JSON.stringify(log.metadata)) : "";
|
|
86
|
+
return meta ? `${log.message}\n\n${meta}` : log.message;
|
|
87
|
+
}, []);
|
|
88
|
+
const onCopyNativeBody = useCallback(() => {
|
|
89
|
+
const log = selNativeRef.current;
|
|
90
|
+
if (!log) return "";
|
|
91
|
+
const meta = log.metadata ? formatPlainBody(JSON.stringify(log.metadata)) : "";
|
|
92
|
+
return meta ? `${log.message}\n\n${meta}` : log.message;
|
|
93
|
+
}, []);
|
|
94
|
+
const onCopyNetBody = useCallback(() => {
|
|
95
|
+
const log = selNetRef.current;
|
|
96
|
+
return log ? formatPlainBody(log.responseBody) : "";
|
|
97
|
+
}, []);
|
|
98
|
+
const onCopyNetExtra = useCallback(() => {
|
|
99
|
+
const log = selNetRef.current;
|
|
100
|
+
return log ? buildCurlCommand(log) : "";
|
|
101
|
+
}, []);
|
|
102
|
+
|
|
103
|
+
const logsDetail = useDetailPanel({
|
|
104
|
+
linesRef: logsLinesRef,
|
|
105
|
+
visibleRows: detailBodyVisibleRows,
|
|
106
|
+
scrollStep: 5,
|
|
107
|
+
isActive: focused === "logs",
|
|
108
|
+
onCopyBody: onCopyLogBody,
|
|
109
|
+
});
|
|
110
|
+
const netDetail = useDetailPanel({
|
|
111
|
+
linesRef: netLinesRef,
|
|
112
|
+
visibleRows: detailBodyVisibleRows,
|
|
113
|
+
scrollStep: 5,
|
|
114
|
+
isActive: focused === "network",
|
|
115
|
+
onCopyBody: onCopyNetBody,
|
|
116
|
+
onCopyExtra: onCopyNetExtra,
|
|
117
|
+
});
|
|
118
|
+
const nativeDetail = useDetailPanel({
|
|
119
|
+
linesRef: nativeLinesRef,
|
|
120
|
+
visibleRows: detailBodyVisibleRows,
|
|
121
|
+
scrollStep: 5,
|
|
122
|
+
isActive: focused === "native",
|
|
123
|
+
onCopyBody: onCopyNativeBody,
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
const focusedDetailOpen =
|
|
127
|
+
(focused === "logs" && logsDetail.detailOpen) ||
|
|
128
|
+
(focused === "network" && netDetail.detailOpen) ||
|
|
129
|
+
(focused === "native" && nativeDetail.detailOpen);
|
|
130
|
+
|
|
131
|
+
const logsNav = useListNavigation({
|
|
132
|
+
count: jsLogs.length,
|
|
133
|
+
visibleRows: logsPanelRows,
|
|
134
|
+
isActive: focused === "logs",
|
|
135
|
+
});
|
|
136
|
+
const netNav = useListNavigation({
|
|
137
|
+
count: networkLogs.length,
|
|
138
|
+
visibleRows: networkPanelRows,
|
|
139
|
+
isActive: focused === "network",
|
|
140
|
+
});
|
|
141
|
+
const nativeNav = useListNavigation({
|
|
142
|
+
count: nativeLogs.length,
|
|
143
|
+
visibleRows: nativePanelRows,
|
|
144
|
+
isActive: focused === "native",
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
const selLog = jsLogs[logsNav.selectedIndex] ?? null;
|
|
148
|
+
const selNet = networkLogs[netNav.selectedIndex] ?? null;
|
|
149
|
+
const selNative = nativeLogs[nativeNav.selectedIndex] ?? null;
|
|
150
|
+
|
|
151
|
+
const logMeta = useMemo(
|
|
152
|
+
() => (selLog?.metadata ? formatBody(JSON.stringify(selLog.metadata)) : []),
|
|
153
|
+
[selLog],
|
|
154
|
+
);
|
|
155
|
+
const netBody = useMemo(() => formatBody(selNet?.responseBody), [selNet]);
|
|
156
|
+
const nativeMeta = useMemo(
|
|
157
|
+
() => (selNative?.metadata ? formatBody(JSON.stringify(selNative.metadata)) : []),
|
|
158
|
+
[selNative],
|
|
159
|
+
);
|
|
160
|
+
logsLinesRef.current = logMeta;
|
|
161
|
+
netLinesRef.current = netBody;
|
|
162
|
+
nativeLinesRef.current = nativeMeta;
|
|
163
|
+
selLogRef.current = selLog;
|
|
164
|
+
selNetRef.current = selNet;
|
|
165
|
+
selNativeRef.current = selNative;
|
|
166
|
+
|
|
167
|
+
useEffect(() => {
|
|
168
|
+
logsDetail.resetDetailScroll();
|
|
169
|
+
}, [logsNav.selectedIndex, logsDetail.resetDetailScroll]);
|
|
170
|
+
useEffect(() => {
|
|
171
|
+
netDetail.resetDetailScroll();
|
|
172
|
+
}, [netNav.selectedIndex, netDetail.resetDetailScroll]);
|
|
173
|
+
useEffect(() => {
|
|
174
|
+
nativeDetail.resetDetailScroll();
|
|
175
|
+
}, [nativeNav.selectedIndex, nativeDetail.resetDetailScroll]);
|
|
176
|
+
|
|
177
|
+
useInput((_input, key) => {
|
|
178
|
+
if (key.leftArrow) {
|
|
179
|
+
const i = PANELS.indexOf(focused);
|
|
180
|
+
setFocused(PANELS[(i - 1 + PANELS.length) % PANELS.length]);
|
|
181
|
+
}
|
|
182
|
+
if (key.rightArrow) {
|
|
183
|
+
const i = PANELS.indexOf(focused);
|
|
184
|
+
setFocused(PANELS[(i + 1) % PANELS.length]);
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
return (
|
|
189
|
+
<Box flexDirection="column" flexGrow={1}>
|
|
190
|
+
<Box flexDirection="row" flexGrow={1}>
|
|
191
|
+
<Box flexDirection="column">
|
|
192
|
+
<PerformancePanel
|
|
193
|
+
latest={latest}
|
|
194
|
+
snapshots={snapshots}
|
|
195
|
+
sparkWidth={sparkWidth}
|
|
196
|
+
height={PERF_PANEL_ROWS}
|
|
197
|
+
/>
|
|
198
|
+
|
|
199
|
+
<Panel title="Logs" focused={focused === "logs"} flexGrow={1}>
|
|
200
|
+
<LogList
|
|
201
|
+
logs={jsLogs}
|
|
202
|
+
visibleRows={logsPanelRows}
|
|
203
|
+
selectedIndex={logsNav.selectedIndex}
|
|
204
|
+
scrollOffset={logsNav.scrollOffset}
|
|
205
|
+
showHeader={false}
|
|
206
|
+
maxMessageWidth={logsMsgWidth}
|
|
207
|
+
/>
|
|
208
|
+
</Panel>
|
|
209
|
+
|
|
210
|
+
<Panel title="Network" focused={focused === "network"} flexGrow={1}>
|
|
211
|
+
<NetworkTableHeader />
|
|
212
|
+
{networkLogs
|
|
213
|
+
.slice(
|
|
214
|
+
netNav.scrollOffset,
|
|
215
|
+
netNav.scrollOffset + networkPanelRows,
|
|
216
|
+
)
|
|
217
|
+
.map((log, i) => {
|
|
218
|
+
const absoluteIndex = netNav.scrollOffset + i;
|
|
219
|
+
return (
|
|
220
|
+
<NetworkRow
|
|
221
|
+
key={log.requestId}
|
|
222
|
+
log={log}
|
|
223
|
+
urlMaxWidth={urlMaxWidth}
|
|
224
|
+
isSelected={absoluteIndex === netNav.selectedIndex}
|
|
225
|
+
/>
|
|
226
|
+
);
|
|
227
|
+
})}
|
|
228
|
+
</Panel>
|
|
229
|
+
|
|
230
|
+
<Panel title="Native" focused={focused === "native"} flexGrow={1}>
|
|
231
|
+
<NativeLogList
|
|
232
|
+
logs={nativeLogs}
|
|
233
|
+
visibleRows={nativePanelRows}
|
|
234
|
+
selectedIndex={nativeNav.selectedIndex}
|
|
235
|
+
scrollOffset={nativeNav.scrollOffset}
|
|
236
|
+
showHeader={false}
|
|
237
|
+
maxMessageWidth={nativeMsgWidth}
|
|
238
|
+
/>
|
|
239
|
+
</Panel>
|
|
240
|
+
</Box>
|
|
241
|
+
<Box
|
|
242
|
+
flexDirection="column"
|
|
243
|
+
height={focusedDetailOpen ? undefined : 0}
|
|
244
|
+
maxHeight={focusedDetailOpen ? detailRows : 0}
|
|
245
|
+
overflow="hidden"
|
|
246
|
+
flexGrow={1}
|
|
247
|
+
borderStyle="single"
|
|
248
|
+
borderColor="gray"
|
|
249
|
+
paddingX={2}
|
|
250
|
+
>
|
|
251
|
+
{focused === "logs" && logsDetail.detailOpen && selLog ? (
|
|
252
|
+
<LogDetail
|
|
253
|
+
log={selLog}
|
|
254
|
+
width={cols}
|
|
255
|
+
metaLines={logMeta}
|
|
256
|
+
metaScrollOffset={logsDetail.detailScrollOffset}
|
|
257
|
+
metaVisibleRows={detailBodyVisibleRows}
|
|
258
|
+
copyFeedback={logsDetail.copyFeedback}
|
|
259
|
+
/>
|
|
260
|
+
) : null}
|
|
261
|
+
{focused === "network" && netDetail.detailOpen && selNet ? (
|
|
262
|
+
<NetworkDetail
|
|
263
|
+
log={selNet}
|
|
264
|
+
width={cols}
|
|
265
|
+
bodyLines={netBody}
|
|
266
|
+
bodyScrollOffset={netDetail.detailScrollOffset}
|
|
267
|
+
bodyVisibleRows={detailBodyVisibleRows}
|
|
268
|
+
copyFeedback={netDetail.copyFeedback}
|
|
269
|
+
/>
|
|
270
|
+
) : null}
|
|
271
|
+
{focused === "native" && nativeDetail.detailOpen && selNative ? (
|
|
272
|
+
<NativeLogDetail
|
|
273
|
+
log={selNative}
|
|
274
|
+
width={cols}
|
|
275
|
+
metaLines={nativeMeta}
|
|
276
|
+
metaScrollOffset={nativeDetail.detailScrollOffset}
|
|
277
|
+
metaVisibleRows={detailBodyVisibleRows}
|
|
278
|
+
copyFeedback={nativeDetail.copyFeedback}
|
|
279
|
+
/>
|
|
280
|
+
) : null}
|
|
281
|
+
</Box>
|
|
282
|
+
</Box>
|
|
283
|
+
<Box>
|
|
284
|
+
<Text color="whiteBright" dimColor>
|
|
285
|
+
←→ panel · ↑↓ navigate · ↵ open · esc close · [ ] scroll detail · c copy{focused === "network" ? " · u curl" : ""}
|
|
286
|
+
</Text>
|
|
287
|
+
</Box>
|
|
288
|
+
</Box>
|
|
289
|
+
);
|
|
290
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { DashboardContainer } from './dashboard-container'
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { create } from 'zustand'
|
|
2
|
+
import { useShallow } from 'zustand/react/shallow'
|
|
3
|
+
import type { LogEvent } from '@salve-software/salvetron-types'
|
|
4
|
+
|
|
5
|
+
interface JsLogsStore {
|
|
6
|
+
logs: LogEvent[]
|
|
7
|
+
addLog: (log: LogEvent) => void
|
|
8
|
+
clear: () => void
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const MAX = 500
|
|
12
|
+
|
|
13
|
+
export const useJsLogsStore = create<JsLogsStore>((set) => ({
|
|
14
|
+
logs: [],
|
|
15
|
+
addLog: (log) => set((s) => ({ logs: [...s.logs, log].slice(-MAX) })),
|
|
16
|
+
clear: () => set({ logs: [] }),
|
|
17
|
+
}))
|
|
18
|
+
|
|
19
|
+
export const useJsLogs = () => useJsLogsStore(useShallow((s) => s.logs))
|
|
20
|
+
export const useRecentJsLogs = (n: number) =>
|
|
21
|
+
useJsLogsStore(useShallow((s) => s.logs.slice(-n)))
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { LogDetail } from './log-detail'
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { Box, Text } from 'ink'
|
|
2
|
+
import type { LogEvent } from '@salve-software/salvetron-types'
|
|
3
|
+
import type { CopyFeedback } from '../../../../../shared/hooks/use-detail-panel.js'
|
|
4
|
+
|
|
5
|
+
const LEVEL_COLOR: Record<string, string> = {
|
|
6
|
+
error: 'red', warn: 'yellow', info: 'cyan', debug: 'gray', log: 'white',
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface LogDetailProps {
|
|
10
|
+
log: LogEvent
|
|
11
|
+
width: number
|
|
12
|
+
metaLines: string[]
|
|
13
|
+
metaScrollOffset: number
|
|
14
|
+
metaVisibleRows: number
|
|
15
|
+
copyFeedback?: CopyFeedback | null
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function LogDetail({ log, width, metaLines, metaScrollOffset, metaVisibleRows, copyFeedback }: LogDetailProps) {
|
|
19
|
+
const color = LEVEL_COLOR[log.level] ?? 'white'
|
|
20
|
+
const time = new Date(log.timestamp).toLocaleTimeString('en', { hour12: false })
|
|
21
|
+
const visibleLines = metaLines.slice(metaScrollOffset, metaScrollOffset + metaVisibleRows)
|
|
22
|
+
const canScroll = metaLines.length > metaVisibleRows
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<Box
|
|
26
|
+
flexDirection="column"
|
|
27
|
+
borderStyle="single"
|
|
28
|
+
borderColor="gray"
|
|
29
|
+
borderTop={true}
|
|
30
|
+
borderBottom={false}
|
|
31
|
+
borderLeft={false}
|
|
32
|
+
borderRight={false}
|
|
33
|
+
paddingX={1}
|
|
34
|
+
>
|
|
35
|
+
<Box gap={2}>
|
|
36
|
+
<Text color={color} bold>[{log.level.toUpperCase()}]</Text>
|
|
37
|
+
<Text color="gray">{time}</Text>
|
|
38
|
+
<Text color="gray" dimColor>src: {log.source}</Text>
|
|
39
|
+
{copyFeedback
|
|
40
|
+
? <Text color={copyFeedback.success ? 'green' : 'red'}>{copyFeedback.success ? '✓ Copied' : '✗ Copy failed'}</Text>
|
|
41
|
+
: null
|
|
42
|
+
}
|
|
43
|
+
</Box>
|
|
44
|
+
<Text wrap="wrap">{log.message.slice(0, width * 3)}</Text>
|
|
45
|
+
{metaLines.length > 0
|
|
46
|
+
?
|
|
47
|
+
<Box flexDirection="column">
|
|
48
|
+
<Text color="whiteBright" dimColor>
|
|
49
|
+
{'── metadata'}
|
|
50
|
+
{canScroll ? ` [ = up ] = down · line ${metaScrollOffset + 1} of ${metaLines.length}` : ''}
|
|
51
|
+
{' · c copy'}
|
|
52
|
+
{' ──'}
|
|
53
|
+
</Text>
|
|
54
|
+
{visibleLines.map((line, i) => (
|
|
55
|
+
<Text key={i}>{line}</Text>
|
|
56
|
+
))}
|
|
57
|
+
</Box>
|
|
58
|
+
: null
|
|
59
|
+
}
|
|
60
|
+
</Box>
|
|
61
|
+
)
|
|
62
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { LogList } from './log-list'
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { Box, Text } from 'ink'
|
|
2
|
+
import type { LogEvent } from '@salve-software/salvetron-types'
|
|
3
|
+
import { LogRow } from '../../../../../shared/components/log-row/index.js'
|
|
4
|
+
|
|
5
|
+
interface LogListProps {
|
|
6
|
+
logs: LogEvent[]
|
|
7
|
+
visibleRows: number
|
|
8
|
+
selectedIndex: number
|
|
9
|
+
scrollOffset: number
|
|
10
|
+
showHeader?: boolean
|
|
11
|
+
maxMessageWidth?: number
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function LogList({ logs, visibleRows, selectedIndex, scrollOffset, showHeader = true, maxMessageWidth }: LogListProps) {
|
|
15
|
+
const visible = logs.slice(scrollOffset, scrollOffset + visibleRows)
|
|
16
|
+
|
|
17
|
+
return (
|
|
18
|
+
<Box flexDirection="column">
|
|
19
|
+
{showHeader ? <Text color="gray" dimColor>{logs.length} logs · ↑↓ navigate</Text> : null}
|
|
20
|
+
{visible.map((log, i) => {
|
|
21
|
+
const absoluteIndex = scrollOffset + i
|
|
22
|
+
const isSelected = absoluteIndex === selectedIndex
|
|
23
|
+
return (
|
|
24
|
+
<Box key={i} gap={1}>
|
|
25
|
+
<Text color="cyan">{isSelected ? '▶' : ' '}</Text>
|
|
26
|
+
<LogRow log={log} maxMessageWidth={maxMessageWidth} />
|
|
27
|
+
</Box>
|
|
28
|
+
)
|
|
29
|
+
})}
|
|
30
|
+
</Box>
|
|
31
|
+
)
|
|
32
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { JsLogsContainer } from './js-logs-container'
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { Box } from 'ink'
|
|
2
|
+
import { useCallback, useEffect, useRef } from 'react'
|
|
3
|
+
import { useTerminalSize } from '../../../../../shared/hooks/use-terminal-size.js'
|
|
4
|
+
import { useListNavigation } from '../../../../../shared/hooks/use-list-navigation.js'
|
|
5
|
+
import { useDetailPanel } from '../../../../../shared/hooks/use-detail-panel.js'
|
|
6
|
+
import { useJsLogs } from '../../../store/js-logs.store.js'
|
|
7
|
+
import { LogList } from '../../components/log-list/index.js'
|
|
8
|
+
import { LogDetail } from '../../components/log-detail/index.js'
|
|
9
|
+
import { formatBody, formatPlainBody } from '../../../../../shared/utils/format-body.js'
|
|
10
|
+
import type { LogEvent } from '@salve-software/salvetron-types'
|
|
11
|
+
|
|
12
|
+
const OVERHEAD_ROWS = 6
|
|
13
|
+
const DETAIL_FIXED_ROWS = 3
|
|
14
|
+
const MIN_LIST_ROWS = 10
|
|
15
|
+
|
|
16
|
+
export function JsLogsContainer() {
|
|
17
|
+
const [cols, rows] = useTerminalSize()
|
|
18
|
+
const logs = useJsLogs()
|
|
19
|
+
|
|
20
|
+
const availableRows = rows - OVERHEAD_ROWS
|
|
21
|
+
const detailHeight = Math.max(DETAIL_FIXED_ROWS + 2, availableRows - MIN_LIST_ROWS)
|
|
22
|
+
const metaVisibleRows = detailHeight - DETAIL_FIXED_ROWS
|
|
23
|
+
|
|
24
|
+
const metaLinesRef = useRef<string[]>([])
|
|
25
|
+
const selectedLogRef = useRef<LogEvent | null>(null)
|
|
26
|
+
|
|
27
|
+
const onCopyBody = useCallback(() => {
|
|
28
|
+
const log = selectedLogRef.current
|
|
29
|
+
if (!log) return ''
|
|
30
|
+
const meta = log.metadata ? formatPlainBody(JSON.stringify(log.metadata)) : ''
|
|
31
|
+
return meta ? `${log.message}\n\n${meta}` : log.message
|
|
32
|
+
}, [])
|
|
33
|
+
|
|
34
|
+
const { detailOpen, detailScrollOffset, resetDetailScroll, copyFeedback } = useDetailPanel({
|
|
35
|
+
linesRef: metaLinesRef,
|
|
36
|
+
visibleRows: metaVisibleRows,
|
|
37
|
+
scrollStep:5,
|
|
38
|
+
onCopyBody,
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
const listRows = detailOpen
|
|
42
|
+
? Math.max(MIN_LIST_ROWS, availableRows - detailHeight)
|
|
43
|
+
: availableRows
|
|
44
|
+
|
|
45
|
+
const { selectedIndex, scrollOffset } = useListNavigation({ count: logs.length, visibleRows: listRows })
|
|
46
|
+
|
|
47
|
+
const selectedLog = logs[selectedIndex] ?? null
|
|
48
|
+
const metaLines = selectedLog?.metadata ? formatBody(JSON.stringify(selectedLog.metadata)) : []
|
|
49
|
+
metaLinesRef.current = metaLines
|
|
50
|
+
selectedLogRef.current = selectedLog
|
|
51
|
+
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
resetDetailScroll()
|
|
54
|
+
}, [selectedIndex, resetDetailScroll])
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<Box flexDirection="column">
|
|
58
|
+
<Box flexGrow={1}>
|
|
59
|
+
<LogList
|
|
60
|
+
logs={logs}
|
|
61
|
+
visibleRows={listRows}
|
|
62
|
+
selectedIndex={selectedIndex}
|
|
63
|
+
scrollOffset={scrollOffset}
|
|
64
|
+
/>
|
|
65
|
+
</Box>
|
|
66
|
+
{detailOpen && selectedLog
|
|
67
|
+
?
|
|
68
|
+
<LogDetail
|
|
69
|
+
log={selectedLog}
|
|
70
|
+
width={cols}
|
|
71
|
+
metaLines={metaLines}
|
|
72
|
+
metaScrollOffset={detailScrollOffset}
|
|
73
|
+
metaVisibleRows={metaVisibleRows}
|
|
74
|
+
copyFeedback={copyFeedback}
|
|
75
|
+
/>
|
|
76
|
+
: null
|
|
77
|
+
}
|
|
78
|
+
</Box>
|
|
79
|
+
)
|
|
80
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { create } from 'zustand'
|
|
2
|
+
import { useShallow } from 'zustand/react/shallow'
|
|
3
|
+
import type { NativeLogEvent } from '@salve-software/salvetron-types'
|
|
4
|
+
|
|
5
|
+
interface NativeLogsStore {
|
|
6
|
+
logs: NativeLogEvent[]
|
|
7
|
+
addLog: (log: NativeLogEvent) => void
|
|
8
|
+
clear: () => void
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const MAX = 500
|
|
12
|
+
|
|
13
|
+
export const useNativeLogsStore = create<NativeLogsStore>((set) => ({
|
|
14
|
+
logs: [],
|
|
15
|
+
addLog: (log) => set((s) => ({ logs: [...s.logs, log].slice(-MAX) })),
|
|
16
|
+
clear: () => set({ logs: [] }),
|
|
17
|
+
}))
|
|
18
|
+
|
|
19
|
+
export const useNativeLogs = () => useNativeLogsStore(useShallow((s) => s.logs))
|
|
20
|
+
export const useRecentNativeLogs = (n: number) =>
|
|
21
|
+
useNativeLogsStore(useShallow((s) => s.logs.slice(-n)))
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { NativeLogDetail } from './native-log-detail'
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { Box, Text } from 'ink'
|
|
2
|
+
import type { NativeLogEvent } from '@salve-software/salvetron-types'
|
|
3
|
+
import type { CopyFeedback } from '../../../../../shared/hooks/use-detail-panel.js'
|
|
4
|
+
|
|
5
|
+
const LEVEL_COLOR: Record<string, string> = {
|
|
6
|
+
error: 'red', warn: 'yellow', info: 'cyan', debug: 'gray', log: 'white',
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface NativeLogDetailProps {
|
|
10
|
+
log: NativeLogEvent
|
|
11
|
+
width: number
|
|
12
|
+
metaLines: string[]
|
|
13
|
+
metaScrollOffset: number
|
|
14
|
+
metaVisibleRows: number
|
|
15
|
+
copyFeedback?: CopyFeedback | null
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function NativeLogDetail({ log, width, metaLines, metaScrollOffset, metaVisibleRows, copyFeedback }: NativeLogDetailProps) {
|
|
19
|
+
const color = LEVEL_COLOR[log.level] ?? 'white'
|
|
20
|
+
const time = new Date(log.timestamp).toLocaleTimeString('en', { hour12: false })
|
|
21
|
+
const visibleLines = metaLines.slice(metaScrollOffset, metaScrollOffset + metaVisibleRows)
|
|
22
|
+
const canScroll = metaLines.length > metaVisibleRows
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<Box
|
|
26
|
+
flexDirection="column"
|
|
27
|
+
borderStyle="single"
|
|
28
|
+
borderColor="gray"
|
|
29
|
+
borderTop={true}
|
|
30
|
+
borderBottom={false}
|
|
31
|
+
borderLeft={false}
|
|
32
|
+
borderRight={false}
|
|
33
|
+
paddingX={1}
|
|
34
|
+
>
|
|
35
|
+
<Box gap={2}>
|
|
36
|
+
<Text color={color} bold>[{log.level.toUpperCase()}]</Text>
|
|
37
|
+
<Text color="whiteBright">{time}</Text>
|
|
38
|
+
<Text color="whiteBright" dimColor>[{log.source}]</Text>
|
|
39
|
+
{log.tag ? <Text color="whiteBright" dimColor>tag: {log.tag}</Text> : null}
|
|
40
|
+
{copyFeedback
|
|
41
|
+
? <Text color={copyFeedback.success ? 'green' : 'red'}>{copyFeedback.success ? '✓ Copied' : '✗ Copy failed'}</Text>
|
|
42
|
+
: null
|
|
43
|
+
}
|
|
44
|
+
</Box>
|
|
45
|
+
<Text wrap="wrap">{log.message.slice(0, width * 3)}</Text>
|
|
46
|
+
{metaLines.length > 0
|
|
47
|
+
?
|
|
48
|
+
<Box flexDirection="column">
|
|
49
|
+
<Text color="whiteBright" dimColor>
|
|
50
|
+
{'── metadata'}
|
|
51
|
+
{canScroll ? ` [ = up ] = down · line ${metaScrollOffset + 1} of ${metaLines.length}` : ''}
|
|
52
|
+
{' · c copy'}
|
|
53
|
+
{' ──'}
|
|
54
|
+
</Text>
|
|
55
|
+
{visibleLines.map((line, i) => (
|
|
56
|
+
<Text key={i}>{line}</Text>
|
|
57
|
+
))}
|
|
58
|
+
</Box>
|
|
59
|
+
: null
|
|
60
|
+
}
|
|
61
|
+
</Box>
|
|
62
|
+
)
|
|
63
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { NativeLogList } from './native-log-list'
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { Box, Text } from 'ink'
|
|
2
|
+
import type { NativeLogEvent } from '@salve-software/salvetron-types'
|
|
3
|
+
import { LogRow } from '../../../../../shared/components/log-row/index.js'
|
|
4
|
+
|
|
5
|
+
interface NativeLogListProps {
|
|
6
|
+
logs: NativeLogEvent[]
|
|
7
|
+
visibleRows: number
|
|
8
|
+
selectedIndex: number
|
|
9
|
+
scrollOffset: number
|
|
10
|
+
showHeader?: boolean
|
|
11
|
+
maxMessageWidth?: number
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function NativeLogList({ logs, visibleRows, selectedIndex, scrollOffset, showHeader = true, maxMessageWidth }: NativeLogListProps) {
|
|
15
|
+
const visible = logs.slice(scrollOffset, scrollOffset + visibleRows)
|
|
16
|
+
|
|
17
|
+
return (
|
|
18
|
+
<Box flexDirection="column">
|
|
19
|
+
{showHeader ? <Text color="gray" dimColor>{logs.length} native logs · ↑↓ navigate</Text> : null}
|
|
20
|
+
{visible.map((log, i) => {
|
|
21
|
+
const absoluteIndex = scrollOffset + i
|
|
22
|
+
const isSelected = absoluteIndex === selectedIndex
|
|
23
|
+
return (
|
|
24
|
+
<Box key={i} gap={1}>
|
|
25
|
+
<Text color="cyan">{isSelected ? '▶' : ' '}</Text>
|
|
26
|
+
<Text color="gray" dimColor>[{log.source}]</Text>
|
|
27
|
+
<LogRow log={log} maxMessageWidth={maxMessageWidth} />
|
|
28
|
+
</Box>
|
|
29
|
+
)
|
|
30
|
+
})}
|
|
31
|
+
</Box>
|
|
32
|
+
)
|
|
33
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { NativeLogsContainer } from './native-logs-container'
|