@@ -12,14 +12,14 @@ import { AccessoriesPanel } from './panels/AccessoriesPanel.js';
1212import { AppSelector } from './app-selector.js' ;
1313import { HelpOverlay } from './components/HelpOverlay.js' ;
1414import { ConfirmDialog } from './components/ConfirmDialog.js' ;
15- import { restartProcess } from './actions.js' ;
15+ import { restartProcess , rollbackToRelease } from './actions.js' ;
1616import chalk from 'chalk' ;
17- import { useMonitorData } from './hooks/use-monitor-data.js' ;
17+ import { useMonitorData , HEALTH_ALERT_THRESHOLD } from './hooks/use-monitor-data.js' ;
1818import { useLiveLogs } from './hooks/use-live-logs.js' ;
1919import { MonitorFrame , WaitingPanel } from './layout/MonitorFrame.js' ;
2020
2121type View = 'dashboard' | 'logs' ;
22- type Overlay = 'none' | 'selector' | 'help' | 'confirmRestart' ;
22+ type Overlay = 'none' | 'selector' | 'help' | 'confirmRestart' | 'confirmRollback' ;
2323
2424interface 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 ) }
0 commit comments