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.
Files changed (60) hide show
  1. package/README.md +333 -0
  2. package/bin/salvetron.tsx +9 -0
  3. package/package.json +43 -0
  4. package/scripts/salvetron-sim.mjs +75 -0
  5. package/src/app.tsx +62 -0
  6. package/src/modules/dashboard/store/dashboard.store.ts +19 -0
  7. package/src/modules/dashboard/ui/components/metric-row/index.ts +1 -0
  8. package/src/modules/dashboard/ui/components/metric-row/metric-row.tsx +25 -0
  9. package/src/modules/dashboard/ui/components/performance-panel/index.ts +1 -0
  10. package/src/modules/dashboard/ui/components/performance-panel/performance-panel.tsx +67 -0
  11. package/src/modules/dashboard/ui/containers/dashboard-container/dashboard-container.tsx +290 -0
  12. package/src/modules/dashboard/ui/containers/dashboard-container/index.ts +1 -0
  13. package/src/modules/js-logs/store/js-logs.store.ts +21 -0
  14. package/src/modules/js-logs/ui/components/log-detail/index.ts +1 -0
  15. package/src/modules/js-logs/ui/components/log-detail/log-detail.tsx +62 -0
  16. package/src/modules/js-logs/ui/components/log-list/index.ts +1 -0
  17. package/src/modules/js-logs/ui/components/log-list/log-list.tsx +32 -0
  18. package/src/modules/js-logs/ui/containers/js-logs-container/index.ts +1 -0
  19. package/src/modules/js-logs/ui/containers/js-logs-container/js-logs-container.tsx +80 -0
  20. package/src/modules/native-logs/store/native-logs.store.ts +21 -0
  21. package/src/modules/native-logs/ui/components/native-log-detail/index.ts +1 -0
  22. package/src/modules/native-logs/ui/components/native-log-detail/native-log-detail.tsx +63 -0
  23. package/src/modules/native-logs/ui/components/native-log-list/index.ts +1 -0
  24. package/src/modules/native-logs/ui/components/native-log-list/native-log-list.tsx +33 -0
  25. package/src/modules/native-logs/ui/containers/native-logs-container/index.ts +1 -0
  26. package/src/modules/native-logs/ui/containers/native-logs-container/native-logs-container.tsx +80 -0
  27. package/src/modules/network/library/constants.ts +15 -0
  28. package/src/modules/network/store/network.store.ts +58 -0
  29. package/src/modules/network/ui/components/network-detail/index.ts +1 -0
  30. package/src/modules/network/ui/components/network-detail/network-detail.tsx +82 -0
  31. package/src/modules/network/ui/components/network-row/index.ts +1 -0
  32. package/src/modules/network/ui/components/network-row/network-row.tsx +23 -0
  33. package/src/modules/network/ui/components/network-table-header/index.ts +1 -0
  34. package/src/modules/network/ui/components/network-table-header/network-table-header.tsx +12 -0
  35. package/src/modules/network/ui/containers/network-container/index.ts +1 -0
  36. package/src/modules/network/ui/containers/network-container/network-container.tsx +93 -0
  37. package/src/server/ws-server.ts +52 -0
  38. package/src/shared/components/ascii-logo/ascii-logo.tsx +169 -0
  39. package/src/shared/components/ascii-logo/ascii.txt +0 -0
  40. package/src/shared/components/ascii-logo/index.ts +1 -0
  41. package/src/shared/components/gauge/gauge.tsx +18 -0
  42. package/src/shared/components/gauge/index.ts +1 -0
  43. package/src/shared/components/log-row/index.ts +1 -0
  44. package/src/shared/components/log-row/log-row.tsx +28 -0
  45. package/src/shared/components/panel/index.ts +1 -0
  46. package/src/shared/components/panel/panel.tsx +28 -0
  47. package/src/shared/components/sparkline/index.ts +1 -0
  48. package/src/shared/components/sparkline/sparkline.tsx +26 -0
  49. package/src/shared/components/status-bar/index.ts +1 -0
  50. package/src/shared/components/status-bar/status-bar.tsx +32 -0
  51. package/src/shared/components/tab-bar/index.ts +1 -0
  52. package/src/shared/components/tab-bar/tab-bar.tsx +36 -0
  53. package/src/shared/hooks/use-detail-panel.ts +78 -0
  54. package/src/shared/hooks/use-list-navigation.ts +44 -0
  55. package/src/shared/hooks/use-terminal-size.ts +42 -0
  56. package/src/shared/store/device.store.ts +25 -0
  57. package/src/shared/types.ts +3 -0
  58. package/src/shared/utils/build-curl-command.ts +19 -0
  59. package/src/shared/utils/clipboard.ts +27 -0
  60. package/src/shared/utils/format-body.ts +21 -0
@@ -0,0 +1,28 @@
1
+ import { Box, Text } from 'ink'
2
+ import type { ReactNode } from 'react'
3
+
4
+ interface PanelProps {
5
+ title: string
6
+ focused?: boolean
7
+ flexGrow?: number
8
+ height?: number | string
9
+ children: ReactNode
10
+ }
11
+
12
+ export function Panel({ title, focused = false, flexGrow, height, children }: PanelProps) {
13
+ const color = focused ? 'cyan' : 'gray'
14
+
15
+ return (
16
+ <Box
17
+ flexDirection="column"
18
+ borderStyle="single"
19
+ borderColor={color}
20
+ paddingX={1}
21
+ flexGrow={flexGrow}
22
+ height={height}
23
+ >
24
+ <Text bold color={color}>{title}</Text>
25
+ {children}
26
+ </Box>
27
+ )
28
+ }
@@ -0,0 +1 @@
1
+ export { Sparkline } from './sparkline'
@@ -0,0 +1,26 @@
1
+ import { Text } from 'ink'
2
+
3
+ const BLOCKS = ' ▁▂▃▄▅▆▇█'
4
+
5
+ interface SparklineProps {
6
+ values: number[]
7
+ max: number
8
+ width?: number
9
+ color?: string
10
+ }
11
+
12
+ export function Sparkline({ values, max, width = 20, color = 'green' }: SparklineProps) {
13
+ const padded = [
14
+ ...Array(Math.max(0, width - values.length)).fill(0),
15
+ ...values,
16
+ ].slice(-width)
17
+
18
+ const chars = padded
19
+ .map((v) => {
20
+ const ratio = Math.min(1, Math.max(0, v / max))
21
+ return BLOCKS[Math.round(ratio * (BLOCKS.length - 1))]
22
+ })
23
+ .join('')
24
+
25
+ return <Text color={color}>{chars}</Text>
26
+ }
@@ -0,0 +1 @@
1
+ export { StatusBar } from './status-bar'
@@ -0,0 +1,32 @@
1
+ import { Box, Text } from 'ink'
2
+ import { useDevice, useConnectionStatus } from '../../store/device.store.js'
3
+
4
+ export function StatusBar() {
5
+ const device = useDevice()
6
+ const connected = useConnectionStatus()
7
+
8
+ return (
9
+ <Box
10
+ borderStyle="single"
11
+ borderColor="gray"
12
+ borderTop={true}
13
+ borderBottom={false}
14
+ borderLeft={false}
15
+ borderRight={false}
16
+ paddingX={1}
17
+ >
18
+ {connected && device
19
+ ?
20
+ <>
21
+ <Text color="green">● </Text>
22
+ <Text>{device.deviceName} ({device.platform}) · port {process.env.SALVETRON_PORT ?? '8765'}</Text>
23
+ </>
24
+ :
25
+ <>
26
+ <Text color="gray">○ </Text>
27
+ <Text color="gray">Waiting for connection on :{process.env.SALVETRON_PORT ?? '8765'}</Text>
28
+ </>
29
+ }
30
+ </Box>
31
+ )
32
+ }
@@ -0,0 +1 @@
1
+ export { TabBar } from './tab-bar'
@@ -0,0 +1,36 @@
1
+ import { Box, Text } from 'ink'
2
+ import type { Tab } from '../../../app.js'
3
+
4
+ const LABELS: Record<Tab, string> = {
5
+ dashboard: '1 Dashboard',
6
+ 'js-logs': '2 JS Logs',
7
+ network: '3 Network',
8
+ native: '4 Native',
9
+ }
10
+
11
+ interface TabBarProps {
12
+ active: Tab
13
+ }
14
+
15
+ export function TabBar({ active }: TabBarProps) {
16
+ return (
17
+ <Box
18
+ borderStyle="single"
19
+ borderBottom={true}
20
+ marginTop={1}
21
+ borderColor="gray"
22
+ borderTop={false}
23
+ borderLeft={false}
24
+ borderRight={false}
25
+ gap={2}
26
+ paddingX={1}
27
+ >
28
+ {(Object.keys(LABELS) as Tab[]).map((tab) =>
29
+ <Text key={tab} bold={tab === active} color={tab === active ? 'cyan' : 'gray'}>
30
+ {LABELS[tab]}
31
+ </Text>
32
+ )}
33
+ <Text color="gray" dimColor> Tab to switch</Text>
34
+ </Box>
35
+ )
36
+ }
@@ -0,0 +1,78 @@
1
+ import { useInput } from 'ink'
2
+ import { useState, useCallback, useEffect, useRef } from 'react'
3
+ import type { RefObject } from 'react'
4
+ import { copyToClipboard } from '../utils/clipboard.js'
5
+
6
+ export interface CopyFeedback {
7
+ kind: 'body' | 'extra'
8
+ success: boolean
9
+ }
10
+
11
+ interface UseDetailPanelParams {
12
+ linesRef: RefObject<string[]>
13
+ visibleRows: number
14
+ scrollStep?: number
15
+ isActive?: boolean
16
+ onCopyBody?: () => string
17
+ onCopyExtra?: () => string
18
+ }
19
+
20
+ const FEEDBACK_TIMEOUT_MS = 1500
21
+
22
+ export function useDetailPanel({
23
+ linesRef,
24
+ visibleRows,
25
+ scrollStep = 1,
26
+ isActive = true,
27
+ onCopyBody,
28
+ onCopyExtra,
29
+ }: UseDetailPanelParams) {
30
+ const [detailOpen, setDetailOpen] = useState(false)
31
+ const [detailScrollOffset, setDetailScrollOffset] = useState(0)
32
+ const [copyFeedback, setCopyFeedback] = useState<CopyFeedback | null>(null)
33
+
34
+ const feedbackTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
35
+
36
+ const resetDetailScroll = useCallback(() => setDetailScrollOffset(0), [])
37
+
38
+ const showFeedback = useCallback((kind: CopyFeedback['kind'], success: boolean) => {
39
+ if (feedbackTimeoutRef.current) clearTimeout(feedbackTimeoutRef.current)
40
+ setCopyFeedback({ kind, success })
41
+ feedbackTimeoutRef.current = setTimeout(() => {
42
+ setCopyFeedback(null)
43
+ feedbackTimeoutRef.current = null
44
+ }, FEEDBACK_TIMEOUT_MS)
45
+ }, [])
46
+
47
+ useEffect(() => {
48
+ if (!detailOpen && copyFeedback) {
49
+ if (feedbackTimeoutRef.current) clearTimeout(feedbackTimeoutRef.current)
50
+ feedbackTimeoutRef.current = null
51
+ setCopyFeedback(null)
52
+ }
53
+ }, [detailOpen, copyFeedback])
54
+
55
+ useEffect(() => () => {
56
+ if (feedbackTimeoutRef.current) clearTimeout(feedbackTimeoutRef.current)
57
+ }, [])
58
+
59
+ useInput((input, key) => {
60
+ if (key.return) { setDetailOpen(o => !o); setDetailScrollOffset(0) }
61
+ if (key.escape) { setDetailOpen(false) }
62
+ if (input === '[') { setDetailScrollOffset(o => Math.max(0, o - scrollStep)) }
63
+ if (input === ']') {
64
+ const maxOffset = Math.max(0, linesRef.current.length - visibleRows)
65
+ setDetailScrollOffset(o => Math.min(maxOffset, o + scrollStep))
66
+ }
67
+ if (input === 'c' && detailOpen && onCopyBody) {
68
+ const text = onCopyBody()
69
+ if (text.length > 0) showFeedback('body', copyToClipboard(text))
70
+ }
71
+ if (input === 'u' && detailOpen && onCopyExtra) {
72
+ const text = onCopyExtra()
73
+ if (text.length > 0) showFeedback('extra', copyToClipboard(text))
74
+ }
75
+ }, { isActive })
76
+
77
+ return { detailOpen, detailScrollOffset, resetDetailScroll, copyFeedback }
78
+ }
@@ -0,0 +1,44 @@
1
+ import { useInput } from 'ink'
2
+ import { useState, useEffect, useRef } from 'react'
3
+
4
+ interface UseListNavigationParams {
5
+ count: number
6
+ visibleRows: number
7
+ isActive?: boolean
8
+ }
9
+
10
+ export function useListNavigation({ count, visibleRows, isActive = true }: UseListNavigationParams) {
11
+ const [selectedIndex, setSelectedIndex] = useState(0)
12
+ const [scrollOffset, setScrollOffset] = useState(0)
13
+ const pinnedToLatestRef = useRef(true)
14
+
15
+ // Follow the newest item as it arrives, unless the user scrolled away
16
+ // from the bottom — then stay put until they navigate back down to it.
17
+ useEffect(() => {
18
+ if (count === 0) return
19
+ if (pinnedToLatestRef.current) {
20
+ const last = count - 1
21
+ setSelectedIndex(last)
22
+ setScrollOffset(Math.max(0, last - visibleRows + 1))
23
+ } else if (selectedIndex > count - 1) {
24
+ setSelectedIndex(Math.max(0, count - 1))
25
+ }
26
+ }, [count, visibleRows])
27
+
28
+ useInput((_input, key) => {
29
+ if (key.upArrow) {
30
+ const next = Math.max(0, selectedIndex - 1)
31
+ pinnedToLatestRef.current = false
32
+ setSelectedIndex(next)
33
+ if (next < scrollOffset) setScrollOffset(next)
34
+ }
35
+ if (key.downArrow) {
36
+ const next = Math.min(count - 1, selectedIndex + 1)
37
+ pinnedToLatestRef.current = next >= count - 1
38
+ setSelectedIndex(next)
39
+ if (next >= scrollOffset + visibleRows) setScrollOffset(next - visibleRows + 1)
40
+ }
41
+ }, { isActive })
42
+
43
+ return { selectedIndex, scrollOffset }
44
+ }
@@ -0,0 +1,42 @@
1
+ import { useStdout } from 'ink'
2
+ import { useState, useEffect } from 'react'
3
+
4
+ // Clears the visible viewport and homes the cursor without touching scrollback.
5
+ // Ink's own log-update tracks previous output height by counting '\n' in the
6
+ // rendered string, not actual wrapped terminal rows, so its line-erase math
7
+ // drifts after a width change and leaves stale frame remnants on screen
8
+ // (https://github.com/vadimdemedes/ink/issues/907). A hard clear right before
9
+ // committing the new size sidesteps that miscount entirely.
10
+ const CLEAR_VIEWPORT = '\x1b[2J\x1b[H'
11
+
12
+ export function useTerminalSize(): [number, number] {
13
+ const { stdout } = useStdout()
14
+ const [size, setSize] = useState<[number, number]>([
15
+ stdout?.columns ?? 80,
16
+ stdout?.rows ?? 24,
17
+ ])
18
+
19
+ useEffect(() => {
20
+ if (!stdout) return
21
+ let timer: ReturnType<typeof setTimeout> | undefined
22
+ const handler = () => {
23
+ clearTimeout(timer)
24
+ timer = setTimeout(() => {
25
+ const cols = stdout.columns
26
+ const rows = stdout.rows
27
+ setSize((prev) => {
28
+ if (prev[0] === cols && prev[1] === rows) return prev
29
+ stdout.write(CLEAR_VIEWPORT)
30
+ return [cols, rows]
31
+ })
32
+ }, 80)
33
+ }
34
+ stdout.on('resize', handler)
35
+ return () => {
36
+ clearTimeout(timer)
37
+ stdout.off('resize', handler)
38
+ }
39
+ }, [stdout])
40
+
41
+ return size
42
+ }
@@ -0,0 +1,25 @@
1
+ import { create } from 'zustand'
2
+ import type { DeviceInfoEvent, ProjectInfoEvent } from '@salve-software/salvetron-types'
3
+
4
+ interface DeviceStore {
5
+ device: DeviceInfoEvent | null
6
+ project: ProjectInfoEvent | null
7
+ connected: boolean
8
+ setInfo: (event: DeviceInfoEvent | ProjectInfoEvent) => void
9
+ setDisconnected: () => void
10
+ }
11
+
12
+ export const useDeviceStore = create<DeviceStore>((set) => ({
13
+ device: null,
14
+ project: null,
15
+ connected: false,
16
+ setInfo: (event) => {
17
+ if (event.type === 'device_info') set({ device: event, connected: true })
18
+ if (event.type === 'project_info') set({ project: event })
19
+ },
20
+ setDisconnected: () => set({ connected: false, device: null, project: null }),
21
+ }))
22
+
23
+ export const useDevice = () => useDeviceStore((s) => s.device)
24
+ export const useProject = () => useDeviceStore((s) => s.project)
25
+ export const useConnectionStatus = () => useDeviceStore((s) => s.connected)
@@ -0,0 +1,3 @@
1
+ import type { LogEvent, NativeLogEvent } from '@salve-software/salvetron-types'
2
+
3
+ export type AnyLog = LogEvent | NativeLogEvent
@@ -0,0 +1,19 @@
1
+ import type { NetworkLog } from '@salve-software/salvetron-types'
2
+
3
+ function shellSingleQuote(value: string): string {
4
+ return `'${value.replace(/'/g, "'\\''")}'`
5
+ }
6
+
7
+ export function buildCurlCommand(log: NetworkLog): string {
8
+ const parts: string[] = [`curl -X ${log.method}`, ` ${shellSingleQuote(log.url)}`]
9
+
10
+ for (const [key, value] of Object.entries(log.requestHeaders ?? {})) {
11
+ parts.push(` -H ${shellSingleQuote(`${key}: ${value}`)}`)
12
+ }
13
+
14
+ if (log.requestBody != null) {
15
+ parts.push(` --data ${shellSingleQuote(log.requestBody)}`)
16
+ }
17
+
18
+ return parts.join(' \\\n')
19
+ }
@@ -0,0 +1,27 @@
1
+ import { spawnSync } from 'node:child_process'
2
+
3
+ interface ClipTool { cmd: string; args: string[] }
4
+
5
+ function getClipboardTools(): ClipTool[] {
6
+ switch (process.platform) {
7
+ case 'darwin':
8
+ return [{ cmd: 'pbcopy', args: [] }]
9
+ case 'win32':
10
+ return [{ cmd: 'clip', args: [] }]
11
+ default:
12
+ return [
13
+ { cmd: 'xclip', args: ['-selection', 'clipboard'] },
14
+ { cmd: 'xsel', args: ['--clipboard', '--input'] },
15
+ ]
16
+ }
17
+ }
18
+
19
+ export function copyToClipboard(text: string): boolean {
20
+ for (const tool of getClipboardTools()) {
21
+ const result = spawnSync(tool.cmd, tool.args, { input: text, encoding: 'utf8' })
22
+ if (result.error) continue
23
+ if (typeof result.status === 'number' && result.status !== 0) continue
24
+ return true
25
+ }
26
+ return false
27
+ }
@@ -0,0 +1,21 @@
1
+ import { colorize } from 'json-colorizer'
2
+
3
+ export function formatBody(body: string | null | undefined): string[] {
4
+ if (!body) return []
5
+ try {
6
+ const parsed = JSON.parse(body)
7
+ return colorize(JSON.stringify(parsed, null, 2)).split('\n')
8
+ } catch {
9
+ return body.split('\n')
10
+ }
11
+ }
12
+
13
+ export function formatPlainBody(body: string | null | undefined): string {
14
+ if (!body) return ''
15
+ try {
16
+ const parsed = JSON.parse(body)
17
+ return JSON.stringify(parsed, null, 2)
18
+ } catch {
19
+ return body
20
+ }
21
+ }