Skip to content

Commit adcc2e9

Browse files
committed
Overhaul monitor TUI with richer data and actions
- Consolidate poller into one sectioned SSH command with per-section fallbacks and a 3x-interval timeout - Add health probe latency, richer PM2 details (exec mode, instances, node version, unstable restarts, exit code), accessories panel, and live Caddy request stats for frontend apps - Normalize CPU gauge by core count; share Gauge/Sparkline components with threshold colors - Add fullscreen logs with per-process filter and 500-line scrollback - Add process selection, confirmed pm2 restart (blocked during deploy lock), help overlay, and deploy-lock header chip
1 parent 69aadf1 commit adcc2e9

25 files changed

Lines changed: 1499 additions & 312 deletions

src/cli/commands/monitor.ts

Lines changed: 9 additions & 1 deletion
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';
33
import { runMonitor } from '../monitor/index.js';
4-
import { getAppsForMonitorTarget, resolveMonitorSession } from '../monitor/monitor-session.js';
4+
import { getAccessoriesForMonitorTarget, getAppsForMonitorTarget, resolveMonitorSession } from '../monitor/monitor-session.js';
55
import { ui } from '../ui.js';
66

77
export async function cmdMonitor(
@@ -24,6 +24,13 @@ export async function cmdMonitor(
2424
return;
2525
}
2626

27+
const accessoryNames = getAccessoriesForMonitorTarget(config, session.value.target.name);
28+
if (accessoryNames.isErr()) {
29+
ui.error(accessoryNames.error.message);
30+
process.exit(1);
31+
return;
32+
}
33+
2734
const host = `${session.value.target.ssh.user}@${session.value.target.ssh.host}:${session.value.target.ssh.port}`;
2835
const ssh = new SshConnection();
2936

@@ -42,6 +49,7 @@ export async function cmdMonitor(
4249
config,
4350
app: session.value.app,
4451
apps: apps.value,
52+
accessoryNames: accessoryNames.value,
4553
targetName: session.value.target.name,
4654
host,
4755
interval,

src/cli/monitor/App.tsx

Lines changed: 160 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Box, useInput, useApp } from 'ink';
1+
import { Box, useInput, useApp, useStdout } from 'ink';
22
import { useState } from 'react';
33
import type { RemoteExecutor } from '../../domain/remote/executor.js';
44
import type { ShipnodeConfig, ShipnodeApp } from '../../shared/types.js';
@@ -8,36 +8,121 @@ import { ReleasePanel } from './panels/ReleasePanel.js';
88
import { LogPanel } from './panels/LogPanel.js';
99
import { EventsPanel } from './panels/EventsPanel.js';
1010
import { StaticFrontendPanel } from './panels/StaticFrontendPanel.js';
11+
import { AccessoriesPanel } from './panels/AccessoriesPanel.js';
1112
import { AppSelector } from './app-selector.js';
13+
import { HelpOverlay } from './components/HelpOverlay.js';
14+
import { ConfirmDialog } from './components/ConfirmDialog.js';
15+
import { restartProcess } from './actions.js';
1216
import chalk from 'chalk';
1317
import { useMonitorData } from './hooks/use-monitor-data.js';
1418
import { useLiveLogs } from './hooks/use-live-logs.js';
1519
import { MonitorFrame, WaitingPanel } from './layout/MonitorFrame.js';
1620

21+
type View = 'dashboard' | 'logs';
22+
type Overlay = 'none' | 'selector' | 'help' | 'confirmRestart';
23+
1724
interface AppProps {
1825
executor: RemoteExecutor;
1926
config: ShipnodeConfig;
2027
app: ShipnodeApp;
2128
apps: ShipnodeApp[];
29+
accessoryNames: string[];
2230
targetName: string;
2331
host: string;
2432
interval: number;
2533
}
2634

27-
export function App({ executor, config, app: initialApp, apps, targetName, host, interval }: AppProps) {
35+
export function App({ executor, config, app: initialApp, apps, accessoryNames, targetName, host, interval }: AppProps) {
2836
const { exit } = useApp();
37+
const { stdout } = useStdout();
2938
const [currentApp, setCurrentApp] = useState(initialApp);
39+
const [view, setView] = useState<View>('dashboard');
40+
const [overlay, setOverlay] = useState<Overlay>('none');
3041
const [liveMode, setLiveMode] = useState(false);
31-
const [showSelector, setShowSelector] = useState(false);
32-
const monitor = useMonitorData(executor, config, currentApp, interval);
33-
const liveLogs = useLiveLogs(executor, currentApp, liveMode, interval);
42+
const [logFilter, setLogFilter] = useState<string | null>(null);
43+
const [selectedProcess, setSelectedProcess] = useState(0);
44+
const monitor = useMonitorData(executor, config, currentApp, interval, accessoryNames);
45+
const liveActive = liveMode || view === 'logs';
46+
const liveLogs = useLiveLogs(executor, currentApp, liveActive, interval, logFilter);
47+
48+
const processes = monitor.snapshot?.processes ?? [];
49+
const selectedIndex = processes.length === 0 ? 0 : Math.min(selectedProcess, processes.length - 1);
50+
const selectedInfo = processes[selectedIndex];
51+
52+
const confirmRestart = async (): Promise<void> => {
53+
setOverlay('none');
54+
if (selectedInfo === undefined) return;
55+
monitor.appendEvent(chalk.yellow(`Restarting ${selectedInfo.pm2Name}…`));
56+
const result = await restartProcess(executor, selectedInfo.pm2Name);
57+
if (result.isOk()) {
58+
monitor.appendEvent(chalk.green(`Restarted ${chalk.bold(selectedInfo.pm2Name)}`));
59+
} else {
60+
monitor.appendEvent(chalk.red(result.error.message));
61+
}
62+
void monitor.refresh();
63+
};
64+
65+
const cycleLogFilter = (direction: 1 | -1): void => {
66+
if (currentApp.appType === 'frontend') return;
67+
const names = monitor.snapshot?.processes.map((p) => p.pm2Name) ?? [];
68+
if (names.length === 0) return;
69+
const cycle: Array<string | null> = [null, ...names];
70+
const index = cycle.indexOf(logFilter);
71+
const next = cycle[(index + direction + cycle.length) % cycle.length];
72+
setLogFilter(next);
73+
};
3474

3575
useInput((input, key) => {
76+
if (overlay === 'help') {
77+
setOverlay('none');
78+
return;
79+
}
80+
if (overlay !== 'none') return;
81+
3682
if (input === 'q') {
3783
exit();
84+
return;
85+
}
86+
if (input === '?') {
87+
setOverlay('help');
88+
return;
89+
}
90+
if (input === 'r' || input === 'R') {
91+
void monitor.refresh();
92+
monitor.appendEvent(chalk.green('Refresh triggered'));
93+
return;
94+
}
95+
if (input === 'f' || input === 'F') {
96+
setView((v) => (v === 'logs' ? 'dashboard' : 'logs'));
97+
return;
98+
}
99+
100+
if (view === 'logs') {
101+
if (key.escape) setView('dashboard');
102+
if (key.leftArrow) cycleLogFilter(-1);
103+
if (key.rightArrow) cycleLogFilter(1);
104+
return;
38105
}
106+
39107
if (key.tab) {
40-
setShowSelector(true);
108+
setOverlay('selector');
109+
return;
110+
}
111+
if (key.upArrow) {
112+
setSelectedProcess(Math.max(0, selectedIndex - 1));
113+
return;
114+
}
115+
if (key.downArrow) {
116+
setSelectedProcess(Math.min(Math.max(processes.length - 1, 0), selectedIndex + 1));
117+
return;
118+
}
119+
if ((key.return || input === 'x' || input === 'X') && selectedInfo !== undefined) {
120+
if (monitor.snapshot?.deployLock != null) {
121+
monitor.appendEvent(chalk.red('Restart blocked: a deploy is in progress (lock held)'));
122+
return;
123+
}
124+
setOverlay('confirmRestart');
125+
return;
41126
}
42127
if (input === 'l' || input === 'L') {
43128
setLiveMode((v) => {
@@ -51,37 +136,79 @@ export function App({ executor, config, app: initialApp, apps, targetName, host,
51136
return next;
52137
});
53138
}
54-
if (input === 'r' || input === 'R') {
55-
void monitor.refresh();
56-
monitor.appendEvent(chalk.green('Refresh triggered'));
57-
}
58139
});
59140

60-
if (showSelector) {
141+
if (overlay === 'help') {
142+
return <HelpOverlay />;
143+
}
144+
145+
if (overlay === 'confirmRestart' && selectedInfo !== undefined) {
146+
const dropsRequests = selectedInfo.execMode !== 'cluster' || selectedInfo.instances <= 1;
147+
return (
148+
<ConfirmDialog
149+
title={`Restart ${selectedInfo.pm2Name}?`}
150+
lines={dropsRequests ? ['Single-instance process — restart drops in-flight requests.'] : []}
151+
onConfirm={() => {
152+
void confirmRestart();
153+
}}
154+
onCancel={() => setOverlay('none')}
155+
/>
156+
);
157+
}
158+
159+
if (overlay === 'selector') {
61160
return (
62161
<AppSelector
63162
apps={apps}
64163
targetName={targetName}
65164
onSelect={(app) => {
66165
setCurrentApp(app);
67-
setShowSelector(false);
166+
setOverlay('none');
167+
setView('dashboard');
168+
setLogFilter(null);
169+
setSelectedProcess(0);
68170
monitor.reset();
69171
liveLogs.clearLogs();
70172
setLiveMode(false);
71173
monitor.appendEvent(chalk.green(`Switched to ${chalk.bold(app.name)}`));
72174
}}
73-
onCancel={() => setShowSelector(false)}
175+
onCancel={() => setOverlay('none')}
74176
/>
75177
);
76178
}
77179

180+
if (view === 'logs') {
181+
const rows = stdout?.rows ?? 24;
182+
const title =
183+
currentApp.appType === 'frontend'
184+
? `Caddy Access Log — ${currentApp.name}`
185+
: `Live Logs — ${logFilter ?? 'all processes'} (←/→ filter, F back)`;
186+
return (
187+
<MonitorFrame
188+
app={currentApp}
189+
targetName={targetName}
190+
host={host}
191+
interval={interval}
192+
liveMode={liveActive}
193+
lastUpdate={monitor.lastUpdate}
194+
snapshot={monitor.snapshot}
195+
polling={monitor.polling}
196+
error={monitor.error}
197+
>
198+
<Box flexGrow={1}>
199+
<LogPanel logBuffer={liveLogs.logBuffer} maxLines={Math.max(10, rows - 6)} title={title} />
200+
</Box>
201+
</MonitorFrame>
202+
);
203+
}
204+
78205
return (
79206
<MonitorFrame
80207
app={currentApp}
81208
targetName={targetName}
82209
host={host}
83210
interval={interval}
84-
liveMode={liveMode}
211+
liveMode={liveActive}
85212
lastUpdate={monitor.lastUpdate}
86213
snapshot={monitor.snapshot}
87214
polling={monitor.polling}
@@ -90,35 +217,47 @@ export function App({ executor, config, app: initialApp, apps, targetName, host,
90217
<Box flexGrow={1} flexDirection="row" minHeight={8}>
91218
<Box width="60%" flexDirection="column">
92219
{currentApp.appType === 'frontend' ? (
93-
<StaticFrontendPanel app={currentApp} />
220+
<StaticFrontendPanel app={currentApp} caddy={monitor.snapshot?.caddy ?? null} />
94221
) : monitor.snapshot ? (
95222
<Pm2Panel
96223
processes={monitor.snapshot.processes}
97224
cpuHistory={monitor.history.cpu}
98225
memHistory={monitor.history.memory}
226+
health={monitor.snapshot.health}
227+
responseHistory={monitor.history.responseMs}
228+
selectedIndex={selectedIndex}
99229
/>
100230
) : (
101231
<WaitingPanel />
102232
)}
103233
</Box>
104234
<Box width="40%" flexDirection="column">
105235
{monitor.snapshot ? (
106-
<SystemPanel
107-
system={monitor.snapshot.system}
108-
cpuHistory={monitor.history.cpu}
109-
memHistory={monitor.history.memory}
110-
/>
236+
<>
237+
<SystemPanel
238+
system={monitor.snapshot.system}
239+
cpuHistory={monitor.history.cpu}
240+
memHistory={monitor.history.memory}
241+
/>
242+
{accessoryNames.length > 0 && (
243+
<AccessoriesPanel
244+
configuredNames={accessoryNames}
245+
accessories={monitor.snapshot.accessories}
246+
/>
247+
)}
248+
</>
111249
) : (
112250
<WaitingPanel />
113251
)}
114252
</Box>
115253
</Box>
116254

117255
{monitor.snapshot && (
118-
<Box height={7}>
256+
<Box height={accessoryNames.length > 0 ? 6 : 7}>
119257
<ReleasePanel
120258
currentRelease={monitor.snapshot.currentRelease}
121259
releases={monitor.snapshot.releases}
260+
maxReleases={accessoryNames.length > 0 ? 4 : 5}
122261
/>
123262
</Box>
124263
)}

src/cli/monitor/actions.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { Result, type Result as ResultType } from 'better-result';
2+
import type { RemoteExecutor } from '../../domain/remote/executor.js';
3+
import { ProcessRestartError } from '../../shared/result-errors.js';
4+
import { MISE, shellQuote } from './poller.js';
5+
6+
/**
7+
* Restart exactly one PM2 process by its full pm2 name — never a bare
8+
* namespace, so a single keypress cannot bounce a whole deployment.
9+
*/
10+
export async function restartProcess(
11+
executor: RemoteExecutor,
12+
pm2Name: string,
13+
): Promise<ResultType<void, ProcessRestartError>> {
14+
const result = await executor.exec(
15+
`${MISE} && pm2 restart ${shellQuote(pm2Name)} --update-env`,
16+
);
17+
if (result.exitCode !== 0) {
18+
return Result.err(new ProcessRestartError({
19+
pm2Name,
20+
detail: (result.stderr || result.stdout).trim() || `exit code ${result.exitCode}`,
21+
}));
22+
}
23+
return Result.ok(undefined);
24+
}

0 commit comments

Comments
 (0)