Skip to content

Commit 69aadf1

Browse files
committed
Refine monitor TUI layout and targeting
1 parent 1cc6d5b commit 69aadf1

16 files changed

Lines changed: 550 additions & 194 deletions

src/cli/commands/monitor.ts

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { loadConfig } from '../../config/loader.js';
22
import { SshConnection } from '../../infrastructure/ssh/connection.js';
3-
import { getActiveApp } from '../../domain/workspace.js';
43
import { runMonitor } from '../monitor/index.js';
4+
import { getAppsForMonitorTarget, resolveMonitorSession } from '../monitor/monitor-session.js';
55
import { ui } from '../ui.js';
66

77
export async function cmdMonitor(
@@ -10,29 +10,40 @@ export async function cmdMonitor(
1010
): Promise<void> {
1111
const interval = Math.max(1, parseInt(options.interval ?? '2', 10) || 2);
1212
const config = await loadConfig(cwd, options.config);
13-
const app = options.app ? getActiveApp(config, options.app) : config.apps[0];
13+
const session = resolveMonitorSession(config, options.app);
14+
if (session.isErr()) {
15+
ui.error(session.error.message);
16+
process.exit(1);
17+
return;
18+
}
1419

15-
if (!app) {
16-
ui.error('No app configured in shipnode.config.ts');
20+
const apps = getAppsForMonitorTarget(config, session.value.target.name);
21+
if (apps.isErr()) {
22+
ui.error(apps.error.message);
1723
process.exit(1);
24+
return;
1825
}
1926

20-
const host = `${config.ssh.user}@${config.ssh.host}:${config.ssh.port}`;
27+
const host = `${session.value.target.ssh.user}@${session.value.target.ssh.host}:${session.value.target.ssh.port}`;
2128
const ssh = new SshConnection();
2229

2330
try {
24-
await ssh.connect(config.ssh);
31+
await ssh.connect(session.value.target.ssh);
2532
} catch (err) {
2633
const msg = err instanceof Error ? err.message : String(err);
2734
ui.error(`Failed to connect to ${host}: ${msg}`);
2835
process.exit(1);
36+
return;
2937
}
3038

3139
try {
3240
await runMonitor({
3341
executor: ssh,
3442
config,
35-
app,
43+
app: session.value.app,
44+
apps: apps.value,
45+
targetName: session.value.target.name,
46+
host,
3647
interval,
3748
});
3849
} finally {

src/cli/monitor/App.tsx

Lines changed: 52 additions & 155 deletions
Original file line numberDiff line numberDiff line change
@@ -1,89 +1,36 @@
1-
import { Box, Text, useInput, useApp } from 'ink';
2-
import { useEffect, useState, useRef } from 'react';
1+
import { Box, useInput, useApp } from 'ink';
2+
import { useState } from 'react';
33
import type { RemoteExecutor } from '../../domain/remote/executor.js';
44
import type { ShipnodeConfig, ShipnodeApp } from '../../shared/types.js';
5-
import { MetricsHistory, type MetricsSnapshot } from './state.js';
6-
import { collectMetrics, collectLogs } from './poller.js';
75
import { Pm2Panel } from './panels/Pm2Panel.js';
86
import { SystemPanel } from './panels/SystemPanel.js';
97
import { ReleasePanel } from './panels/ReleasePanel.js';
108
import { LogPanel } from './panels/LogPanel.js';
9+
import { EventsPanel } from './panels/EventsPanel.js';
10+
import { StaticFrontendPanel } from './panels/StaticFrontendPanel.js';
1111
import { AppSelector } from './app-selector.js';
1212
import chalk from 'chalk';
13-
14-
const ACCENT = '#d6a85d';
15-
const BG = '#0d1117';
16-
const BG_HEADER = '#161b22';
17-
const BORDER = '#30363d';
13+
import { useMonitorData } from './hooks/use-monitor-data.js';
14+
import { useLiveLogs } from './hooks/use-live-logs.js';
15+
import { MonitorFrame, WaitingPanel } from './layout/MonitorFrame.js';
1816

1917
interface AppProps {
2018
executor: RemoteExecutor;
2119
config: ShipnodeConfig;
2220
app: ShipnodeApp;
21+
apps: ShipnodeApp[];
22+
targetName: string;
23+
host: string;
2324
interval: number;
2425
}
2526

26-
export function App({ executor, config, app: initialApp, interval }: AppProps) {
27+
export function App({ executor, config, app: initialApp, apps, targetName, host, interval }: AppProps) {
2728
const { exit } = useApp();
2829
const [currentApp, setCurrentApp] = useState(initialApp);
29-
const [snapshot, setSnapshot] = useState<MetricsSnapshot | null>(null);
30-
const historyRef = useRef(new MetricsHistory());
3130
const [liveMode, setLiveMode] = useState(false);
32-
const [logBuffer, setLogBuffer] = useState('');
33-
const [events, setEvents] = useState<string[]>([]);
3431
const [showSelector, setShowSelector] = useState(false);
35-
const [error, setError] = useState<string | null>(null);
36-
const [lastUpdate, setLastUpdate] = useState<string>('');
37-
38-
const history = historyRef.current;
39-
40-
const appendEvent = (msg: string) => {
41-
setEvents((prev) => [...prev.slice(-100), msg]);
42-
};
43-
44-
const doPoll = async () => {
45-
try {
46-
const snap = await collectMetrics(executor, currentApp, config);
47-
setSnapshot(snap);
48-
history.push(snap);
49-
setLastUpdate(new Date().toLocaleTimeString());
50-
setError(null);
51-
} catch (err) {
52-
const msg = err instanceof Error ? err.message : String(err);
53-
setError(msg);
54-
appendEvent(chalk.red(`Poll failed: ${msg}`));
55-
}
56-
};
57-
58-
const doLogPoll = async () => {
59-
try {
60-
const namespace = currentApp.pm2?.apps[0]?.name ?? '';
61-
if (!namespace) return;
62-
const freshLogs = await collectLogs(executor, namespace, 20);
63-
if (freshLogs && freshLogs !== '(no logs)') {
64-
setLogBuffer((prev) => {
65-
const combined = prev
66-
? prev.split('\n').slice(-50).join('\n') + '\n' + freshLogs
67-
: freshLogs;
68-
return combined;
69-
});
70-
}
71-
} catch {
72-
// best-effort
73-
}
74-
};
75-
76-
useEffect(() => {
77-
doPoll();
78-
const timer = setInterval(doPoll, interval * 1000);
79-
return () => clearInterval(timer);
80-
}, [currentApp, interval]);
81-
82-
useEffect(() => {
83-
if (!liveMode) return;
84-
const timer = setInterval(doLogPoll, interval * 1000);
85-
return () => clearInterval(timer);
86-
}, [liveMode, currentApp, interval]);
32+
const monitor = useMonitorData(executor, config, currentApp, interval);
33+
const liveLogs = useLiveLogs(executor, currentApp, liveMode, interval);
8734

8835
useInput((input, key) => {
8936
if (input === 'q') {
@@ -96,143 +43,93 @@ export function App({ executor, config, app: initialApp, interval }: AppProps) {
9643
setLiveMode((v) => {
9744
const next = !v;
9845
if (next) {
99-
appendEvent(chalk.yellow('Live logs enabled'));
46+
monitor.appendEvent(chalk.yellow('Live logs enabled'));
10047
} else {
101-
setLogBuffer('');
102-
appendEvent(chalk.yellow('Live logs disabled'));
48+
liveLogs.clearLogs();
49+
monitor.appendEvent(chalk.yellow('Live logs disabled'));
10350
}
10451
return next;
10552
});
10653
}
10754
if (input === 'r' || input === 'R') {
108-
doPoll();
109-
appendEvent(chalk.green('Refresh triggered'));
55+
void monitor.refresh();
56+
monitor.appendEvent(chalk.green('Refresh triggered'));
11057
}
11158
});
11259

11360
if (showSelector) {
11461
return (
11562
<AppSelector
116-
apps={config.apps}
63+
apps={apps}
64+
targetName={targetName}
11765
onSelect={(app) => {
11866
setCurrentApp(app);
11967
setShowSelector(false);
120-
history.clear();
121-
setLogBuffer('');
68+
monitor.reset();
69+
liveLogs.clearLogs();
12270
setLiveMode(false);
123-
setSnapshot(null);
124-
appendEvent(chalk.green(`Switched to ${chalk.bold(app.name)}`));
71+
monitor.appendEvent(chalk.green(`Switched to ${chalk.bold(app.name)}`));
12572
}}
12673
onCancel={() => setShowSelector(false)}
12774
/>
12875
);
12976
}
13077

131-
const host = currentApp.domain ?? 'no domain';
132-
13378
return (
134-
<Box flexDirection="column" height="100%" backgroundColor={BG}>
135-
{/* Header */}
136-
<Box height={1} backgroundColor={BG_HEADER} paddingLeft={1} borderBottom={false}>
137-
<Text bold color={ACCENT}>
138-
ShipNode Monitor{' '}
139-
</Text>
140-
<Text>
141-
{currentApp.name} ({currentApp.appType}) —{' '}
142-
</Text>
143-
<Text dimColor>{host}</Text>
144-
<Text dimColor>
145-
{' │'} interval: {interval}s
146-
</Text>
147-
{liveMode ? (
148-
<Text>
149-
{' │'} logs: <Text color="green">ON</Text>
150-
</Text>
151-
) : (
152-
<Text dimColor> │ logs: off</Text>
153-
)}
154-
<Text dimColor>
155-
{' │'} [Q]uit [Tab]switch app [L]toggle logs [R]refresh
156-
</Text>
157-
</Box>
158-
159-
{/* Main content: PM2 + System */}
79+
<MonitorFrame
80+
app={currentApp}
81+
targetName={targetName}
82+
host={host}
83+
interval={interval}
84+
liveMode={liveMode}
85+
lastUpdate={monitor.lastUpdate}
86+
snapshot={monitor.snapshot}
87+
polling={monitor.polling}
88+
error={monitor.error}
89+
>
16090
<Box flexGrow={1} flexDirection="row" minHeight={8}>
16191
<Box width="60%" flexDirection="column">
162-
{snapshot ? (
92+
{currentApp.appType === 'frontend' ? (
93+
<StaticFrontendPanel app={currentApp} />
94+
) : monitor.snapshot ? (
16395
<Pm2Panel
164-
processes={snapshot.processes}
165-
cpuHistory={history.cpu}
166-
memHistory={history.memory}
96+
processes={monitor.snapshot.processes}
97+
cpuHistory={monitor.history.cpu}
98+
memHistory={monitor.history.memory}
16799
/>
168100
) : (
169-
<Box borderStyle="round" borderColor={BORDER} padding={1} flexGrow={1} backgroundColor={BG}>
170-
<Text dimColor>Waiting for data...</Text>
171-
</Box>
101+
<WaitingPanel />
172102
)}
173103
</Box>
174104
<Box width="40%" flexDirection="column">
175-
{snapshot ? (
105+
{monitor.snapshot ? (
176106
<SystemPanel
177-
system={snapshot.system}
178-
cpuHistory={history.cpu}
179-
memHistory={history.memory}
107+
system={monitor.snapshot.system}
108+
cpuHistory={monitor.history.cpu}
109+
memHistory={monitor.history.memory}
180110
/>
181111
) : (
182-
<Box borderStyle="round" borderColor={BORDER} padding={1} flexGrow={1} backgroundColor={BG}>
183-
<Text dimColor>Waiting for data...</Text>
184-
</Box>
112+
<WaitingPanel />
185113
)}
186114
</Box>
187115
</Box>
188116

189-
{/* Release panel */}
190-
{snapshot && (
117+
{monitor.snapshot && (
191118
<Box height={7}>
192119
<ReleasePanel
193-
currentRelease={snapshot.currentRelease}
194-
releases={snapshot.releases}
120+
currentRelease={monitor.snapshot.currentRelease}
121+
releases={monitor.snapshot.releases}
195122
/>
196123
</Box>
197124
)}
198125

199-
{/* Bottom panel: events or logs */}
200126
<Box height={8}>
201127
{liveMode ? (
202-
<LogPanel logBuffer={logBuffer} />
128+
<LogPanel logBuffer={liveLogs.logBuffer} />
203129
) : (
204-
<Box
205-
borderStyle="round"
206-
borderColor={BORDER}
207-
padding={1}
208-
flexDirection="column"
209-
flexGrow={1}
210-
backgroundColor={BG}
211-
>
212-
<Text bold color={ACCENT}>
213-
Events
214-
</Text>
215-
{events.length === 0 ? (
216-
<Text dimColor> No events yet. Press [L] to enable live logs.</Text>
217-
) : (
218-
events.map((e, i) => <Text key={i}>{e}</Text>)
219-
)}
220-
</Box>
221-
)}
222-
</Box>
223-
224-
{/* Status bar */}
225-
<Box height={1} backgroundColor={BG_HEADER}>
226-
<Text dimColor>
227-
{' '}Last update: {lastUpdate || '—'}{' │ '}
228-
{snapshot ? `${snapshot.processes.length} process(es)` : '—'}
229-
</Text>
230-
{error && (
231-
<Text color="red" dimColor>
232-
{' │ '}Error: {error}
233-
</Text>
130+
<EventsPanel events={monitor.events} />
234131
)}
235132
</Box>
236-
</Box>
133+
</MonitorFrame>
237134
);
238135
}

src/cli/monitor/app-selector.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,12 @@ const BG_SELECTED = '#1f2937';
88

99
interface AppSelectorProps {
1010
apps: ShipnodeApp[];
11+
targetName: string;
1112
onSelect: (app: ShipnodeApp) => void;
1213
onCancel: () => void;
1314
}
1415

15-
export function AppSelector({ apps, onSelect, onCancel }: AppSelectorProps) {
16+
export function AppSelector({ apps, targetName, onSelect, onCancel }: AppSelectorProps) {
1617
const [selected, setSelected] = useState(0);
1718

1819
useInput((input, key) => {
@@ -37,7 +38,7 @@ export function AppSelector({ apps, onSelect, onCancel }: AppSelectorProps) {
3738
alignItems="center"
3839
>
3940
<Text bold color="#d6a85d">
40-
Select App ({apps.length} configured)
41+
Select App ({apps.length} on {targetName})
4142
</Text>
4243
{apps.map((app, i) => {
4344
const typeLabel = app.appType === 'backend' ? 'backend' : 'frontend';

0 commit comments

Comments
 (0)