@@ -26,6 +26,7 @@ const COMMAND_TABLE: &[(fn(&str) -> bool, CommandHandler)] = &[
2626 ( commands:: is_unhealthy_command, App :: cmd_filter_unhealthy) ,
2727 ( commands:: is_favorites_command, App :: cmd_show_favorites) ,
2828 ( commands:: is_events_command, App :: cmd_show_events) ,
29+ ( commands:: is_logs_command, App :: cmd_show_logs) ,
2930 ( commands:: is_all_command, App :: cmd_show_all) ,
3031] ;
3132
@@ -35,6 +36,10 @@ impl App {
3536 /// Ctrl+F so all scroll keys behave identically in every view.
3637 fn scroll_down ( & mut self , amount : usize ) {
3738 let view = self . view_state . current_view ;
39+ // Manual scrolling in the log view pauses following (G resumes).
40+ if view == View :: Logs {
41+ self . logs . follow = false ;
42+ }
3843 // In the graph, j/Down/PageDown move keyboard focus between nodes instead
3944 // of free-scrolling; the renderer scrolls to keep the focused node on screen.
4045 if view == View :: ResourceGraph {
@@ -56,6 +61,10 @@ impl App {
5661 /// up when a list view is active (keeping the selection visible).
5762 fn scroll_up ( & mut self , amount : usize ) {
5863 let view = self . view_state . current_view ;
64+ // Manual scrolling in the log view pauses following (G resumes).
65+ if view == View :: Logs {
66+ self . logs . follow = false ;
67+ }
5968 if view == View :: ResourceGraph {
6069 self . move_graph_focus ( false ) ;
6170 } else if let Some ( offset) = view. scroll_offset_mut ( & mut self . view_state ) {
@@ -349,6 +358,10 @@ impl App {
349358 self . ui_state . command_mode = true ;
350359 self . ui_state . command_buffer . clear ( ) ;
351360 }
361+ // Jump to the newest log line and resume following.
362+ crossterm:: event:: KeyCode :: Char ( 'G' ) if self . view_state . current_view == View :: Logs => {
363+ self . logs . follow = true ;
364+ }
352365 crossterm:: event:: KeyCode :: Up | crossterm:: event:: KeyCode :: Char ( 'k' ) => {
353366 self . scroll_up ( 1 ) ;
354367 }
@@ -574,6 +587,10 @@ impl App {
574587 self . stop_kube_events_watch ( ) ;
575588 self . view_state . current_view = View :: ResourceList ;
576589 self . selection_state . selected_resource_key = None ;
590+ } else if self . view_state . current_view == View :: Logs {
591+ self . logs . stop ( ) ;
592+ self . view_state . text_search . clear ( ) ;
593+ self . view_state . current_view = self . view_state . previous_list_view ;
577594 }
578595 }
579596 _ => { }
@@ -730,6 +747,8 @@ impl App {
730747 format ! ( "Switching to context '{}'..." , value) ,
731748 false ,
732749 ) ) ;
750+ } else if command == "logs" {
751+ self . open_log_view ( & value) ;
733752 } else if command == "skin" {
734753 // Change theme (already previewed, so just confirm)
735754 match self . set_theme ( & value) {
@@ -924,6 +943,13 @@ impl App {
924943 self . view_state . current_view = View :: ResourceList ;
925944 None
926945 }
946+ View :: Logs => {
947+ // Stop the stream; return to wherever logs were opened from.
948+ self . logs . stop ( ) ;
949+ self . view_state . text_search . clear ( ) ;
950+ self . view_state . current_view = self . view_state . previous_list_view ;
951+ None
952+ }
927953 View :: Help => {
928954 self . view_state . current_view = View :: ResourceList ;
929955 None
@@ -1470,6 +1496,43 @@ impl App {
14701496 self . reset_list_position ( ) ;
14711497 }
14721498
1499+ /// `:logs [pod]` — stream a Flux controller pod's logs. Without an
1500+ /// argument, opens a submenu of the discovered controller pods; with one,
1501+ /// matches a pod by exact name or unique prefix.
1502+ fn cmd_show_logs ( & mut self , cmd : & str ) {
1503+ let arg = commands:: extract_command_arg ( cmd, "logs" )
1504+ . or_else ( || commands:: extract_command_arg ( cmd, "log" ) ) ;
1505+ let pods = self . controller_pods . get_all_pods ( ) ;
1506+
1507+ let Some ( prefix) = arg else {
1508+ match commands:: logs_submenu ( & pods, self . config . ui . no_icons ) {
1509+ Some ( submenu) => self . view_state . submenu_state = Some ( submenu) ,
1510+ None => {
1511+ self . set_status_message ( ( "No controller pods discovered yet" . to_string ( ) , true ) )
1512+ }
1513+ }
1514+ return ;
1515+ } ;
1516+
1517+ let matched = pods
1518+ . iter ( )
1519+ . find ( |pod| pod. name == prefix)
1520+ . map ( |pod| pod. name . clone ( ) )
1521+ . or_else ( || {
1522+ let mut prefixed = pods. iter ( ) . filter ( |pod| pod. name . starts_with ( & prefix) ) ;
1523+ match ( prefixed. next ( ) , prefixed. next ( ) ) {
1524+ ( Some ( only) , None ) => Some ( only. name . clone ( ) ) ,
1525+ _ => None , // no match, or ambiguous prefix
1526+ }
1527+ } ) ;
1528+ match matched {
1529+ Some ( pod) => self . open_log_view ( & pod) ,
1530+ None => {
1531+ self . set_status_message ( ( format ! ( "No controller pod matching '{}'" , prefix) , true ) )
1532+ }
1533+ }
1534+ }
1535+
14731536 /// `:all` — clear resource-type and health filters and return to the main
14741537 /// resource list (also from the favorites and events views).
14751538 fn cmd_show_all ( & mut self , _cmd : & str ) {
@@ -2187,4 +2250,108 @@ mod tests {
21872250 app. view_state . filter . clear ( ) ;
21882251 assert_eq ! ( app. filtered_kube_events( ) . len( ) , 2 ) ;
21892252 }
2253+
2254+ fn add_controller_pod ( app : & mut App , name : & str ) {
2255+ app. controller_pods . upsert_pod (
2256+ name. to_string ( ) ,
2257+ crate :: tui:: app:: state:: ControllerPodInfo {
2258+ name : name. to_string ( ) ,
2259+ ready : true ,
2260+ version : Some ( "v1.0.0" . to_string ( ) ) ,
2261+ } ,
2262+ ) ;
2263+ }
2264+
2265+ #[ test]
2266+ fn logs_command_matches_pod_by_exact_name_and_prefix ( ) {
2267+ let mut app = create_test_app ( false ) ;
2268+ add_controller_pod ( & mut app, "source-controller-abc123" ) ;
2269+ add_controller_pod ( & mut app, "source-watcher-def456" ) ;
2270+
2271+ // Unique prefix opens the log view
2272+ app. ui_state . command_buffer = "logs source-c" . to_string ( ) ;
2273+ app. execute_command ( ) ;
2274+ assert_eq ! ( app. view_state. current_view, View :: Logs ) ;
2275+ assert ! ( app. logs. follow, "streams start in follow mode" ) ;
2276+
2277+ // Back stops the session and returns to the list
2278+ app. handle_key ( make_key ( KeyCode :: Esc ) ) ;
2279+ assert_eq ! ( app. view_state. current_view, View :: ResourceList ) ;
2280+ assert ! ( app. logs. session. is_none( ) , "Esc stops the stream" ) ;
2281+
2282+ // Ambiguous prefix ("source" matches both pods) does not open a stream
2283+ app. ui_state . command_buffer = "logs source" . to_string ( ) ;
2284+ app. execute_command ( ) ;
2285+ assert_eq ! ( app. view_state. current_view, View :: ResourceList ) ;
2286+
2287+ // No match reports an error
2288+ app. ui_state . command_buffer = "logs nonexistent" . to_string ( ) ;
2289+ app. execute_command ( ) ;
2290+ assert ! (
2291+ app. ui_state
2292+ . status_message
2293+ . as_ref( )
2294+ . is_some_and( |( msg, is_err) | * is_err && msg. contains( "nonexistent" ) )
2295+ ) ;
2296+ }
2297+
2298+ #[ test]
2299+ fn logs_command_without_arg_opens_pod_submenu ( ) {
2300+ let mut app = create_test_app ( false ) ;
2301+
2302+ // No pods discovered yet: error message, no submenu
2303+ app. ui_state . command_buffer = "logs" . to_string ( ) ;
2304+ app. execute_command ( ) ;
2305+ assert ! ( app. view_state. submenu_state. is_none( ) ) ;
2306+ assert ! ( app. ui_state. status_message. is_some( ) ) ;
2307+
2308+ add_controller_pod ( & mut app, "helm-controller-xyz" ) ;
2309+ app. ui_state . command_buffer = "logs" . to_string ( ) ;
2310+ app. execute_command ( ) ;
2311+ let submenu = app
2312+ . view_state
2313+ . submenu_state
2314+ . as_ref ( )
2315+ . expect ( "submenu opens" ) ;
2316+ assert_eq ! ( submenu. command, "logs" ) ;
2317+ assert_eq ! ( submenu. items. len( ) , 1 ) ;
2318+
2319+ // Selecting a pod opens the log view
2320+ app. handle_key ( make_key ( KeyCode :: Enter ) ) ;
2321+ assert_eq ! ( app. view_state. current_view, View :: Logs ) ;
2322+ assert ! ( app. view_state. submenu_state. is_none( ) ) ;
2323+ }
2324+
2325+ #[ test]
2326+ fn log_view_scrolling_pauses_follow_and_g_resumes ( ) {
2327+ let mut app = create_test_app ( false ) ;
2328+ add_controller_pod ( & mut app, "source-controller-abc" ) ;
2329+ app. ui_state . command_buffer = "logs source-controller-abc" . to_string ( ) ;
2330+ app. execute_command ( ) ;
2331+ assert ! ( app. logs. follow) ;
2332+
2333+ app. handle_key ( make_key ( KeyCode :: Char ( 'k' ) ) ) ;
2334+ assert ! ( !app. logs. follow, "scrolling up pauses following" ) ;
2335+
2336+ app. handle_key ( make_key ( KeyCode :: Char ( 'G' ) ) ) ;
2337+ assert ! ( app. logs. follow, "G resumes following" ) ;
2338+ }
2339+
2340+ #[ test]
2341+ fn logs_from_event_list_returns_to_event_list ( ) {
2342+ let mut app = create_test_app ( false ) ;
2343+ add_controller_pod ( & mut app, "source-controller-abc" ) ;
2344+ app. view_state . current_view = View :: EventList ;
2345+
2346+ app. ui_state . command_buffer = "logs source-controller-abc" . to_string ( ) ;
2347+ app. execute_command ( ) ;
2348+ assert_eq ! ( app. view_state. current_view, View :: Logs ) ;
2349+
2350+ app. handle_key ( make_key ( KeyCode :: Esc ) ) ;
2351+ assert_eq ! (
2352+ app. view_state. current_view,
2353+ View :: EventList ,
2354+ "Back returns to the events feed logs were opened from"
2355+ ) ;
2356+ }
21902357}
0 commit comments