Skip to content

Commit 57f3347

Browse files
committed
Add rollback, log search/pause, stderr highlight, health alerts to monitor
1 parent adcc2e9 commit 57f3347

11 files changed

Lines changed: 477 additions & 38 deletions

File tree

src/cli/monitor/App.tsx

Lines changed: 137 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,14 @@ import { AccessoriesPanel } from './panels/AccessoriesPanel.js';
1212
import { AppSelector } from './app-selector.js';
1313
import { HelpOverlay } from './components/HelpOverlay.js';
1414
import { ConfirmDialog } from './components/ConfirmDialog.js';
15-
import { restartProcess } from './actions.js';
15+
import { restartProcess, rollbackToRelease } from './actions.js';
1616
import chalk from 'chalk';
17-
import { useMonitorData } from './hooks/use-monitor-data.js';
17+
import { useMonitorData, HEALTH_ALERT_THRESHOLD } from './hooks/use-monitor-data.js';
1818
import { useLiveLogs } from './hooks/use-live-logs.js';
1919
import { MonitorFrame, WaitingPanel } from './layout/MonitorFrame.js';
2020

2121
type View = 'dashboard' | 'logs';
22-
type Overlay = 'none' | 'selector' | 'help' | 'confirmRestart';
22+
type Overlay = 'none' | 'selector' | 'help' | 'confirmRestart' | 'confirmRollback';
2323

2424
interface AppProps {
2525
executor: RemoteExecutor;
@@ -40,14 +40,33 @@ export function App({ executor, config, app: initialApp, apps, accessoryNames, t
4040
const [overlay, setOverlay] = useState<Overlay>('none');
4141
const [liveMode, setLiveMode] = useState(false);
4242
const [logFilter, setLogFilter] = useState<string | null>(null);
43-
const [selectedProcess, setSelectedProcess] = useState(0);
43+
const [logSearch, setLogSearch] = useState('');
44+
const [searchTyping, setSearchTyping] = useState(false);
45+
const [logsPaused, setLogsPaused] = useState(false);
46+
const [selectedRow, setSelectedRow] = useState(0);
4447
const monitor = useMonitorData(executor, config, currentApp, interval, accessoryNames);
4548
const liveActive = liveMode || view === 'logs';
46-
const liveLogs = useLiveLogs(executor, currentApp, liveActive, interval, logFilter);
49+
const liveLogs = useLiveLogs(executor, currentApp, liveActive && !logsPaused, interval, logFilter);
4750

51+
// On the 24-row terminals this layout was tuned for, the releases box has
52+
// room for exactly one row; taller terminals get a few more so a rollback
53+
// selection made with ↑/↓ is actually visible before confirming.
54+
const terminalRows = stdout?.rows ?? 24;
55+
const releaseBoxExtra = Math.max(0, Math.min(6, terminalRows - 30));
56+
57+
// Selection runs over processes first, then the visible release rows, so
58+
// ↓ walks straight from the PM2 panel into the Releases panel.
4859
const processes = monitor.snapshot?.processes ?? [];
49-
const selectedIndex = processes.length === 0 ? 0 : Math.min(selectedProcess, processes.length - 1);
50-
const selectedInfo = processes[selectedIndex];
60+
const maxReleases = accessoryNames.length > 0 ? 4 : 5;
61+
const releaseRows = (monitor.snapshot?.releases ?? []).slice(0, maxReleases);
62+
const totalRows = processes.length + releaseRows.length;
63+
const selectedIndex = totalRows === 0 ? 0 : Math.min(selectedRow, totalRows - 1);
64+
const selectedInfo = selectedIndex < processes.length ? processes[selectedIndex] : undefined;
65+
const selectedRelease =
66+
selectedIndex >= processes.length ? releaseRows[selectedIndex - processes.length] : undefined;
67+
const currentTimestamp = monitor.snapshot?.currentRelease?.split('/').pop() ?? null;
68+
const alertStreak =
69+
monitor.healthFailStreak >= HEALTH_ALERT_THRESHOLD ? monitor.healthFailStreak : 0;
5170

5271
const confirmRestart = async (): Promise<void> => {
5372
setOverlay('none');
@@ -62,6 +81,26 @@ export function App({ executor, config, app: initialApp, apps, accessoryNames, t
6281
void monitor.refresh();
6382
};
6483

84+
const confirmRollback = async (): Promise<void> => {
85+
setOverlay('none');
86+
if (selectedRelease === undefined) return;
87+
monitor.appendEvent(chalk.yellow(`Rolling back to ${selectedRelease.timestamp}…`));
88+
const result = await rollbackToRelease(executor, config, currentApp, selectedRelease.timestamp);
89+
if (result.isOk()) {
90+
monitor.appendEvent(chalk.green(`Rolled back to ${chalk.bold(selectedRelease.timestamp)}`));
91+
} else {
92+
monitor.appendEvent(chalk.red(result.error.message));
93+
}
94+
void monitor.refresh();
95+
};
96+
97+
const exitLogsView = (): void => {
98+
setView('dashboard');
99+
setSearchTyping(false);
100+
setLogSearch('');
101+
setLogsPaused(false);
102+
};
103+
65104
const cycleLogFilter = (direction: 1 | -1): void => {
66105
if (currentApp.appType === 'frontend') return;
67106
const names = monitor.snapshot?.processes.map((p) => p.pm2Name) ?? [];
@@ -79,6 +118,28 @@ export function App({ executor, config, app: initialApp, apps, accessoryNames, t
79118
}
80119
if (overlay !== 'none') return;
81120

121+
// While typing a search query every printable key belongs to the query,
122+
// including q/f/r — this branch must stay ahead of the global bindings.
123+
if (view === 'logs' && searchTyping) {
124+
if (key.return) {
125+
setSearchTyping(false);
126+
return;
127+
}
128+
if (key.escape) {
129+
setSearchTyping(false);
130+
setLogSearch('');
131+
return;
132+
}
133+
if (key.backspace || key.delete) {
134+
setLogSearch((s) => s.slice(0, -1));
135+
return;
136+
}
137+
if (input !== '' && !key.ctrl && !key.meta) {
138+
setLogSearch((s) => s + input);
139+
}
140+
return;
141+
}
142+
82143
if (input === 'q') {
83144
exit();
84145
return;
@@ -93,14 +154,20 @@ export function App({ executor, config, app: initialApp, apps, accessoryNames, t
93154
return;
94155
}
95156
if (input === 'f' || input === 'F') {
96-
setView((v) => (v === 'logs' ? 'dashboard' : 'logs'));
157+
if (view === 'logs') exitLogsView();
158+
else setView('logs');
97159
return;
98160
}
99161

100162
if (view === 'logs') {
101-
if (key.escape) setView('dashboard');
163+
if (key.escape) exitLogsView();
102164
if (key.leftArrow) cycleLogFilter(-1);
103165
if (key.rightArrow) cycleLogFilter(1);
166+
if (input === '/') {
167+
setSearchTyping(true);
168+
setLogSearch('');
169+
}
170+
if (input === ' ') setLogsPaused((p) => !p);
104171
return;
105172
}
106173

@@ -109,19 +176,38 @@ export function App({ executor, config, app: initialApp, apps, accessoryNames, t
109176
return;
110177
}
111178
if (key.upArrow) {
112-
setSelectedProcess(Math.max(0, selectedIndex - 1));
179+
setSelectedRow(Math.max(0, selectedIndex - 1));
113180
return;
114181
}
115182
if (key.downArrow) {
116-
setSelectedProcess(Math.min(Math.max(processes.length - 1, 0), selectedIndex + 1));
183+
setSelectedRow(Math.min(Math.max(totalRows - 1, 0), selectedIndex + 1));
117184
return;
118185
}
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)'));
186+
if (key.return || input === 'x' || input === 'X') {
187+
if (selectedInfo !== undefined) {
188+
if (monitor.snapshot?.deployLock != null) {
189+
monitor.appendEvent(chalk.red('Restart blocked: a deploy is in progress (lock held)'));
190+
return;
191+
}
192+
setOverlay('confirmRestart');
193+
return;
194+
}
195+
if (selectedRelease !== undefined) {
196+
if (monitor.snapshot?.deployLock != null) {
197+
monitor.appendEvent(chalk.red('Rollback blocked: a deploy is in progress (lock held)'));
198+
return;
199+
}
200+
if (selectedRelease.status !== 'success') {
201+
monitor.appendEvent(chalk.red('Cannot roll back to a failed release'));
202+
return;
203+
}
204+
if (selectedRelease.timestamp === currentTimestamp) {
205+
monitor.appendEvent(chalk.yellow(`${selectedRelease.timestamp} is already the current release`));
206+
return;
207+
}
208+
setOverlay('confirmRollback');
122209
return;
123210
}
124-
setOverlay('confirmRestart');
125211
return;
126212
}
127213
if (input === 'l' || input === 'L') {
@@ -156,6 +242,23 @@ export function App({ executor, config, app: initialApp, apps, accessoryNames, t
156242
);
157243
}
158244

245+
if (overlay === 'confirmRollback' && selectedRelease !== undefined) {
246+
return (
247+
<ConfirmDialog
248+
title={`Rollback ${currentApp.name} to ${selectedRelease.timestamp}?`}
249+
lines={
250+
currentApp.appType === 'backend'
251+
? ['Switches the current symlink and reloads PM2 from that release.']
252+
: ['Switches the current symlink.']
253+
}
254+
onConfirm={() => {
255+
void confirmRollback();
256+
}}
257+
onCancel={() => setOverlay('none')}
258+
/>
259+
);
260+
}
261+
159262
if (overlay === 'selector') {
160263
return (
161264
<AppSelector
@@ -164,9 +267,9 @@ export function App({ executor, config, app: initialApp, apps, accessoryNames, t
164267
onSelect={(app) => {
165268
setCurrentApp(app);
166269
setOverlay('none');
167-
setView('dashboard');
270+
exitLogsView();
168271
setLogFilter(null);
169-
setSelectedProcess(0);
272+
setSelectedRow(0);
170273
monitor.reset();
171274
liveLogs.clearLogs();
172275
setLiveMode(false);
@@ -182,7 +285,7 @@ export function App({ executor, config, app: initialApp, apps, accessoryNames, t
182285
const title =
183286
currentApp.appType === 'frontend'
184287
? `Caddy Access Log — ${currentApp.name}`
185-
: `Live Logs — ${logFilter ?? 'all processes'} (←/→ filter, F back)`;
288+
: `Live Logs — ${logFilter ?? 'all processes'} (←/→ filter, / search, space pause, F back)`;
186289
return (
187290
<MonitorFrame
188291
app={currentApp}
@@ -194,9 +297,17 @@ export function App({ executor, config, app: initialApp, apps, accessoryNames, t
194297
snapshot={monitor.snapshot}
195298
polling={monitor.polling}
196299
error={monitor.error}
300+
healthFailStreak={alertStreak}
197301
>
198302
<Box flexGrow={1}>
199-
<LogPanel logBuffer={liveLogs.logBuffer} maxLines={Math.max(10, rows - 6)} title={title} />
303+
<LogPanel
304+
logBuffer={liveLogs.logBuffer}
305+
maxLines={Math.max(10, rows - 6)}
306+
title={title}
307+
search={logSearch}
308+
searchTyping={searchTyping}
309+
paused={logsPaused}
310+
/>
200311
</Box>
201312
</MonitorFrame>
202313
);
@@ -213,6 +324,7 @@ export function App({ executor, config, app: initialApp, apps, accessoryNames, t
213324
snapshot={monitor.snapshot}
214325
polling={monitor.polling}
215326
error={monitor.error}
327+
healthFailStreak={alertStreak}
216328
>
217329
<Box flexGrow={1} flexDirection="row" minHeight={8}>
218330
<Box width="60%" flexDirection="column">
@@ -225,7 +337,7 @@ export function App({ executor, config, app: initialApp, apps, accessoryNames, t
225337
memHistory={monitor.history.memory}
226338
health={monitor.snapshot.health}
227339
responseHistory={monitor.history.responseMs}
228-
selectedIndex={selectedIndex}
340+
selectedIndex={selectedInfo !== undefined ? selectedIndex : undefined}
229341
/>
230342
) : (
231343
<WaitingPanel />
@@ -253,11 +365,14 @@ export function App({ executor, config, app: initialApp, apps, accessoryNames, t
253365
</Box>
254366

255367
{monitor.snapshot && (
256-
<Box height={accessoryNames.length > 0 ? 6 : 7}>
368+
<Box height={(accessoryNames.length > 0 ? 6 : 7) + releaseBoxExtra}>
257369
<ReleasePanel
258370
currentRelease={monitor.snapshot.currentRelease}
259371
releases={monitor.snapshot.releases}
260-
maxReleases={accessoryNames.length > 0 ? 4 : 5}
372+
maxReleases={maxReleases}
373+
selectedIndex={
374+
selectedRelease !== undefined ? selectedIndex - processes.length : undefined
375+
}
261376
/>
262377
</Box>
263378
)}

src/cli/monitor/actions.ts

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { Result, type Result as ResultType } from 'better-result';
22
import type { RemoteExecutor } from '../../domain/remote/executor.js';
3-
import { ProcessRestartError } from '../../shared/result-errors.js';
3+
import type { ShipnodeApp, ShipnodeConfig } from '../../shared/types.js';
4+
import { ProcessRestartError, ReleaseRollbackError } from '../../shared/result-errors.js';
5+
import { getEcosystemPath } from '../../domain/pm2/apps.js';
46
import { MISE, shellQuote } from './poller.js';
57

68
/**
@@ -22,3 +24,51 @@ export async function restartProcess(
2224
}
2325
return Result.ok(undefined);
2426
}
27+
28+
/**
29+
* Switch the app's `current` symlink to an existing release and, for backend
30+
* apps, reload PM2 from that release's ecosystem file (same recovery path as
31+
* `shipnode rollback`, minus the interactive prompt — the TUI confirms first).
32+
*/
33+
export async function rollbackToRelease(
34+
executor: RemoteExecutor,
35+
config: ShipnodeConfig,
36+
app: ShipnodeApp,
37+
timestamp: string,
38+
): Promise<ResultType<void, ReleaseRollbackError>> {
39+
const fail = (detail: string): ResultType<void, ReleaseRollbackError> =>
40+
Result.err(new ReleaseRollbackError({ appName: app.name, timestamp, detail }));
41+
42+
const appPath = `${config.remotePath}/${app.name}`;
43+
const releasePath = `${appPath}/releases/${timestamp}`;
44+
45+
const exists = await executor.exec(`[ -d ${shellQuote(releasePath)} ]`);
46+
if (exists.exitCode !== 0) return fail('release directory not found on server');
47+
48+
const switched = await executor.exec(
49+
`ln -sfn ${shellQuote(releasePath)} ${shellQuote(`${appPath}/current.tmp`)} && ` +
50+
`mv -Tf ${shellQuote(`${appPath}/current.tmp`)} ${shellQuote(`${appPath}/current`)}`,
51+
);
52+
if (switched.exitCode !== 0) {
53+
return fail((switched.stderr || switched.stdout).trim() || 'failed to switch current symlink');
54+
}
55+
56+
const namespace = app.pm2?.apps[0]?.name;
57+
if (app.appType === 'backend' && namespace !== undefined) {
58+
const nodeVersion = config.nodeVersion === 'lts' ? '24' : config.nodeVersion;
59+
const ecosystem = getEcosystemPath(config, app.name);
60+
// Prefer the rolled-back release's ecosystem file (ADR-0001); fall back to
61+
// a namespace reload for releases that predate per-release ecosystem files.
62+
const reloaded = await executor.exec(
63+
`${MISE}; mise exec node@${nodeVersion} -- ` +
64+
`sh -c ${shellQuote(`pm2 reload "${ecosystem}" --update-env 2>/dev/null || pm2 reload ${namespace} --update-env`)}`,
65+
);
66+
if (reloaded.exitCode !== 0) {
67+
return fail(
68+
`symlink switched but PM2 reload failed: ${(reloaded.stderr || reloaded.stdout).trim() || `exit code ${reloaded.exitCode}`}`,
69+
);
70+
}
71+
}
72+
73+
return Result.ok(undefined);
74+
}

src/cli/monitor/components/HelpOverlay.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,10 @@ const BINDINGS: Array<[string, string]> = [
1010
['l', 'toggle live log strip'],
1111
['f', 'fullscreen logs'],
1212
['←/→', 'filter logs by process (fullscreen)'],
13-
['↑/↓', 'select process'],
14-
['Enter/x', 'restart selected process'],
13+
['/', 'search logs (fullscreen)'],
14+
['space', 'pause/resume logs (fullscreen)'],
15+
['↑/↓', 'select process or release'],
16+
['Enter/x', 'restart process / rollback to release'],
1517
['?', 'this help'],
1618
];
1719

0 commit comments

Comments
 (0)