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,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,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
|
+
}
|