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,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 { useNativeLogs } from '../../../store/native-logs.store.js'
|
|
7
|
+
import { NativeLogList } from '../../components/native-log-list/index.js'
|
|
8
|
+
import { NativeLogDetail } from '../../components/native-log-detail/index.js'
|
|
9
|
+
import { formatBody, formatPlainBody } from '../../../../../shared/utils/format-body.js'
|
|
10
|
+
import type { NativeLogEvent } from '@salve-software/salvetron-types'
|
|
11
|
+
|
|
12
|
+
const OVERHEAD_ROWS = 6
|
|
13
|
+
const DETAIL_FIXED_ROWS = 3
|
|
14
|
+
const MIN_LIST_ROWS = 4
|
|
15
|
+
|
|
16
|
+
export function NativeLogsContainer() {
|
|
17
|
+
const [cols, rows] = useTerminalSize()
|
|
18
|
+
const logs = useNativeLogs()
|
|
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<NativeLogEvent | 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
|
+
<NativeLogList
|
|
60
|
+
logs={logs}
|
|
61
|
+
visibleRows={listRows}
|
|
62
|
+
selectedIndex={selectedIndex}
|
|
63
|
+
scrollOffset={scrollOffset}
|
|
64
|
+
/>
|
|
65
|
+
</Box>
|
|
66
|
+
{detailOpen && selectedLog
|
|
67
|
+
?
|
|
68
|
+
<NativeLogDetail
|
|
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,15 @@
|
|
|
1
|
+
export const METHOD_COLOR: Record<string, string> = {
|
|
2
|
+
GET: 'green',
|
|
3
|
+
POST: 'cyan',
|
|
4
|
+
PUT: 'yellow',
|
|
5
|
+
PATCH: 'yellow',
|
|
6
|
+
DELETE: 'red',
|
|
7
|
+
HEAD: 'gray',
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function getStatusColor(statusCode: number | null): string {
|
|
11
|
+
if (!statusCode) return 'gray'
|
|
12
|
+
if (statusCode >= 500) return 'red'
|
|
13
|
+
if (statusCode >= 400) return 'yellow'
|
|
14
|
+
return 'green'
|
|
15
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { create } from 'zustand'
|
|
2
|
+
import { useShallow } from 'zustand/react/shallow'
|
|
3
|
+
import type { NetworkEvent, NetworkLog } from '@salve-software/salvetron-types'
|
|
4
|
+
import { normalizeHttpMethod } from '@salve-software/salvetron-types'
|
|
5
|
+
|
|
6
|
+
interface NetworkStore {
|
|
7
|
+
logsArray: NetworkLog[]
|
|
8
|
+
addOrUpdateLog: (event: NetworkEvent) => void
|
|
9
|
+
clear: () => void
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const MAX = 100
|
|
13
|
+
|
|
14
|
+
export const useNetworkStore = create<NetworkStore>((set) => ({
|
|
15
|
+
logsArray: [],
|
|
16
|
+
addOrUpdateLog: (event) =>
|
|
17
|
+
set((s) => {
|
|
18
|
+
if (event.stage === 'request') {
|
|
19
|
+
const entry: NetworkLog = {
|
|
20
|
+
requestId: event.requestId,
|
|
21
|
+
deviceId: event.deviceId ?? 'unknown',
|
|
22
|
+
projectId: event.projectId,
|
|
23
|
+
method: normalizeHttpMethod(event.method),
|
|
24
|
+
url: event.url,
|
|
25
|
+
requestHeaders: event.headers ?? {},
|
|
26
|
+
requestBody: event.body ?? null,
|
|
27
|
+
requestTimestamp: event.timestamp,
|
|
28
|
+
statusCode: null,
|
|
29
|
+
responseHeaders: null,
|
|
30
|
+
responseBody: null,
|
|
31
|
+
responseTimestamp: null,
|
|
32
|
+
duration: null,
|
|
33
|
+
state: 'pending',
|
|
34
|
+
}
|
|
35
|
+
return { logsArray: [...s.logsArray, entry].slice(-MAX) }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const idx = s.logsArray.findIndex((l) => l.requestId === event.requestId)
|
|
39
|
+
if (idx === -1) return s
|
|
40
|
+
|
|
41
|
+
const updated = [...s.logsArray]
|
|
42
|
+
updated[idx] = {
|
|
43
|
+
...updated[idx],
|
|
44
|
+
statusCode: event.statusCode,
|
|
45
|
+
responseHeaders: event.headers ?? null,
|
|
46
|
+
responseBody: event.body ?? null,
|
|
47
|
+
responseTimestamp: event.timestamp,
|
|
48
|
+
duration: event.duration,
|
|
49
|
+
state: 'completed',
|
|
50
|
+
}
|
|
51
|
+
return { logsArray: updated }
|
|
52
|
+
}),
|
|
53
|
+
clear: () => set({ logsArray: [] }),
|
|
54
|
+
}))
|
|
55
|
+
|
|
56
|
+
export const useNetworkLogs = () => useNetworkStore(useShallow((s) => s.logsArray))
|
|
57
|
+
export const useRecentNetworkLogs = (n: number) =>
|
|
58
|
+
useNetworkStore(useShallow((s) => s.logsArray.slice(-n)))
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { NetworkDetail } from './network-detail'
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { Box, Text } from 'ink'
|
|
2
|
+
import type { NetworkLog } from '@salve-software/salvetron-types'
|
|
3
|
+
import { METHOD_COLOR, getStatusColor } from '../../../library/constants.js'
|
|
4
|
+
import type { CopyFeedback } from '../../../../../shared/hooks/use-detail-panel.js'
|
|
5
|
+
|
|
6
|
+
interface NetworkDetailProps {
|
|
7
|
+
log: NetworkLog
|
|
8
|
+
width: number
|
|
9
|
+
bodyLines: string[]
|
|
10
|
+
bodyScrollOffset: number
|
|
11
|
+
bodyVisibleRows: number
|
|
12
|
+
copyFeedback?: CopyFeedback | null
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function NetworkDetail({ log, width, bodyLines, bodyScrollOffset, bodyVisibleRows, copyFeedback }: NetworkDetailProps) {
|
|
16
|
+
const time = new Date(log.requestTimestamp).toLocaleTimeString('en', { hour12: false })
|
|
17
|
+
const reqHeaders = Object.entries(log.requestHeaders ?? {})
|
|
18
|
+
const resHeaders = Object.entries(log.responseHeaders ?? {})
|
|
19
|
+
const visibleLines = bodyLines.slice(bodyScrollOffset, bodyScrollOffset + bodyVisibleRows)
|
|
20
|
+
const canScroll = bodyLines.length > bodyVisibleRows
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<Box
|
|
24
|
+
flexDirection="column"
|
|
25
|
+
borderStyle="single"
|
|
26
|
+
borderColor="gray"
|
|
27
|
+
borderTop={true}
|
|
28
|
+
borderBottom={false}
|
|
29
|
+
borderLeft={false}
|
|
30
|
+
borderRight={false}
|
|
31
|
+
paddingX={1}
|
|
32
|
+
>
|
|
33
|
+
<Box gap={2}>
|
|
34
|
+
<Text color={METHOD_COLOR[log.method] ?? 'white'} bold>{log.method}</Text>
|
|
35
|
+
<Text color={getStatusColor(log.statusCode)} bold>
|
|
36
|
+
{log.statusCode ?? 'pending'}
|
|
37
|
+
</Text>
|
|
38
|
+
{log.duration ? <Text color="gray">{log.duration}ms</Text> : null}
|
|
39
|
+
<Text color="gray" dimColor>{time}</Text>
|
|
40
|
+
{copyFeedback
|
|
41
|
+
?
|
|
42
|
+
<Text color={copyFeedback.success ? 'green' : 'red'}>
|
|
43
|
+
{copyFeedback.success
|
|
44
|
+
? (copyFeedback.kind === 'extra' ? '✓ curl copied' : '✓ Copied')
|
|
45
|
+
: '✗ Copy failed'}
|
|
46
|
+
</Text>
|
|
47
|
+
: null
|
|
48
|
+
}
|
|
49
|
+
</Box>
|
|
50
|
+
<Text color="whiteBright">{log.url.slice(0, width - 2)}</Text>
|
|
51
|
+
{reqHeaders.length > 0
|
|
52
|
+
?
|
|
53
|
+
<Text color="whiteBright" dimColor>
|
|
54
|
+
req: {reqHeaders.slice(0, 3).map(([k, v]) => `${k}: ${v}`).join(' ')}
|
|
55
|
+
</Text>
|
|
56
|
+
: null
|
|
57
|
+
}
|
|
58
|
+
{resHeaders.length > 0
|
|
59
|
+
?
|
|
60
|
+
<Text color="whiteBright" dimColor>
|
|
61
|
+
res: {resHeaders.slice(0, 3).map(([k, v]) => `${k}: ${v}`).join(' ')}
|
|
62
|
+
</Text>
|
|
63
|
+
: null
|
|
64
|
+
}
|
|
65
|
+
{bodyLines.length > 0
|
|
66
|
+
?
|
|
67
|
+
<Box flexDirection="column">
|
|
68
|
+
<Text color="whiteBright" dimColor>
|
|
69
|
+
{'── body'}
|
|
70
|
+
{canScroll ? ` [ = scroll up ] = scroll down · line ${bodyScrollOffset + 1} of ${bodyLines.length}` : ''}
|
|
71
|
+
{' · c copy · u curl'}
|
|
72
|
+
{' ──'}
|
|
73
|
+
</Text>
|
|
74
|
+
{visibleLines.map((line, i) => (
|
|
75
|
+
<Text key={i}>{line}</Text>
|
|
76
|
+
))}
|
|
77
|
+
</Box>
|
|
78
|
+
: null
|
|
79
|
+
}
|
|
80
|
+
</Box>
|
|
81
|
+
)
|
|
82
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { NetworkRow } from './network-row'
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { Box, Text } from 'ink'
|
|
2
|
+
import type { NetworkLog } from '@salve-software/salvetron-types'
|
|
3
|
+
import { METHOD_COLOR, getStatusColor } from '../../../library/constants.js'
|
|
4
|
+
|
|
5
|
+
interface NetworkRowProps {
|
|
6
|
+
log: NetworkLog
|
|
7
|
+
urlMaxWidth: number
|
|
8
|
+
isSelected?: boolean
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function NetworkRow({ log, urlMaxWidth, isSelected = false }: NetworkRowProps) {
|
|
12
|
+
const dur = log.duration ? `${log.duration}ms` : '...'
|
|
13
|
+
|
|
14
|
+
return (
|
|
15
|
+
<Box gap={1}>
|
|
16
|
+
<Text color="cyan">{isSelected ? '▶' : ' '}</Text>
|
|
17
|
+
<Text color={METHOD_COLOR[log.method] ?? 'white'}>{log.method.padEnd(8)}</Text>
|
|
18
|
+
<Text color={getStatusColor(log.statusCode)}>{String(log.statusCode ?? '---').padEnd(7)}</Text>
|
|
19
|
+
<Text color="gray">{dur.padEnd(8)}</Text>
|
|
20
|
+
<Text>{log.url.slice(0, urlMaxWidth)}</Text>
|
|
21
|
+
</Box>
|
|
22
|
+
)
|
|
23
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { NetworkTableHeader } from './network-table-header'
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { Box, Text } from 'ink'
|
|
2
|
+
|
|
3
|
+
export function NetworkTableHeader() {
|
|
4
|
+
return (
|
|
5
|
+
<Box gap={1}>
|
|
6
|
+
<Text bold color="gray">{'METHOD'.padEnd(8)}</Text>
|
|
7
|
+
<Text bold color="gray">{'STATUS'.padEnd(7)}</Text>
|
|
8
|
+
<Text bold color="gray">{'DUR'.padEnd(8)}</Text>
|
|
9
|
+
<Text bold color="gray">URL</Text>
|
|
10
|
+
</Box>
|
|
11
|
+
)
|
|
12
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { NetworkContainer } from './network-container'
|
|
@@ -0,0 +1,93 @@
|
|
|
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 { useNetworkLogs } from '../../../store/network.store.js'
|
|
7
|
+
import { NetworkTableHeader } from '../../components/network-table-header/index.js'
|
|
8
|
+
import { NetworkRow } from '../../components/network-row/index.js'
|
|
9
|
+
import { NetworkDetail } from '../../components/network-detail/index.js'
|
|
10
|
+
import { formatBody, formatPlainBody } from '../../../../../shared/utils/format-body.js'
|
|
11
|
+
import { buildCurlCommand } from '../../../../../shared/utils/build-curl-command.js'
|
|
12
|
+
import type { NetworkLog } from '@salve-software/salvetron-types'
|
|
13
|
+
|
|
14
|
+
const OVERHEAD_ROWS = 6
|
|
15
|
+
const DETAIL_FIXED_ROWS = 5
|
|
16
|
+
const MIN_LIST_ROWS = 4
|
|
17
|
+
|
|
18
|
+
export function NetworkContainer() {
|
|
19
|
+
const [cols, rows] = useTerminalSize()
|
|
20
|
+
const logs = useNetworkLogs()
|
|
21
|
+
|
|
22
|
+
const availableRows = rows - OVERHEAD_ROWS
|
|
23
|
+
const detailHeight = Math.max(DETAIL_FIXED_ROWS + 2, availableRows - MIN_LIST_ROWS)
|
|
24
|
+
const bodyVisibleRows = detailHeight - DETAIL_FIXED_ROWS
|
|
25
|
+
|
|
26
|
+
const bodyLinesRef = useRef<string[]>([])
|
|
27
|
+
const selectedLogRef = useRef<NetworkLog | null>(null)
|
|
28
|
+
|
|
29
|
+
const onCopyBody = useCallback(() => {
|
|
30
|
+
const log = selectedLogRef.current
|
|
31
|
+
return log ? formatPlainBody(log.responseBody) : ''
|
|
32
|
+
}, [])
|
|
33
|
+
const onCopyExtra = useCallback(() => {
|
|
34
|
+
const log = selectedLogRef.current
|
|
35
|
+
return log ? buildCurlCommand(log) : ''
|
|
36
|
+
}, [])
|
|
37
|
+
|
|
38
|
+
const { detailOpen, detailScrollOffset, resetDetailScroll, copyFeedback } = useDetailPanel({
|
|
39
|
+
linesRef: bodyLinesRef,
|
|
40
|
+
visibleRows: bodyVisibleRows,
|
|
41
|
+
scrollStep: 5,
|
|
42
|
+
onCopyBody,
|
|
43
|
+
onCopyExtra,
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
const listRows = detailOpen
|
|
47
|
+
? Math.max(MIN_LIST_ROWS, availableRows - detailHeight)
|
|
48
|
+
: availableRows
|
|
49
|
+
|
|
50
|
+
const { selectedIndex, scrollOffset } = useListNavigation({ count: logs.length, visibleRows: listRows })
|
|
51
|
+
|
|
52
|
+
const selectedLog = logs[selectedIndex] ?? null
|
|
53
|
+
const bodyLines = formatBody(selectedLog?.responseBody)
|
|
54
|
+
bodyLinesRef.current = bodyLines
|
|
55
|
+
selectedLogRef.current = selectedLog
|
|
56
|
+
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
resetDetailScroll()
|
|
59
|
+
}, [selectedIndex, resetDetailScroll])
|
|
60
|
+
|
|
61
|
+
const visible = logs.slice(scrollOffset, scrollOffset + listRows)
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<Box flexDirection="column">
|
|
65
|
+
<Box flexGrow={1} flexDirection="column">
|
|
66
|
+
<NetworkTableHeader />
|
|
67
|
+
{visible.map((log, i) => {
|
|
68
|
+
const absoluteIndex = scrollOffset + i
|
|
69
|
+
return (
|
|
70
|
+
<NetworkRow
|
|
71
|
+
key={log.requestId}
|
|
72
|
+
log={log}
|
|
73
|
+
urlMaxWidth={cols - 30}
|
|
74
|
+
isSelected={absoluteIndex === selectedIndex}
|
|
75
|
+
/>
|
|
76
|
+
)
|
|
77
|
+
})}
|
|
78
|
+
</Box>
|
|
79
|
+
{detailOpen && selectedLog
|
|
80
|
+
?
|
|
81
|
+
<NetworkDetail
|
|
82
|
+
log={selectedLog}
|
|
83
|
+
width={cols}
|
|
84
|
+
bodyLines={bodyLines}
|
|
85
|
+
bodyScrollOffset={detailScrollOffset}
|
|
86
|
+
bodyVisibleRows={bodyVisibleRows}
|
|
87
|
+
copyFeedback={copyFeedback}
|
|
88
|
+
/>
|
|
89
|
+
: null
|
|
90
|
+
}
|
|
91
|
+
</Box>
|
|
92
|
+
)
|
|
93
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { WebSocketServer } from 'ws'
|
|
2
|
+
import type { RnTuiEvent } from '@salve-software/salvetron-types'
|
|
3
|
+
import { useJsLogsStore } from '../modules/js-logs/store/js-logs.store.js'
|
|
4
|
+
import { useNativeLogsStore } from '../modules/native-logs/store/native-logs.store.js'
|
|
5
|
+
import { useNetworkStore } from '../modules/network/store/network.store.js'
|
|
6
|
+
import { useDashboardStore } from '../modules/dashboard/store/dashboard.store.js'
|
|
7
|
+
import { useDeviceStore } from '../shared/store/device.store.js'
|
|
8
|
+
|
|
9
|
+
export function startWsServer(port: number) {
|
|
10
|
+
const wss = new WebSocketServer({ port, host: '0.0.0.0' })
|
|
11
|
+
|
|
12
|
+
wss.on('error', (err: NodeJS.ErrnoException) => {
|
|
13
|
+
if (err.code === 'EADDRINUSE') {
|
|
14
|
+
console.error(`Port ${port} already in use. Set SALVETRON_PORT env var to change port.`)
|
|
15
|
+
process.exit(1)
|
|
16
|
+
}
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
wss.on('connection', (socket) => {
|
|
20
|
+
socket.on('message', (raw) => {
|
|
21
|
+
try {
|
|
22
|
+
const event: RnTuiEvent = JSON.parse(raw.toString())
|
|
23
|
+
dispatch(event)
|
|
24
|
+
} catch {}
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
socket.on('close', () => {
|
|
28
|
+
useDeviceStore.getState().setDisconnected()
|
|
29
|
+
})
|
|
30
|
+
})
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function dispatch(event: RnTuiEvent) {
|
|
34
|
+
switch (event.type) {
|
|
35
|
+
case 'device_info':
|
|
36
|
+
case 'project_info':
|
|
37
|
+
useDeviceStore.getState().setInfo(event)
|
|
38
|
+
break
|
|
39
|
+
case 'log':
|
|
40
|
+
useJsLogsStore.getState().addLog(event)
|
|
41
|
+
break
|
|
42
|
+
case 'native':
|
|
43
|
+
useNativeLogsStore.getState().addLog(event)
|
|
44
|
+
break
|
|
45
|
+
case 'network':
|
|
46
|
+
useNetworkStore.getState().addOrUpdateLog(event)
|
|
47
|
+
break
|
|
48
|
+
case 'performance_metrics':
|
|
49
|
+
useDashboardStore.getState().addSnapshot(event)
|
|
50
|
+
break
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { Text, useStdout } from "ink";
|
|
2
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
3
|
+
import figlet from "figlet";
|
|
4
|
+
|
|
5
|
+
const FALLBACK_TEXT = "RN PANEL";
|
|
6
|
+
const MAX_TEXT_LENGTH = 18;
|
|
7
|
+
|
|
8
|
+
const DURATION_MS = 4000;
|
|
9
|
+
const TICK_MS = 80;
|
|
10
|
+
const TWO_PI = Math.PI * 2;
|
|
11
|
+
|
|
12
|
+
// Glyphs are never swapped for block characters anymore — only color
|
|
13
|
+
// brightness is modulated, so the full name stays legible at every frame,
|
|
14
|
+
// including loop edges. The wave crest lightens the base color toward a
|
|
15
|
+
// slightly lighter shade (not pure white) instead of dimming it, for a
|
|
16
|
+
// glow/shine sweep.
|
|
17
|
+
const GLOW_MIX = 0.65;
|
|
18
|
+
const GLOW_LEVELS = 55;
|
|
19
|
+
|
|
20
|
+
export const LOGO_COLOR_PALETTE = [
|
|
21
|
+
"#61DAFB", // React cyan (original brand color)
|
|
22
|
+
"#FF6B6B", // coral red
|
|
23
|
+
"#4ECDC4", // teal
|
|
24
|
+
"#FFD93D", // gold
|
|
25
|
+
"#A78BFA", // violet
|
|
26
|
+
"#FF8C42", // orange
|
|
27
|
+
"#52D17C", // emerald green
|
|
28
|
+
"#F472B6", // pink
|
|
29
|
+
"#38BDF8", // sky blue
|
|
30
|
+
"#FACC15", // amber
|
|
31
|
+
] as const;
|
|
32
|
+
|
|
33
|
+
export function pickRandomColor(): string {
|
|
34
|
+
return LOGO_COLOR_PALETTE[Math.floor(Math.random() * LOGO_COLOR_PALETTE.length)];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
type Rgb = [number, number, number];
|
|
38
|
+
|
|
39
|
+
function hexToRgb(hex: string): Rgb {
|
|
40
|
+
const value = hex.replace("#", "");
|
|
41
|
+
const r = parseInt(value.slice(0, 2), 16);
|
|
42
|
+
const g = parseInt(value.slice(2, 4), 16);
|
|
43
|
+
const b = parseInt(value.slice(4, 6), 16);
|
|
44
|
+
return [r, g, b];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function lerp(a: number, b: number, t: number): number {
|
|
48
|
+
return Math.round(a + (b - a) * t);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const RESET = "[39m";
|
|
52
|
+
|
|
53
|
+
function ansiColor([r, g, b]: Rgb): string {
|
|
54
|
+
return `[38;2;${r};${g};${b}m`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function buildArt(text: string) {
|
|
58
|
+
// Truncated so longer project names don't blow past typical terminal
|
|
59
|
+
// width and wrap mid-glyph.
|
|
60
|
+
const safeText = text.slice(0, MAX_TEXT_LENGTH);
|
|
61
|
+
const art = figlet
|
|
62
|
+
.textSync(safeText, { font: "ANSI Shadow" })
|
|
63
|
+
.split("\n")
|
|
64
|
+
.filter((line) => line.trim().length > 0);
|
|
65
|
+
|
|
66
|
+
// Guard against an empty `art` (e.g. blank input) collapsing Math.max to
|
|
67
|
+
// -Infinity, which would propagate NaN into the wave frequency below.
|
|
68
|
+
const maxWidth = Math.max(1, ...art.map((line) => line.length));
|
|
69
|
+
|
|
70
|
+
// Several ripples across the full width so the wave never dims everything
|
|
71
|
+
// at once (a single period close to maxWidth caused a visible gap
|
|
72
|
+
// mid-animation).
|
|
73
|
+
const waveFrequency = (TWO_PI * 3) / maxWidth;
|
|
74
|
+
|
|
75
|
+
return { art, waveFrequency };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function render(t: number, rgb: Rgb, art: string[], waveFrequency: number): string {
|
|
79
|
+
const [r, g, b] = rgb;
|
|
80
|
+
const peak: Rgb = [
|
|
81
|
+
lerp(r, 255, GLOW_MIX),
|
|
82
|
+
lerp(g, 255, GLOW_MIX),
|
|
83
|
+
lerp(b, 255, GLOW_MIX),
|
|
84
|
+
];
|
|
85
|
+
|
|
86
|
+
const lines: string[] = [];
|
|
87
|
+
for (let y = 0; y < art.length; y++) {
|
|
88
|
+
const row = art[y];
|
|
89
|
+
let line = "";
|
|
90
|
+
let lastLevel = -1;
|
|
91
|
+
for (let x = 0; x < row.length; x++) {
|
|
92
|
+
const ch = row[x];
|
|
93
|
+
if (ch === " ") {
|
|
94
|
+
line += " ";
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
const wave = Math.sin(x * waveFrequency + t) * 0.5 + 0.5;
|
|
98
|
+
const verticalWave = Math.sin(y * 0.5 + t * 0.8) * 0.5 + 0.5;
|
|
99
|
+
const glow = wave * 0.6 + verticalWave * 0.4;
|
|
100
|
+
const level = Math.round(glow * GLOW_LEVELS);
|
|
101
|
+
if (level !== lastLevel) {
|
|
102
|
+
const factor = level / GLOW_LEVELS;
|
|
103
|
+
line += ansiColor([
|
|
104
|
+
lerp(r, peak[0], factor),
|
|
105
|
+
lerp(g, peak[1], factor),
|
|
106
|
+
lerp(b, peak[2], factor),
|
|
107
|
+
]);
|
|
108
|
+
lastLevel = level;
|
|
109
|
+
}
|
|
110
|
+
line += ch;
|
|
111
|
+
}
|
|
112
|
+
line += RESET;
|
|
113
|
+
lines.push(line);
|
|
114
|
+
}
|
|
115
|
+
return lines.join("\n");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
interface AsciiLogoProps {
|
|
119
|
+
text?: string;
|
|
120
|
+
color?: string;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function AsciiLogo({ text = FALLBACK_TEXT, color = "#61DAFB" }: AsciiLogoProps) {
|
|
124
|
+
const upperText = text.toUpperCase();
|
|
125
|
+
const { art, waveFrequency } = useMemo(() => buildArt(upperText), [upperText]);
|
|
126
|
+
const rgb = useMemo(() => hexToRgb(color), [color]);
|
|
127
|
+
const [frame, setFrame] = useState(() => render(0, rgb, art, waveFrequency));
|
|
128
|
+
const { stdout } = useStdout();
|
|
129
|
+
const isResizingRef = useRef(false);
|
|
130
|
+
|
|
131
|
+
useEffect(() => {
|
|
132
|
+
if (!stdout) return;
|
|
133
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
134
|
+
const handler = () => {
|
|
135
|
+
isResizingRef.current = true;
|
|
136
|
+
clearTimeout(timer);
|
|
137
|
+
timer = setTimeout(() => {
|
|
138
|
+
isResizingRef.current = false;
|
|
139
|
+
}, 150);
|
|
140
|
+
};
|
|
141
|
+
stdout.on("resize", handler);
|
|
142
|
+
return () => {
|
|
143
|
+
clearTimeout(timer);
|
|
144
|
+
stdout.off("resize", handler);
|
|
145
|
+
};
|
|
146
|
+
}, [stdout]);
|
|
147
|
+
|
|
148
|
+
useEffect(() => {
|
|
149
|
+
const start = Date.now();
|
|
150
|
+
// Render immediately so a text/color change doesn't leave a stale frame
|
|
151
|
+
// on screen until the next tick.
|
|
152
|
+
setFrame(render(0, rgb, art, waveFrequency));
|
|
153
|
+
|
|
154
|
+
const id = setInterval(() => {
|
|
155
|
+
if (isResizingRef.current) return;
|
|
156
|
+
const pos = (Date.now() - start) % (DURATION_MS * 2);
|
|
157
|
+
const linear =
|
|
158
|
+
pos < DURATION_MS
|
|
159
|
+
? pos / DURATION_MS
|
|
160
|
+
: 1 - (pos - DURATION_MS) / DURATION_MS;
|
|
161
|
+
const eased = linear * linear * (3 - 2 * linear);
|
|
162
|
+
setFrame(render(eased * TWO_PI, rgb, art, waveFrequency));
|
|
163
|
+
}, TICK_MS);
|
|
164
|
+
|
|
165
|
+
return () => clearInterval(id);
|
|
166
|
+
}, [art, waveFrequency, rgb]);
|
|
167
|
+
|
|
168
|
+
return <Text>{frame}</Text>;
|
|
169
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { AsciiLogo, pickRandomColor } from './ascii-logo'
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Text } from 'ink'
|
|
2
|
+
|
|
3
|
+
interface GaugeProps {
|
|
4
|
+
value: number
|
|
5
|
+
max: number
|
|
6
|
+
width?: number
|
|
7
|
+
warnAt?: number
|
|
8
|
+
critAt?: number
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function Gauge({ value, max, width = 12, warnAt = 0.7, critAt = 0.9 }: GaugeProps) {
|
|
12
|
+
const ratio = Math.min(1, Math.max(0, value / max))
|
|
13
|
+
const filled = Math.round(ratio * width)
|
|
14
|
+
const bar = ':'.repeat(filled) + '.'.repeat(width - filled)
|
|
15
|
+
const color = ratio >= critAt ? 'red' : ratio >= warnAt ? 'yellow' : 'green'
|
|
16
|
+
|
|
17
|
+
return <Text color={color}>{bar}</Text>
|
|
18
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { Gauge } from './gauge'
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { LogRow } from './log-row'
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { Text } from 'ink'
|
|
2
|
+
import type { AnyLog } from '../../types.js'
|
|
3
|
+
|
|
4
|
+
const LEVEL_COLOR: Record<string, string> = {
|
|
5
|
+
error: 'red',
|
|
6
|
+
warn: 'yellow',
|
|
7
|
+
info: 'cyan',
|
|
8
|
+
debug: 'gray',
|
|
9
|
+
log: 'white',
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface LogRowProps {
|
|
13
|
+
log: AnyLog
|
|
14
|
+
maxMessageWidth?: number
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function LogRow({ log, maxMessageWidth = 120 }: LogRowProps) {
|
|
18
|
+
const color = LEVEL_COLOR[log.level] ?? 'white'
|
|
19
|
+
const time = new Date(log.timestamp).toLocaleTimeString('en', { hour12: false })
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<Text>
|
|
23
|
+
<Text color="gray">{time} </Text>
|
|
24
|
+
<Text color={color}>[{log.level.toUpperCase().padEnd(5)}] </Text>
|
|
25
|
+
<Text>{log.message.slice(0, maxMessageWidth)}</Text>
|
|
26
|
+
</Text>
|
|
27
|
+
)
|
|
28
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { Panel } from './panel'
|