@agfpd/iapeer 0.4.30 → 0.4.32
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/package.json +1 -1
- package/src/cli/index.ts +12 -4
- package/src/launch/adapters/transcriptTail.ts +54 -32
- package/src/tui/dashboard/app.tsx +256 -0
- package/src/tui/dashboard/data.ts +79 -0
- package/src/tui/dashboard/model.ts +127 -0
- package/src/tui/dashboard/run.tsx +96 -0
package/package.json
CHANGED
package/src/cli/index.ts
CHANGED
|
@@ -100,6 +100,9 @@ export interface PeerListing {
|
|
|
100
100
|
default_runtime: Runtime
|
|
101
101
|
/** Runtime with the freshest activity (what `attach` resumes); undefined if none. */
|
|
102
102
|
last_active_runtime?: Runtime
|
|
103
|
+
/** Epoch-ms of that freshest activity (the dashboard's ACTIVE age column);
|
|
104
|
+
* undefined when no runtime has an activity proxy. */
|
|
105
|
+
last_active_ms?: number
|
|
103
106
|
intelligence: Intelligence
|
|
104
107
|
description: string
|
|
105
108
|
/** The peer's working directory (registry fact). Machine-readable so host-local
|
|
@@ -149,6 +152,7 @@ export function listPeers(opts: CliEnvOptions = {}): PeerListing[] {
|
|
|
149
152
|
personality: peer.personality,
|
|
150
153
|
default_runtime: peer.runtime,
|
|
151
154
|
last_active_runtime: lastActive,
|
|
155
|
+
last_active_ms: bestMt >= 0 ? bestMt : undefined,
|
|
152
156
|
intelligence: peer.intelligence,
|
|
153
157
|
description: peer.description,
|
|
154
158
|
cwd: peer.cwd,
|
|
@@ -1754,11 +1758,15 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
1754
1758
|
return r.bootstrapped && (r.bootstrapped.state === 'failed' || r.bootstrapped.state === 'refused-foreign') ? 1 : 0
|
|
1755
1759
|
}
|
|
1756
1760
|
case 'list': {
|
|
1757
|
-
// tty + no --json → the
|
|
1758
|
-
//
|
|
1761
|
+
// tty + no --json → the live Ink dashboard (Фаза 3: host header · live peer
|
|
1762
|
+
// table · per-peer log panel · Enter=attach via suspend-and-spawn);
|
|
1763
|
+
// non-tty / --json → the scriptable table (machine-parsable). The dashboard
|
|
1764
|
+
// fails CLOSED to the table when no real TTY drives it (sentinel), the same
|
|
1765
|
+
// belt as the onboard wizard.
|
|
1759
1766
|
if (flags.json !== true && process.stdout.isTTY && process.stdin.isTTY) {
|
|
1760
|
-
const {
|
|
1761
|
-
|
|
1767
|
+
const { runDashboard, DASHBOARD_NOT_INTERACTIVE } = await import('../tui/dashboard/run.tsx')
|
|
1768
|
+
const code = await runDashboard({ env })
|
|
1769
|
+
if (code !== DASHBOARD_NOT_INTERACTIVE) return code
|
|
1762
1770
|
}
|
|
1763
1771
|
const rows = listPeers({ env })
|
|
1764
1772
|
out(flags.json ? JSON.stringify(rows, null, 2) + '\n' : formatListTable(rows))
|
|
@@ -11,12 +11,24 @@ import { openSync, readSync, closeSync, statSync } from 'fs'
|
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
13
|
* Content-timestamp (epoch ms) of the LAST jsonl entry carrying a `timestamp` field, read from the
|
|
14
|
-
* file's TAIL
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
14
|
+
* file's TAIL. Metadata records without a `timestamp` (claude `mode` / `ai-title` / `bridge-session`)
|
|
15
|
+
* are skipped. Returns null only when the file is unreadable / empty, or genuinely carries NO
|
|
16
|
+
* timestamped entry anywhere (the caller then floors at wokeAt).
|
|
17
|
+
*
|
|
18
|
+
* ADAPTIVE WINDOW (the giant-tail fix). The reader starts at the cheap `tailBytes` window and GROWS it
|
|
19
|
+
* (doubling, capped at file size) until a complete timestamped entry is captured. A single transcript
|
|
20
|
+
* record can dwarf the default window — a browser peer appends base64 screenshots, observed up to
|
|
21
|
+
* 368 KB (5.6× the 64 KB default). When such a record is the tail, a FIXED window holds only a fragment
|
|
22
|
+
* of one line: no complete JSON → the old code returned null, the caller floored at wokeAt, and a LIVE
|
|
23
|
+
* working session was idle-reaped (incident: mrbrowser killed mid-task, last turn 7 s before the reap).
|
|
24
|
+
* Growing guarantees we eventually read the full last record (worst case: the entire file), so a present
|
|
25
|
+
* timestamp is NEVER missed. The default window already covers the common case in one read; growth only
|
|
26
|
+
* kicks in for oversized tail records, and even then is bounded (≈log2(fileSize/tailBytes) reads).
|
|
27
|
+
*
|
|
28
|
+
* A partial LEADING line is never trusted: once the window starts past byte 0, the first split segment
|
|
29
|
+
* may be the truncated tail of an earlier record (which could mis-parse as valid JSON), so we scan only
|
|
30
|
+
* the newline-bounded segments after it. Reaching byte 0 makes every segment complete, including the
|
|
31
|
+
* first — and terminates the growth.
|
|
20
32
|
*/
|
|
21
33
|
export function lastTimestampedEntryMs(path: string, tailBytes = 65536): number | null {
|
|
22
34
|
let fd: number
|
|
@@ -27,35 +39,45 @@ export function lastTimestampedEntryMs(path: string, tailBytes = 65536): number
|
|
|
27
39
|
}
|
|
28
40
|
try {
|
|
29
41
|
const size = statSync(path).size
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
for (let i = lines.length - 1; i >= 0; i--) {
|
|
42
|
-
const line = lines[i].trim()
|
|
43
|
-
if (!line) continue
|
|
44
|
-
let obj: unknown
|
|
45
|
-
try {
|
|
46
|
-
obj = JSON.parse(line)
|
|
47
|
-
} catch {
|
|
48
|
-
continue // partial leading line / non-json → skip
|
|
42
|
+
if (size <= 0) return null
|
|
43
|
+
let window = Math.max(tailBytes, 1)
|
|
44
|
+
for (;;) {
|
|
45
|
+
const start = Math.max(0, size - window)
|
|
46
|
+
const len = size - start
|
|
47
|
+
const buf = Buffer.allocUnsafe(len)
|
|
48
|
+
let read = 0
|
|
49
|
+
while (read < len) {
|
|
50
|
+
const n = readSync(fd, buf, read, len - read, start + read)
|
|
51
|
+
if (n <= 0) break
|
|
52
|
+
read += n
|
|
49
53
|
}
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
54
|
+
const atFileStart = start === 0
|
|
55
|
+
const lines = buf.subarray(0, read).toString('utf8').split(/\r?\n/)
|
|
56
|
+
// Segment 0 is a complete record ONLY when the window reaches byte 0; otherwise it is the possibly
|
|
57
|
+
// truncated tail of an earlier record and must not be parsed (`floor` skips it).
|
|
58
|
+
const floor = atFileStart ? 0 : 1
|
|
59
|
+
for (let i = lines.length - 1; i >= floor; i--) {
|
|
60
|
+
const line = lines[i].trim()
|
|
61
|
+
if (!line) continue
|
|
62
|
+
let obj: unknown
|
|
63
|
+
try {
|
|
64
|
+
obj = JSON.parse(line)
|
|
65
|
+
} catch {
|
|
66
|
+
continue // metadata fragment / non-json → skip
|
|
67
|
+
}
|
|
68
|
+
const ts = (obj as { timestamp?: unknown })?.timestamp
|
|
69
|
+
if (typeof ts === 'string') {
|
|
70
|
+
const ms = Date.parse(ts)
|
|
71
|
+
if (!Number.isNaN(ms)) return ms
|
|
72
|
+
} else if (typeof ts === 'number' && Number.isFinite(ts)) {
|
|
73
|
+
return ts
|
|
74
|
+
}
|
|
56
75
|
}
|
|
76
|
+
// No complete timestamped entry in this window. If the whole file is read, there genuinely is none.
|
|
77
|
+
// Otherwise the tail record outgrows the window → grow and retry (terminates: window hits size → start 0).
|
|
78
|
+
if (atFileStart) return null
|
|
79
|
+
window *= 2
|
|
57
80
|
}
|
|
58
|
-
return null
|
|
59
81
|
} catch {
|
|
60
82
|
return null
|
|
61
83
|
} finally {
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
// DashboardApp — the live management dashboard (Ink), the tty form of `iapeer list`
|
|
2
|
+
// (Фаза 3 «TUI-редизайн management», эталон вкуса — Claude Code, владелец 02.07).
|
|
3
|
+
//
|
|
4
|
+
// Presents: a host header (daemon / memory / voice health), a live peer table
|
|
5
|
+
// (per-runtime liveness ● ○ ✕, nature, last-activity age), an optional per-peer
|
|
6
|
+
// event-log panel (delivery + lifecycle), and a hint footer. State is polled
|
|
7
|
+
// in-process (решение B1) — peers every POLL_MS, host header every HOST_POLL_MS.
|
|
8
|
+
//
|
|
9
|
+
// The app itself never touches the terminal beyond Ink: attach is an ACTION handed
|
|
10
|
+
// to run.tsx (suspend-and-spawn — Ink unmounts, `iapeer attach` child inherits the
|
|
11
|
+
// real TTY, dashboard remounts on its exit). Quit likewise. That keeps ownership of
|
|
12
|
+
// raw-mode/alt-screen in exactly one place (run.tsx).
|
|
13
|
+
|
|
14
|
+
import React, { useEffect, useRef, useState } from 'react'
|
|
15
|
+
import { Box, Text, useApp, useInput, useStdout } from 'ink'
|
|
16
|
+
import type { PeerListing, RuntimeLiveness } from '../../cli/index.ts'
|
|
17
|
+
import { clampCursor, ellipsize, filterRows, formatAge, scrollWindow } from './model.ts'
|
|
18
|
+
import { takeHostHeader, takePeerLog, takePeersSnapshot, type HostHeader } from './data.ts'
|
|
19
|
+
|
|
20
|
+
export type DashAction = { type: 'attach'; personality: string } | { type: 'quit' }
|
|
21
|
+
|
|
22
|
+
const POLL_MS = 2000
|
|
23
|
+
const HOST_POLL_MS = 10_000
|
|
24
|
+
const LOG_LIMIT = 12
|
|
25
|
+
|
|
26
|
+
const STATUS_GLYPH: Record<RuntimeLiveness, string> = { live: '●', asleep: '○', stopped: '✕' }
|
|
27
|
+
const STATUS_COLOR: Record<RuntimeLiveness, string | undefined> = { live: 'green', asleep: undefined, stopped: 'red' }
|
|
28
|
+
|
|
29
|
+
function RuntimeCells({ row }: { row: PeerListing }): React.ReactElement {
|
|
30
|
+
return (
|
|
31
|
+
<>
|
|
32
|
+
{row.runtimes.map((s, i) => (
|
|
33
|
+
<React.Fragment key={s.runtime}>
|
|
34
|
+
{i > 0 ? <Text> </Text> : null}
|
|
35
|
+
<Text color={STATUS_COLOR[s.status]} dimColor={s.status === 'asleep'}>
|
|
36
|
+
{STATUS_GLYPH[s.status]} {s.runtime}
|
|
37
|
+
</Text>
|
|
38
|
+
</React.Fragment>
|
|
39
|
+
))}
|
|
40
|
+
</>
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function DashboardApp({
|
|
45
|
+
env,
|
|
46
|
+
onAction,
|
|
47
|
+
}: {
|
|
48
|
+
env: NodeJS.ProcessEnv
|
|
49
|
+
onAction: (a: DashAction) => void
|
|
50
|
+
}): React.ReactElement {
|
|
51
|
+
const { exit } = useApp()
|
|
52
|
+
const { stdout } = useStdout()
|
|
53
|
+
const [rows, setRows] = useState<PeerListing[]>(() => takePeersSnapshot(env))
|
|
54
|
+
const [host, setHost] = useState<HostHeader | null>(null)
|
|
55
|
+
const [now, setNow] = useState<number>(() => Date.now())
|
|
56
|
+
const [cursor, setCursor] = useState(0)
|
|
57
|
+
const [filter, setFilter] = useState('')
|
|
58
|
+
const [filterMode, setFilterMode] = useState(false)
|
|
59
|
+
const [logsOpen, setLogsOpen] = useState(false)
|
|
60
|
+
const [logLines, setLogLines] = useState<Array<{ text: string; tone: 'ok' | 'fail' | 'info' }>>([])
|
|
61
|
+
// NB: a pty can report 0×0 (ws unset) — `??` alone would keep the zero, so clamp
|
|
62
|
+
// through a positive-or-default helper everywhere a dimension is read.
|
|
63
|
+
const dim = (v: number | undefined, d: number): number => (v && v > 0 ? v : d)
|
|
64
|
+
const [size, setSize] = useState({ cols: dim(stdout?.columns, 80), rows: dim(stdout?.rows, 24) })
|
|
65
|
+
const doneRef = useRef(false)
|
|
66
|
+
|
|
67
|
+
const finish = (a: DashAction): void => {
|
|
68
|
+
if (doneRef.current) return
|
|
69
|
+
doneRef.current = true
|
|
70
|
+
onAction(a)
|
|
71
|
+
exit()
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// peers + clock poll (fast), host header poll (slow), resize.
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
const t = setInterval(() => {
|
|
77
|
+
setRows(takePeersSnapshot(env))
|
|
78
|
+
setNow(Date.now())
|
|
79
|
+
}, POLL_MS)
|
|
80
|
+
return () => clearInterval(t)
|
|
81
|
+
}, [env])
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
let gone = false
|
|
84
|
+
const probe = (): void => {
|
|
85
|
+
void takeHostHeader(env).then(h => {
|
|
86
|
+
if (!gone) setHost(h)
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
probe()
|
|
90
|
+
const t = setInterval(probe, HOST_POLL_MS)
|
|
91
|
+
return () => {
|
|
92
|
+
gone = true
|
|
93
|
+
clearInterval(t)
|
|
94
|
+
}
|
|
95
|
+
}, [env])
|
|
96
|
+
useEffect(() => {
|
|
97
|
+
if (!stdout) return
|
|
98
|
+
const onResize = (): void => setSize({ cols: dim(stdout.columns, 80), rows: dim(stdout.rows, 24) })
|
|
99
|
+
stdout.on('resize', onResize)
|
|
100
|
+
return () => {
|
|
101
|
+
stdout.off('resize', onResize)
|
|
102
|
+
}
|
|
103
|
+
}, [stdout])
|
|
104
|
+
|
|
105
|
+
const visible = filterRows(rows, filter)
|
|
106
|
+
const cur = clampCursor(cursor, visible.length)
|
|
107
|
+
const selected = visible[cur]
|
|
108
|
+
|
|
109
|
+
// per-peer log panel poll — only while open, keyed to the selected peer.
|
|
110
|
+
useEffect(() => {
|
|
111
|
+
if (!logsOpen || !selected) return
|
|
112
|
+
const load = (): void => setLogLines(takePeerLog(env, selected.personality, LOG_LIMIT))
|
|
113
|
+
load()
|
|
114
|
+
const t = setInterval(load, POLL_MS)
|
|
115
|
+
return () => clearInterval(t)
|
|
116
|
+
}, [logsOpen, selected?.personality, env])
|
|
117
|
+
|
|
118
|
+
useInput((input, key) => {
|
|
119
|
+
if (filterMode) {
|
|
120
|
+
if (key.return || key.escape) setFilterMode(false)
|
|
121
|
+
else if (key.backspace || key.delete) setFilter(f => f.slice(0, -1))
|
|
122
|
+
else if (input && !key.ctrl && !key.meta) setFilter(f => f + input)
|
|
123
|
+
setCursor(0)
|
|
124
|
+
return
|
|
125
|
+
}
|
|
126
|
+
if (input === 'q' || (key.ctrl && input === 'c')) return finish({ type: 'quit' })
|
|
127
|
+
if (key.escape) {
|
|
128
|
+
if (logsOpen) setLogsOpen(false)
|
|
129
|
+
else if (filter) setFilter('')
|
|
130
|
+
return
|
|
131
|
+
}
|
|
132
|
+
if (input === '/') return setFilterMode(true)
|
|
133
|
+
if (input === 'l') return setLogsOpen(o => !o)
|
|
134
|
+
if (input === 'r') {
|
|
135
|
+
setRows(takePeersSnapshot(env))
|
|
136
|
+
setNow(Date.now())
|
|
137
|
+
return
|
|
138
|
+
}
|
|
139
|
+
if (key.upArrow || input === 'k') return setCursor(c => clampCursor(c - 1, visible.length))
|
|
140
|
+
if (key.downArrow || input === 'j') return setCursor(c => clampCursor(c + 1, visible.length))
|
|
141
|
+
if (key.return && selected) return finish({ type: 'attach', personality: selected.personality })
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
// ── layout ──────────────────────────────────────────────────────────────────
|
|
145
|
+
const liveCount = rows.filter(r => r.runtimes.some(s => s.status === 'live')).length
|
|
146
|
+
// fixed rows around the table: header(1) + header-margin(1) + column line(1) +
|
|
147
|
+
// "↓ N more"(1) + footer margin+line(2) + one spare against terminal rounding.
|
|
148
|
+
const overhead = 7 + (logsOpen ? LOG_LIMIT + 3 : 0) + (filterMode || filter ? 1 : 0)
|
|
149
|
+
const tableHeight = Math.max(3, size.rows - overhead)
|
|
150
|
+
const win = scrollWindow(cur, visible.length, tableHeight)
|
|
151
|
+
const nameW = Math.min(18, Math.max(6, ...visible.map(r => r.personality.length)))
|
|
152
|
+
const rtW = Math.max(10, ...visible.map(r => r.runtimes.map(s => `x ${s.runtime}`).join(' ').length))
|
|
153
|
+
const descW = Math.max(0, size.cols - (2 + nameW + 2 + rtW + 2 + 10 + 2 + 6 + 2) - 2)
|
|
154
|
+
|
|
155
|
+
return (
|
|
156
|
+
<Box flexDirection="column" paddingX={1}>
|
|
157
|
+
{/* host header */}
|
|
158
|
+
<Box justifyContent="space-between">
|
|
159
|
+
<Box>
|
|
160
|
+
<Text color="cyan" bold>
|
|
161
|
+
⏺ iapeer
|
|
162
|
+
</Text>
|
|
163
|
+
<Text bold> peers</Text>
|
|
164
|
+
<Text dimColor>
|
|
165
|
+
{' '}
|
|
166
|
+
{rows.length} peers · {liveCount} live
|
|
167
|
+
</Text>
|
|
168
|
+
</Box>
|
|
169
|
+
<Box>
|
|
170
|
+
{host ? (
|
|
171
|
+
<>
|
|
172
|
+
<Text dimColor>v{host.version} · daemon </Text>
|
|
173
|
+
<Text color={host.daemonHealthy ? 'green' : 'red'}>{host.daemonHealthy ? '●' : '✕'}</Text>
|
|
174
|
+
<Text dimColor> · memory </Text>
|
|
175
|
+
<Text color={host.memory.present ? 'green' : undefined} dimColor={!host.memory.present}>
|
|
176
|
+
{host.memory.present ? '●' : '○'}
|
|
177
|
+
</Text>
|
|
178
|
+
<Text dimColor> · voice </Text>
|
|
179
|
+
<Text color={host.voice.present ? 'green' : undefined} dimColor={!host.voice.present}>
|
|
180
|
+
{host.voice.present ? '●' : '○'}
|
|
181
|
+
</Text>
|
|
182
|
+
</>
|
|
183
|
+
) : (
|
|
184
|
+
<Text dimColor>probing host…</Text>
|
|
185
|
+
)}
|
|
186
|
+
</Box>
|
|
187
|
+
</Box>
|
|
188
|
+
<Box marginTop={1}>
|
|
189
|
+
<Text dimColor>
|
|
190
|
+
{' '}
|
|
191
|
+
{'NAME'.padEnd(nameW)} {'RUNTIMES'.padEnd(rtW)} {'NATURE'.padEnd(10)} {'ACTIVE'.padEnd(6)} DESCRIPTION
|
|
192
|
+
</Text>
|
|
193
|
+
</Box>
|
|
194
|
+
|
|
195
|
+
{/* peer table (scroll window) */}
|
|
196
|
+
<Box flexDirection="column">
|
|
197
|
+
{visible.length === 0 ? (
|
|
198
|
+
<Text dimColor> (no peers{filter ? ' match' : ' registered'})</Text>
|
|
199
|
+
) : (
|
|
200
|
+
visible.slice(win.start, win.end).map((r, i) => {
|
|
201
|
+
const idx = win.start + i
|
|
202
|
+
const sel = idx === cur
|
|
203
|
+
return (
|
|
204
|
+
<Box key={r.personality}>
|
|
205
|
+
<Text color={sel ? 'cyan' : undefined} bold={sel}>
|
|
206
|
+
{sel ? '❯ ' : ' '}
|
|
207
|
+
{ellipsize(r.personality, nameW).padEnd(nameW)}{' '}
|
|
208
|
+
</Text>
|
|
209
|
+
<Box width={rtW + 1}>
|
|
210
|
+
<RuntimeCells row={r} />
|
|
211
|
+
</Box>
|
|
212
|
+
<Text dimColor> {r.intelligence.padEnd(10)}</Text>
|
|
213
|
+
<Text color={sel ? 'cyan' : undefined} dimColor={!sel}>
|
|
214
|
+
{' '}
|
|
215
|
+
{formatAge(r.last_active_ms, now).padEnd(6)}
|
|
216
|
+
</Text>
|
|
217
|
+
<Text dimColor> {ellipsize(r.description, descW)}</Text>
|
|
218
|
+
</Box>
|
|
219
|
+
)
|
|
220
|
+
})
|
|
221
|
+
)}
|
|
222
|
+
{visible.length > win.end ? <Text dimColor> ↓ {visible.length - win.end} more</Text> : null}
|
|
223
|
+
</Box>
|
|
224
|
+
|
|
225
|
+
{/* per-peer log panel */}
|
|
226
|
+
{logsOpen && selected ? (
|
|
227
|
+
<Box marginTop={1} flexDirection="column" borderStyle="round" borderDimColor paddingX={1}>
|
|
228
|
+
<Text dimColor>logs · {selected.personality} (delivery + lifecycle)</Text>
|
|
229
|
+
{logLines.length === 0 ? (
|
|
230
|
+
<Text dimColor>(no recent events)</Text>
|
|
231
|
+
) : (
|
|
232
|
+
logLines.map((l, i) => (
|
|
233
|
+
<Text key={i} color={l.tone === 'fail' ? 'red' : l.tone === 'ok' ? undefined : undefined} dimColor={l.tone !== 'fail'}>
|
|
234
|
+
{ellipsize(l.text, Math.max(10, size.cols - 6))}
|
|
235
|
+
</Text>
|
|
236
|
+
))
|
|
237
|
+
)}
|
|
238
|
+
</Box>
|
|
239
|
+
) : null}
|
|
240
|
+
|
|
241
|
+
{/* filter + footer */}
|
|
242
|
+
{filterMode ? (
|
|
243
|
+
<Text>
|
|
244
|
+
<Text color="cyan">/</Text>
|
|
245
|
+
{filter}
|
|
246
|
+
<Text color="cyan">▌</Text>
|
|
247
|
+
</Text>
|
|
248
|
+
) : filter ? (
|
|
249
|
+
<Text dimColor>filter: {filter} (esc to clear)</Text>
|
|
250
|
+
) : null}
|
|
251
|
+
<Box marginTop={1}>
|
|
252
|
+
<Text dimColor>↑↓ move · ⏎ attach · l logs · / filter · r refresh · q quit</Text>
|
|
253
|
+
</Box>
|
|
254
|
+
</Box>
|
|
255
|
+
)
|
|
256
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Dashboard DATA — the in-process state feed (решение B1: фундамент-примитивы, не
|
|
2
|
+
// RPC): peer rows via listPeers (registry + per-runtime liveness + last-active), the
|
|
3
|
+
// host header via hostStatus (daemon health probe + memory/voice slots), and the
|
|
4
|
+
// per-peer event-log tails (delivery.log + lifecycle.log under cfg.eventLogDir).
|
|
5
|
+
//
|
|
6
|
+
// All paths resolve through the injected env → the same IAPEER_ROOT isolation as
|
|
7
|
+
// every other side-effect (sandbox rule). Reads only — the dashboard mutates nothing.
|
|
8
|
+
|
|
9
|
+
import { closeSync, openSync, readSync, statSync } from 'node:fs'
|
|
10
|
+
import { join } from 'node:path'
|
|
11
|
+
import { listPeers, type PeerListing } from '../../cli/index.ts'
|
|
12
|
+
import { loadLifecycleConfig } from '../../lifecycle/index.ts'
|
|
13
|
+
import { deliveryLogPath } from '../../daemon/deliverylog.ts'
|
|
14
|
+
import { lifecycleLogPath } from '../../lifecycle/eventlog.ts'
|
|
15
|
+
import { assemblePeerLog } from './model.ts'
|
|
16
|
+
|
|
17
|
+
/** One peers snapshot (the poll unit). listPeers is synchronous and cheap: registry
|
|
18
|
+
* read + pidfile kill-0 liveness + transcript/pane-log statSync per peer. */
|
|
19
|
+
export function takePeersSnapshot(env: NodeJS.ProcessEnv): PeerListing[] {
|
|
20
|
+
return listPeers({ env })
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface HostHeader {
|
|
24
|
+
version: string
|
|
25
|
+
daemonHealthy: boolean
|
|
26
|
+
memory: { present: boolean; label: string }
|
|
27
|
+
voice: { present: boolean; label: string }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The host header line (slow poll — the daemon probe has its own short timeout).
|
|
31
|
+
* Dynamic import keeps the update/status modules out of the fast peers-poll path. */
|
|
32
|
+
export async function takeHostHeader(env: NodeJS.ProcessEnv): Promise<HostHeader> {
|
|
33
|
+
const { hostStatus } = await import('../../status/index.ts')
|
|
34
|
+
const s = await hostStatus({ env })
|
|
35
|
+
const slot = (p: { provider: string; version: string } | null, hbAge: number | null): { present: boolean; label: string } =>
|
|
36
|
+
p ? { present: true, label: `${p.version}${hbAge !== null ? ` · hb ${hbAge}s` : ''}` } : { present: false, label: 'none' }
|
|
37
|
+
return {
|
|
38
|
+
version: s.version,
|
|
39
|
+
daemonHealthy: s.daemon.healthy,
|
|
40
|
+
memory: slot(s.memory.provider, s.memory.heartbeatAgeSecs),
|
|
41
|
+
voice: slot(s.voice.provider, s.voice.heartbeatAgeSecs),
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Read the last `bytes` of a file without loading the whole log (delivery.log can
|
|
46
|
+
* be MBs before rotation). Missing/unreadable file → ''. */
|
|
47
|
+
export function readTail(path: string, bytes = 64 * 1024): string {
|
|
48
|
+
try {
|
|
49
|
+
const size = statSync(path).size
|
|
50
|
+
const fd = openSync(path, 'r')
|
|
51
|
+
try {
|
|
52
|
+
const start = Math.max(0, size - bytes)
|
|
53
|
+
const buf = Buffer.alloc(size - start)
|
|
54
|
+
readSync(fd, buf, 0, buf.length, start)
|
|
55
|
+
// drop the leading partial line when we started mid-file
|
|
56
|
+
const s = buf.toString('utf8')
|
|
57
|
+
return start > 0 ? s.slice(s.indexOf('\n') + 1) : s
|
|
58
|
+
} finally {
|
|
59
|
+
closeSync(fd)
|
|
60
|
+
}
|
|
61
|
+
} catch {
|
|
62
|
+
return ''
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The per-peer log panel content: newest `limit` events concerning the peer from
|
|
67
|
+
* delivery.log + lifecycle.log (merged by timestamp). */
|
|
68
|
+
export function takePeerLog(env: NodeJS.ProcessEnv, personality: string, limit: number): Array<{ text: string; tone: 'ok' | 'fail' | 'info' }> {
|
|
69
|
+
const cfg = loadLifecycleConfig(env)
|
|
70
|
+
const tails = [readTail(deliveryLogPath(cfg.eventLogDir)), readTail(lifecycleLogPath(cfg.eventLogDir))]
|
|
71
|
+
return assemblePeerLog(tails, personality, limit)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The pane-log path of a peer address — reserved for a future raw-view; unused by
|
|
75
|
+
* the prototype (raw pty bytes are an attach concern, not a log panel one). */
|
|
76
|
+
export function paneLogPath(env: NodeJS.ProcessEnv, address: string): string {
|
|
77
|
+
const cfg = loadLifecycleConfig(env)
|
|
78
|
+
return join(cfg.logDir, `${address}.log`)
|
|
79
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// Dashboard MODEL — the pure logic of the management dashboard (Фаза 3 «TUI-редизайн
|
|
2
|
+
// management»): row filtering, activity-age formatting, event-log line parsing and
|
|
3
|
+
// per-peer log assembly. NO Ink, NO I/O — unit-testable in isolation (the same
|
|
4
|
+
// pure-core discipline as listTui.ts / the onboard wizard state machine).
|
|
5
|
+
//
|
|
6
|
+
// The VIEW (app.tsx) renders this; the DATA layer (data.ts) feeds it; run.tsx owns
|
|
7
|
+
// the terminal. Keeping the model pure is what lets the dashboard stay testable
|
|
8
|
+
// under `bun test` with zero terminal plumbing.
|
|
9
|
+
|
|
10
|
+
import type { PeerListing } from '../../cli/index.ts'
|
|
11
|
+
|
|
12
|
+
// ─── row filtering ───────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
/** Rows matching the filter (case-insensitive substring over personality/description). */
|
|
15
|
+
export function filterRows(rows: PeerListing[], filter: string): PeerListing[] {
|
|
16
|
+
const f = filter.trim().toLowerCase()
|
|
17
|
+
if (!f) return rows
|
|
18
|
+
return rows.filter(r => r.personality.toLowerCase().includes(f) || r.description.toLowerCase().includes(f))
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Clamp the cursor into the filtered row set (rows shrink under a live filter). */
|
|
22
|
+
export function clampCursor(cursor: number, visibleCount: number): number {
|
|
23
|
+
if (visibleCount <= 0) return 0
|
|
24
|
+
return Math.min(Math.max(0, cursor), visibleCount - 1)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The scroll window [start, end) of `height` rows keeping `cursor` visible. */
|
|
28
|
+
export function scrollWindow(cursor: number, total: number, height: number): { start: number; end: number } {
|
|
29
|
+
if (total <= height) return { start: 0, end: total }
|
|
30
|
+
const start = Math.min(Math.max(0, cursor - Math.floor(height / 2)), total - height)
|
|
31
|
+
return { start, end: start + height }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ─── activity age ────────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
/** Compact k9s/CC-style age: 12s · 4m · 3h · 2d; '—' when unknown. */
|
|
37
|
+
export function formatAge(mtimeMs: number | undefined, nowMs: number): string {
|
|
38
|
+
if (mtimeMs === undefined || mtimeMs <= 0) return '—'
|
|
39
|
+
const s = Math.max(0, Math.floor((nowMs - mtimeMs) / 1000))
|
|
40
|
+
if (s < 60) return `${s}s`
|
|
41
|
+
const m = Math.floor(s / 60)
|
|
42
|
+
if (m < 60) return `${m}m`
|
|
43
|
+
const h = Math.floor(m / 60)
|
|
44
|
+
if (h < 48) return `${h}h`
|
|
45
|
+
return `${Math.floor(h / 24)}d`
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ─── event-log parsing (ts=… ev=… k=v lines from delivery.log / lifecycle.log) ──
|
|
49
|
+
|
|
50
|
+
export interface LogEvent {
|
|
51
|
+
tsMs: number
|
|
52
|
+
/** HH:MM:SS local time for display. */
|
|
53
|
+
time: string
|
|
54
|
+
ev: string
|
|
55
|
+
fields: Record<string, string>
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Parse one `ts=<iso> ev=<kind> k=v …` line; null for anything else (corrupt tail). */
|
|
59
|
+
export function parseEventLine(line: string): LogEvent | null {
|
|
60
|
+
if (!line.startsWith('ts=')) return null
|
|
61
|
+
const fields: Record<string, string> = {}
|
|
62
|
+
// k=v tokens; values are token-atomic in our append format (no embedded spaces).
|
|
63
|
+
for (const tok of line.split(' ')) {
|
|
64
|
+
const i = tok.indexOf('=')
|
|
65
|
+
if (i > 0) fields[tok.slice(0, i)] = tok.slice(i + 1)
|
|
66
|
+
}
|
|
67
|
+
const ts = fields.ts
|
|
68
|
+
const ev = fields.ev
|
|
69
|
+
if (!ts || !ev) return null
|
|
70
|
+
const tsMs = Date.parse(ts)
|
|
71
|
+
if (!Number.isFinite(tsMs)) return null
|
|
72
|
+
const d = new Date(tsMs)
|
|
73
|
+
const pad = (n: number): string => String(n).padStart(2, '0')
|
|
74
|
+
return { tsMs, time: `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`, ev, fields }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Does this event concern the peer? Matches bare personality and any `<rt>-<personality>`
|
|
78
|
+
* identity in the fields the two logs actually carry (to/caller/via/personality/identity). */
|
|
79
|
+
export function eventConcernsPeer(e: LogEvent, personality: string): boolean {
|
|
80
|
+
const idSuffix = `-${personality}`
|
|
81
|
+
for (const key of ['to', 'caller', 'via', 'personality', 'identity', 'from', 'peer'] as const) {
|
|
82
|
+
const v = e.fields[key]
|
|
83
|
+
if (!v) continue
|
|
84
|
+
if (v === personality || v.endsWith(idSuffix)) return true
|
|
85
|
+
}
|
|
86
|
+
return false
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** One display line per event — compact, the interesting fields only. */
|
|
90
|
+
export function formatEvent(e: LogEvent): { text: string; tone: 'ok' | 'fail' | 'info' } {
|
|
91
|
+
const f = e.fields
|
|
92
|
+
if (e.ev === 'delivery') {
|
|
93
|
+
const ok = f.ok === 'true'
|
|
94
|
+
return {
|
|
95
|
+
text: `${e.time} delivery ${f.caller ?? '?'} → ${f.to ?? '?'} ${ok ? 'ok' : 'FAIL'}${f.ms ? ` ${f.ms}ms` : ''}${f.woke === 'true' ? ' (woke)' : ''}`,
|
|
96
|
+
tone: ok ? 'ok' : 'fail',
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
const rest = Object.entries(f)
|
|
100
|
+
.filter(([k]) => k !== 'ts' && k !== 'ev')
|
|
101
|
+
.map(([k, v]) => `${k}=${v}`)
|
|
102
|
+
.join(' ')
|
|
103
|
+
return { text: `${e.time} ${e.ev} ${rest}`.trimEnd(), tone: e.ev.includes('error') || e.ev.includes('fail') ? 'fail' : 'info' }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Assemble the per-peer log panel: parse both raw tails, filter to the peer, merge
|
|
107
|
+
* by timestamp, keep the newest `limit`. */
|
|
108
|
+
export function assemblePeerLog(rawTails: string[], personality: string, limit: number): Array<{ text: string; tone: 'ok' | 'fail' | 'info' }> {
|
|
109
|
+
const events: LogEvent[] = []
|
|
110
|
+
for (const raw of rawTails) {
|
|
111
|
+
for (const line of raw.split('\n')) {
|
|
112
|
+
const e = parseEventLine(line)
|
|
113
|
+
if (e && eventConcernsPeer(e, personality)) events.push(e)
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
events.sort((a, b) => a.tsMs - b.tsMs)
|
|
117
|
+
return events.slice(-limit).map(formatEvent)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ─── ellipsis ────────────────────────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
/** Truncate to `width` code points with a … tail (multi-byte-safe via the spread). */
|
|
123
|
+
export function ellipsize(s: string, width: number): string {
|
|
124
|
+
if (width <= 0) return ''
|
|
125
|
+
const chars = [...s]
|
|
126
|
+
return chars.length <= width ? s : chars.slice(0, Math.max(0, width - 1)).join('') + '…'
|
|
127
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// runDashboard — the Ink entry point + terminal owner of the management dashboard
|
|
2
|
+
// (tty form of `iapeer list`). Owns EVERYTHING terminal-shaped so the app stays pure:
|
|
3
|
+
//
|
|
4
|
+
// - alt-screen: the dashboard is a full-screen live surface; it renders on the
|
|
5
|
+
// alternate screen so quitting restores the operator's scrollback untouched
|
|
6
|
+
// (Apple Terminal keeps no E3, so repeated in-scrollback repaints would pile up —
|
|
7
|
+
// the alt-screen sidesteps that class entirely).
|
|
8
|
+
// - интерактив только в реальном TTY (контракт фазы): stdin+stdout must both be
|
|
9
|
+
// TTYs, else the caller falls back to the scriptable table (never render + hang;
|
|
10
|
+
// the same fail-closed belt as the onboard wizard).
|
|
11
|
+
// - TUI↔TUI handoff = suspend-and-spawn (решение фазы, Требование-1): on attach the
|
|
12
|
+
// dashboard fully unmounts + leaves the alt-screen + returns the terminal to
|
|
13
|
+
// cooked mode, then spawns `iapeer attach <peer>` as a CHILD with inherited stdio
|
|
14
|
+
// (the runtime TUI gets the REAL tty — the readline-class constraint), waits, and
|
|
15
|
+
// remounts. One full-screen TUI owns the terminal at any moment.
|
|
16
|
+
//
|
|
17
|
+
// Self-invocation uses the same `$bunfs` discriminator as supervisor's
|
|
18
|
+
// resolveDaemonSelfArgv: compiled standalone → re-invoke the binary by verb; running
|
|
19
|
+
// from src → `bun <cli/index.ts> attach …`.
|
|
20
|
+
|
|
21
|
+
import React from 'react'
|
|
22
|
+
import { render } from 'ink'
|
|
23
|
+
import { spawnSync } from 'node:child_process'
|
|
24
|
+
import { fileURLToPath } from 'node:url'
|
|
25
|
+
import { DashboardApp, type DashAction } from './app.tsx'
|
|
26
|
+
|
|
27
|
+
/** Sentinel: no real interactive TTY — the caller should print the scriptable table. */
|
|
28
|
+
export const DASHBOARD_NOT_INTERACTIVE = -1
|
|
29
|
+
|
|
30
|
+
const ALT_SCREEN_ON = '\x1b[?1049h\x1b[H'
|
|
31
|
+
const ALT_SCREEN_OFF = '\x1b[?1049l'
|
|
32
|
+
|
|
33
|
+
/** argv prefix that re-invokes THIS package's CLI (compiled binary by verb / src via
|
|
34
|
+
* bun). Same discriminator as supervisor/index.ts resolveDaemonSelfArgv: `$bunfs` in
|
|
35
|
+
* the module path marks the compiled standalone (existsSync is NO discriminator —
|
|
36
|
+
* Bun intercepts bunfs reads). */
|
|
37
|
+
export function resolveSelfCliArgv(execPath: string, selfPath: string): string[] {
|
|
38
|
+
return selfPath.includes('$bunfs') ? [execPath] : [execPath, selfPath]
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function selfCliArgv(): string[] {
|
|
42
|
+
return resolveSelfCliArgv(process.execPath, fileURLToPath(new URL('../../cli/index.ts', import.meta.url)))
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface DashboardOptions {
|
|
46
|
+
env?: NodeJS.ProcessEnv
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Mount the dashboard once; resolve with the action that ended it. */
|
|
50
|
+
async function mountOnce(env: NodeJS.ProcessEnv): Promise<DashAction> {
|
|
51
|
+
let action: DashAction = { type: 'quit' }
|
|
52
|
+
process.stdout.write(ALT_SCREEN_ON)
|
|
53
|
+
try {
|
|
54
|
+
const app = render(<DashboardApp env={env} onAction={a => (action = a)} />, {
|
|
55
|
+
// the dashboard repaints in place on the alt screen; Ctrl-C is handled by the
|
|
56
|
+
// app itself (quit action) so a half-torn frame never leaks to the main screen
|
|
57
|
+
exitOnCtrlC: false,
|
|
58
|
+
})
|
|
59
|
+
await app.waitUntilExit()
|
|
60
|
+
} finally {
|
|
61
|
+
process.stdout.write(ALT_SCREEN_OFF)
|
|
62
|
+
}
|
|
63
|
+
return action
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Run the live management dashboard. Loops mount → (attach child) → remount until
|
|
68
|
+
* quit. Returns the process exit code, or DASHBOARD_NOT_INTERACTIVE when there is no
|
|
69
|
+
* real TTY to drive it (caller falls back to the scriptable table).
|
|
70
|
+
*/
|
|
71
|
+
export async function runDashboard(opts: DashboardOptions = {}): Promise<number> {
|
|
72
|
+
const env = opts.env ?? process.env
|
|
73
|
+
if (process.stdin.isTTY !== true || process.stdout.isTTY !== true) return DASHBOARD_NOT_INTERACTIVE
|
|
74
|
+
for (;;) {
|
|
75
|
+
let action: DashAction
|
|
76
|
+
try {
|
|
77
|
+
action = await mountOnce(env)
|
|
78
|
+
} catch {
|
|
79
|
+
// raw-mode unsupported / render failed → let the caller fall back, never hang
|
|
80
|
+
return DASHBOARD_NOT_INTERACTIVE
|
|
81
|
+
}
|
|
82
|
+
if (action.type === 'quit') return 0
|
|
83
|
+
// suspend-and-spawn: the child owns the real TTY until the operator detaches
|
|
84
|
+
// (Ctrl-]) or the session ends; then the dashboard remounts with fresh state.
|
|
85
|
+
const argv = selfCliArgv()
|
|
86
|
+
const r = spawnSync(argv[0]!, [...argv.slice(1), 'attach', action.personality], {
|
|
87
|
+
stdio: 'inherit',
|
|
88
|
+
env,
|
|
89
|
+
})
|
|
90
|
+
if (r.error) {
|
|
91
|
+
process.stdout.write(`attach failed: ${r.error.message}\npress Enter to continue…`)
|
|
92
|
+
// swallow one line so the operator sees the error before the alt-screen returns
|
|
93
|
+
spawnSync('sh', ['-c', 'read _'], { stdio: 'inherit' })
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|