From efe0ced47b154903e27ab39a6608f2f99b9aebcc Mon Sep 17 00:00:00 2001 From: Daniel Guns Date: Tue, 14 Jul 2026 14:25:20 -0300 Subject: [PATCH] pod logs and resourceset Signed-off-by: Daniel Guns --- CHANGELOG.md | 11 + README.md | 2 + docs/content/user-guide/_index.md | 24 +++ src/constants.rs | 7 + src/tui/app/core.rs | 26 ++- src/tui/app/events.rs | 167 ++++++++++++++++ src/tui/app/logs.rs | 240 ++++++++++++++++++++++ src/tui/app/mod.rs | 1 + src/tui/app/rendering.rs | 12 ++ src/tui/app/state.rs | 24 ++- src/tui/commands.rs | 92 +++++++++ src/tui/mod.rs | 55 +++++ src/tui/views/detail.rs | 321 ++++++++++++++++++++++++++++++ src/tui/views/help.rs | 4 +- src/tui/views/logs.rs | 200 +++++++++++++++++++ src/tui/views/mod.rs | 2 + 16 files changed, 1183 insertions(+), 5 deletions(-) create mode 100644 src/tui/app/logs.rs create mode 100644 src/tui/views/logs.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index f0cc79b..7d5a57f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- Controller pod log viewer (#192): `:logs` opens a submenu of the discovered + Flux controller pods (`:logs ` streams one directly). Logs are + tailed and followed live in a bounded buffer; scrolling up pauses following + and `G` jumps back to the newest line; `/` searches the buffer. The stream + runs only while the view is open. +- ResourceSet step visualization (#193): the detail view of a step-based + ResourceSet lists its ordered steps with per-step phase (done / applying / + failed / pending) derived from the status conditions, plus each step's + resource count, template marker, and timeout. + ## [0.11.1] - 2026-07-13 ### Changes\n- Version bump to 0.11.1 diff --git a/README.md b/README.md index 1909d5a..12922d9 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ A [K9s](https://github.com/derailed/k9s)-inspired terminal UI for monitoring Flu - **Resource operations** - Suspend, resume, reconcile, and delete Flux resources - **YAML viewing** - Inspect full resource manifests - **Kubernetes Events** - Per-resource events in the describe view, plus a live `:events` feed for the current namespace or cluster +- **Controller logs** - Stream any Flux controller pod's logs live with `:logs` (follow, search, bounded buffer) - **Graph visualization** - Visualize resource relationships and dependencies (Kustomization, HelmRelease, etc.) - **Reconciliation history** - View reconciliation history for resources that track it - **Favorites** - Mark frequently accessed resources for quick access @@ -145,6 +146,7 @@ By default, `flux9s` watches the `flux-system` namespace. Use `:ns all` to view - `:ns all` - View all namespaces - `:favorites` or `:fav` - View favorite resources - `:events` or `:ev` - Live Kubernetes events feed (current namespace scope) +- `:logs [pod]` - Stream a Flux controller pod's logs (submenu without argument) - `:skin {skin-name}` - set skin directly - `:skin` - open interactive theme selection menu with live preview (17 built-in themes + custom) - `:q` or `:q!` - Quit diff --git a/docs/content/user-guide/_index.md b/docs/content/user-guide/_index.md index 5af88c6..61bb276 100644 --- a/docs/content/user-guide/_index.md +++ b/docs/content/user-guide/_index.md @@ -97,6 +97,8 @@ Type these commands in command mode (press `:`): | `:fav` | Alias for `:favorites` | | `:events` | Live Kubernetes events feed | | `:ev` | Alias for `:events` | +| `:logs` | Controller log viewer (pod submenu) | +| `:logs ` | Stream a controller pod by name/prefix | | `:skin ` | Change theme/skin (direct) | | `:skin` | Open interactive theme selection menu | | `:readonly` | Toggle readonly mode | @@ -278,6 +280,28 @@ resource list's MESSAGE column truncates. Events also appear in the describe view (`d`): each resource's describe output ends with a kubectl-style Events section listing that resource's recent events. +### Controller Logs (`:logs`) + +Stream the logs of any Flux controller pod without leaving flux9s — the next +step after Events when a reconciliation fails in a way conditions don't +explain. + +- `:logs` opens a submenu of the discovered controller pods (readiness shown), + `:logs ` streams one directly by exact name or unique prefix +- The stream tails recent lines and follows new output live; scrolling up + (`j`/`k`, page keys) pauses following and `G` jumps back to the newest line +- `/` searches the log buffer with `n`/`N` to cycle matches +- The buffer is bounded (oldest lines evicted), and the stream runs only while + the view is open — `Esc` stops it and returns to where you came from + +### ResourceSet Steps + +Step-based ResourceSets (Flux Operator v0.53+) show their ordered steps in the +detail view (`Enter`), with each step's phase — done, applying, failed, or +pending — derived live from the reconciliation status, alongside the step's +resource count, template marker, and timeout. The reconciliation history view +(`h`) includes the step count of each snapshot. + ## Operations Perform actions on selected resources: diff --git a/src/constants.rs b/src/constants.rs index 188597d..8646d7f 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -14,6 +14,13 @@ pub const MAX_RECONCILIATION_HISTORY: usize = 50; /// busy cluster can't grow memory without limit. pub const MAX_KUBE_EVENTS: usize = 1000; +/// Cap on the controller log view's line buffer; oldest lines are evicted. +pub const MAX_LOG_LINES: usize = 5000; + +/// How many existing lines the log stream starts with (`tail_lines`) before +/// following new output. +pub const LOG_TAIL_LINES: i64 = 500; + /// Status message timeout in seconds pub const STATUS_MESSAGE_TIMEOUT_SECS: u64 = 4; diff --git a/src/tui/app/core.rs b/src/tui/app/core.rs index b539e34..b09b52e 100644 --- a/src/tui/app/core.rs +++ b/src/tui/app/core.rs @@ -34,6 +34,8 @@ pub struct App { pub(crate) controller_pods: ControllerPodState, /// Live Kubernetes events feed (populated while the events view is open). pub(crate) kube_events: KubeEventStore, + /// Controller pod log stream (active while the log view is open). + pub(crate) logs: super::logs::LogState, /// Path to the active log file, shown on the connection error screen. pub(crate) log_path: Option, /// Watchers currently in a degraded (erroring/reconnecting) state. @@ -84,6 +86,7 @@ impl App { pending_context_switch: None, controller_pods: ControllerPodState::default(), kube_events: KubeEventStore::default(), + logs: super::logs::LogState::default(), log_path: None, degraded_watchers: HashSet::new(), } @@ -378,6 +381,7 @@ impl App { self.resource_objects.clear(); self.controller_pods.clear(); self.kube_events.clear(); + self.logs.stop(); self.degraded_watchers.clear(); self.view_state.selected_index = 0; self.view_state.scroll_offset = 0; @@ -544,10 +548,30 @@ impl App { .selected_resource_key .as_deref() .and_then(ResourceKey::parse), - View::Help => None, + // Logs show controller pods, which are not watched Flux resources. + View::Logs | View::Help => None, } } + /// Open the log view streaming the given controller pod. The pod list + /// comes from the controller pod watch, so the namespace is the + /// configured controller namespace. + pub(crate) fn open_log_view(&mut self, pod_name: &str) { + // Remember the root view we came from so Back returns there (and a + // live events feed keeps its watcher). + if matches!( + self.view_state.current_view, + View::ResourceList | View::ResourceFavorites | View::EventList + ) { + self.view_state.previous_list_view = self.view_state.current_view; + } + let namespace = self.config.default_controller_namespace.clone(); + self.logs.request(namespace, pod_name.to_string()); + self.view_state.log_scroll_offset = 0; + self.view_state.text_search.clear(); + self.view_state.current_view = View::Logs; + } + /// The watched Flux resource the current view points at, when the /// [`Self::view_target`] is one flux9s watches. pub(crate) fn get_current_resource(&self) -> Option { diff --git a/src/tui/app/events.rs b/src/tui/app/events.rs index aeb368b..b14061a 100644 --- a/src/tui/app/events.rs +++ b/src/tui/app/events.rs @@ -26,6 +26,7 @@ const COMMAND_TABLE: &[(fn(&str) -> bool, CommandHandler)] = &[ (commands::is_unhealthy_command, App::cmd_filter_unhealthy), (commands::is_favorites_command, App::cmd_show_favorites), (commands::is_events_command, App::cmd_show_events), + (commands::is_logs_command, App::cmd_show_logs), (commands::is_all_command, App::cmd_show_all), ]; @@ -35,6 +36,10 @@ impl App { /// Ctrl+F so all scroll keys behave identically in every view. fn scroll_down(&mut self, amount: usize) { let view = self.view_state.current_view; + // Manual scrolling in the log view pauses following (G resumes). + if view == View::Logs { + self.logs.follow = false; + } // In the graph, j/Down/PageDown move keyboard focus between nodes instead // of free-scrolling; the renderer scrolls to keep the focused node on screen. if view == View::ResourceGraph { @@ -56,6 +61,10 @@ impl App { /// up when a list view is active (keeping the selection visible). fn scroll_up(&mut self, amount: usize) { let view = self.view_state.current_view; + // Manual scrolling in the log view pauses following (G resumes). + if view == View::Logs { + self.logs.follow = false; + } if view == View::ResourceGraph { self.move_graph_focus(false); } else if let Some(offset) = view.scroll_offset_mut(&mut self.view_state) { @@ -349,6 +358,10 @@ impl App { self.ui_state.command_mode = true; self.ui_state.command_buffer.clear(); } + // Jump to the newest log line and resume following. + crossterm::event::KeyCode::Char('G') if self.view_state.current_view == View::Logs => { + self.logs.follow = true; + } crossterm::event::KeyCode::Up | crossterm::event::KeyCode::Char('k') => { self.scroll_up(1); } @@ -574,6 +587,10 @@ impl App { self.stop_kube_events_watch(); self.view_state.current_view = View::ResourceList; self.selection_state.selected_resource_key = None; + } else if self.view_state.current_view == View::Logs { + self.logs.stop(); + self.view_state.text_search.clear(); + self.view_state.current_view = self.view_state.previous_list_view; } } _ => {} @@ -730,6 +747,8 @@ impl App { format!("Switching to context '{}'...", value), false, )); + } else if command == "logs" { + self.open_log_view(&value); } else if command == "skin" { // Change theme (already previewed, so just confirm) match self.set_theme(&value) { @@ -924,6 +943,13 @@ impl App { self.view_state.current_view = View::ResourceList; None } + View::Logs => { + // Stop the stream; return to wherever logs were opened from. + self.logs.stop(); + self.view_state.text_search.clear(); + self.view_state.current_view = self.view_state.previous_list_view; + None + } View::Help => { self.view_state.current_view = View::ResourceList; None @@ -1470,6 +1496,43 @@ impl App { self.reset_list_position(); } + /// `:logs [pod]` — stream a Flux controller pod's logs. Without an + /// argument, opens a submenu of the discovered controller pods; with one, + /// matches a pod by exact name or unique prefix. + fn cmd_show_logs(&mut self, cmd: &str) { + let arg = commands::extract_command_arg(cmd, "logs") + .or_else(|| commands::extract_command_arg(cmd, "log")); + let pods = self.controller_pods.get_all_pods(); + + let Some(prefix) = arg else { + match commands::logs_submenu(&pods, self.config.ui.no_icons) { + Some(submenu) => self.view_state.submenu_state = Some(submenu), + None => { + self.set_status_message(("No controller pods discovered yet".to_string(), true)) + } + } + return; + }; + + let matched = pods + .iter() + .find(|pod| pod.name == prefix) + .map(|pod| pod.name.clone()) + .or_else(|| { + let mut prefixed = pods.iter().filter(|pod| pod.name.starts_with(&prefix)); + match (prefixed.next(), prefixed.next()) { + (Some(only), None) => Some(only.name.clone()), + _ => None, // no match, or ambiguous prefix + } + }); + match matched { + Some(pod) => self.open_log_view(&pod), + None => { + self.set_status_message((format!("No controller pod matching '{}'", prefix), true)) + } + } + } + /// `:all` — clear resource-type and health filters and return to the main /// resource list (also from the favorites and events views). fn cmd_show_all(&mut self, _cmd: &str) { @@ -2187,4 +2250,108 @@ mod tests { app.view_state.filter.clear(); assert_eq!(app.filtered_kube_events().len(), 2); } + + fn add_controller_pod(app: &mut App, name: &str) { + app.controller_pods.upsert_pod( + name.to_string(), + crate::tui::app::state::ControllerPodInfo { + name: name.to_string(), + ready: true, + version: Some("v1.0.0".to_string()), + }, + ); + } + + #[test] + fn logs_command_matches_pod_by_exact_name_and_prefix() { + let mut app = create_test_app(false); + add_controller_pod(&mut app, "source-controller-abc123"); + add_controller_pod(&mut app, "source-watcher-def456"); + + // Unique prefix opens the log view + app.ui_state.command_buffer = "logs source-c".to_string(); + app.execute_command(); + assert_eq!(app.view_state.current_view, View::Logs); + assert!(app.logs.follow, "streams start in follow mode"); + + // Back stops the session and returns to the list + app.handle_key(make_key(KeyCode::Esc)); + assert_eq!(app.view_state.current_view, View::ResourceList); + assert!(app.logs.session.is_none(), "Esc stops the stream"); + + // Ambiguous prefix ("source" matches both pods) does not open a stream + app.ui_state.command_buffer = "logs source".to_string(); + app.execute_command(); + assert_eq!(app.view_state.current_view, View::ResourceList); + + // No match reports an error + app.ui_state.command_buffer = "logs nonexistent".to_string(); + app.execute_command(); + assert!( + app.ui_state + .status_message + .as_ref() + .is_some_and(|(msg, is_err)| *is_err && msg.contains("nonexistent")) + ); + } + + #[test] + fn logs_command_without_arg_opens_pod_submenu() { + let mut app = create_test_app(false); + + // No pods discovered yet: error message, no submenu + app.ui_state.command_buffer = "logs".to_string(); + app.execute_command(); + assert!(app.view_state.submenu_state.is_none()); + assert!(app.ui_state.status_message.is_some()); + + add_controller_pod(&mut app, "helm-controller-xyz"); + app.ui_state.command_buffer = "logs".to_string(); + app.execute_command(); + let submenu = app + .view_state + .submenu_state + .as_ref() + .expect("submenu opens"); + assert_eq!(submenu.command, "logs"); + assert_eq!(submenu.items.len(), 1); + + // Selecting a pod opens the log view + app.handle_key(make_key(KeyCode::Enter)); + assert_eq!(app.view_state.current_view, View::Logs); + assert!(app.view_state.submenu_state.is_none()); + } + + #[test] + fn log_view_scrolling_pauses_follow_and_g_resumes() { + let mut app = create_test_app(false); + add_controller_pod(&mut app, "source-controller-abc"); + app.ui_state.command_buffer = "logs source-controller-abc".to_string(); + app.execute_command(); + assert!(app.logs.follow); + + app.handle_key(make_key(KeyCode::Char('k'))); + assert!(!app.logs.follow, "scrolling up pauses following"); + + app.handle_key(make_key(KeyCode::Char('G'))); + assert!(app.logs.follow, "G resumes following"); + } + + #[test] + fn logs_from_event_list_returns_to_event_list() { + let mut app = create_test_app(false); + add_controller_pod(&mut app, "source-controller-abc"); + app.view_state.current_view = View::EventList; + + app.ui_state.command_buffer = "logs source-controller-abc".to_string(); + app.execute_command(); + assert_eq!(app.view_state.current_view, View::Logs); + + app.handle_key(make_key(KeyCode::Esc)); + assert_eq!( + app.view_state.current_view, + View::EventList, + "Back returns to the events feed logs were opened from" + ); + } } diff --git a/src/tui/app/logs.rs b/src/tui/app/logs.rs new file mode 100644 index 0000000..5509008 --- /dev/null +++ b/src/tui/app/logs.rs @@ -0,0 +1,240 @@ +//! Controller pod log streaming state. +//! +//! The log view follows the app's non-blocking pattern: a handler queues a +//! [`LogRequest`], the main loop dispatches it (spawning the kube log stream +//! task), and each tick drains streamed lines into a bounded buffer. The +//! stream task runs only while the log view is open — leaving the view stops +//! it, mirroring the events watcher lifecycle. + +use std::collections::VecDeque; + +/// Which pod to stream logs from. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LogRequest { + pub namespace: String, + pub pod: String, +} + +/// Message from the log stream task to the app. +#[derive(Debug)] +pub enum LogEvent { + /// One log line. + Line(String), + /// The stream failed (RBAC, pod gone, container restart, …). + Error(String), + /// The stream ended cleanly (pod terminated). + Ended, +} + +/// A running log stream: its bounded line buffer and the channel/handle of +/// the streaming task. +#[derive(Debug)] +pub struct LogSession { + pub pod: String, + pub namespace: String, + lines: VecDeque, + rx: tokio::sync::mpsc::UnboundedReceiver, + /// Handle of the streaming task; set by the main loop right after + /// dispatch+spawn. Aborted on stop. + handle: Option>, + /// Set when the stream ended or failed; shown in the view title. + pub status: Option, +} + +impl LogSession { + /// Drain any streamed lines into the buffer, evicting the oldest past + /// [`crate::constants::MAX_LOG_LINES`]. Returns how many arrived. + fn drain(&mut self) -> usize { + let mut received = 0; + while let Ok(event) = self.rx.try_recv() { + match event { + LogEvent::Line(line) => { + self.lines.push_back(line); + if self.lines.len() > crate::constants::MAX_LOG_LINES { + self.lines.pop_front(); + } + received += 1; + } + LogEvent::Error(e) => self.status = Some(format!("stream error: {}", e)), + LogEvent::Ended => self.status = Some("stream ended".to_string()), + } + } + received + } + + pub fn lines(&self) -> &VecDeque { + &self.lines + } +} + +/// Log view state: at most one queued request and one active session. +#[derive(Debug, Default)] +pub struct LogState { + /// Request waiting for the main loop to dispatch. + pending: Option, + /// The active stream, if any. + pub session: Option, + /// Auto-scroll to the newest line. Scrolling up pauses following; + /// `G` jumps to the bottom and resumes. + pub follow: bool, +} + +impl LogState { + /// Queue a log stream for the given pod, replacing (and stopping) any + /// active session. + pub fn request(&mut self, namespace: String, pod: String) { + self.stop(); + self.pending = Some(LogRequest { namespace, pod }); + self.follow = true; + } + + /// Take the queued request and register the session the main loop spawns. + /// Returns the request and the sender for the stream task; the caller + /// must pass the spawned task's handle to [`Self::set_handle`]. + pub fn dispatch( + &mut self, + ) -> Option<(LogRequest, tokio::sync::mpsc::UnboundedSender)> { + let request = self.pending.take()?; + let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); + self.session = Some(LogSession { + pod: request.pod.clone(), + namespace: request.namespace.clone(), + lines: VecDeque::new(), + rx, + handle: None, + status: None, + }); + Some((request, tx)) + } + + /// Store the stream-task handle after spawning so stop() can abort it. + pub fn set_handle(&mut self, handle: tokio::task::JoinHandle<()>) { + if let Some(ref mut session) = self.session { + session.handle = Some(handle); + } + } + + /// Drain streamed lines into the buffer. Returns how many arrived. + pub fn drain(&mut self) -> usize { + self.session.as_mut().map_or(0, LogSession::drain) + } + + /// Whether a request is queued or a stream is running. + pub fn is_loading(&self) -> bool { + self.pending.is_some() + || self + .session + .as_ref() + .is_some_and(|session| session.status.is_none()) + } + + /// Stop the stream and drop the session (called when leaving the view). + pub fn stop(&mut self) { + self.pending = None; + if let Some(session) = self.session.take() { + if let Some(handle) = session.handle { + handle.abort(); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn request_dispatch_drain_roundtrip() { + let mut state = LogState::default(); + assert!(state.dispatch().is_none()); + + state.request( + "flux-system".to_string(), + "source-controller-abc".to_string(), + ); + assert!(state.is_loading()); + assert!(state.follow); + + let (request, tx) = state.dispatch().expect("queued request dispatches"); + assert_eq!(request.pod, "source-controller-abc"); + assert_eq!(request.namespace, "flux-system"); + + tx.send(LogEvent::Line("line 1".to_string())).unwrap(); + tx.send(LogEvent::Line("line 2".to_string())).unwrap(); + assert_eq!(state.drain(), 2); + let session = state.session.as_ref().unwrap(); + assert_eq!(session.lines().len(), 2); + assert!(state.is_loading(), "stream still running"); + + tx.send(LogEvent::Ended).unwrap(); + state.drain(); + assert!(!state.is_loading(), "ended stream is no longer loading"); + assert!( + state + .session + .as_ref() + .unwrap() + .status + .as_deref() + .unwrap() + .contains("ended") + ); + } + + #[tokio::test] + async fn buffer_is_bounded() { + let mut state = LogState::default(); + state.request("ns".to_string(), "pod".to_string()); + let (_, tx) = state.dispatch().unwrap(); + for i in 0..(crate::constants::MAX_LOG_LINES + 10) { + tx.send(LogEvent::Line(format!("line {i}"))).unwrap(); + } + state.drain(); + let session = state.session.as_ref().unwrap(); + assert_eq!(session.lines().len(), crate::constants::MAX_LOG_LINES); + assert_eq!( + session.lines().front().unwrap(), + "line 10", + "oldest lines evicted first" + ); + } + + #[tokio::test] + async fn new_request_replaces_session_and_stop_clears() { + let mut state = LogState::default(); + state.request("ns".to_string(), "pod-a".to_string()); + let _ = state.dispatch(); + assert!(state.session.is_some()); + + state.request("ns".to_string(), "pod-b".to_string()); + assert!( + state.session.is_none(), + "old session stopped on new request" + ); + let _ = state.dispatch(); + assert_eq!(state.session.as_ref().unwrap().pod, "pod-b"); + + state.stop(); + assert!(state.session.is_none()); + assert!(!state.is_loading()); + } + + #[tokio::test] + async fn stream_error_is_surfaced() { + let mut state = LogState::default(); + state.request("ns".to_string(), "pod".to_string()); + let (_, tx) = state.dispatch().unwrap(); + tx.send(LogEvent::Error("forbidden".to_string())).unwrap(); + state.drain(); + assert!( + state + .session + .as_ref() + .unwrap() + .status + .as_deref() + .unwrap() + .contains("forbidden") + ); + } +} diff --git a/src/tui/app/mod.rs b/src/tui/app/mod.rs index c5cd914..bac198b 100644 --- a/src/tui/app/mod.rs +++ b/src/tui/app/mod.rs @@ -6,6 +6,7 @@ pub mod state; pub mod async_task; +pub mod logs; mod async_ops; mod core; diff --git a/src/tui/app/rendering.rs b/src/tui/app/rendering.rs index 64bf6cb..719b3fb 100644 --- a/src/tui/app/rendering.rs +++ b/src/tui/app/rendering.rs @@ -511,6 +511,18 @@ impl App { f.render_widget(paragraph, area); } } + View::Logs => { + views::render_controller_logs( + f, + area, + self.logs.session.as_ref(), + self.logs.is_loading(), + self.logs.follow, + &mut self.view_state.log_scroll_offset, + &mut self.view_state.text_search, + &self.theme, + ); + } View::EventList => { let events = self.filtered_kube_events(); views::render_kube_events( diff --git a/src/tui/app/state.rs b/src/tui/app/state.rs index 03c8125..acb6b10 100644 --- a/src/tui/app/state.rs +++ b/src/tui/app/state.rs @@ -22,6 +22,9 @@ pub enum View { /// Live Kubernetes events feed, opened with `:events`. The events watcher /// runs only while this view (or a detail view opened from it) is active. EventList, + /// Controller pod log viewer, opened with `:logs`. The log stream runs + /// only while this view is active. + Logs, #[allow(dead_code)] // Reserved for future alternative help view implementation Help, } @@ -38,6 +41,7 @@ impl View { View::ResourceDescribe => Some(&mut vs.describe_scroll_offset), View::ResourceTrace => Some(&mut vs.trace_scroll_offset), View::ResourceHistory => Some(&mut vs.history_scroll_offset), + View::Logs => Some(&mut vs.log_scroll_offset), _ => None, } } @@ -48,12 +52,12 @@ impl View { matches!(self, View::ResourceList | View::ResourceFavorites) } - /// Whether `/` opens an in-view text search (YAML/describe/trace) rather than - /// the resource-list filter. + /// Whether `/` opens an in-view text search (YAML/describe/trace/logs) + /// rather than the resource-list filter. pub fn is_text_search_view(self) -> bool { matches!( self, - View::ResourceYAML | View::ResourceDescribe | View::ResourceTrace + View::ResourceYAML | View::ResourceDescribe | View::ResourceTrace | View::Logs ) } @@ -192,6 +196,8 @@ pub struct ViewState { pub trace_scroll_offset: usize, /// Scroll offset for history view pub history_scroll_offset: usize, + /// Scroll offset for the controller log view + pub log_scroll_offset: usize, /// Scroll offset for graph view pub graph_scroll_offset: usize, /// Index (into the graph's node list) of the currently focused graph node. @@ -231,6 +237,7 @@ impl Default for ViewState { describe_scroll_offset: 0, trace_scroll_offset: 0, history_scroll_offset: 0, + log_scroll_offset: 0, graph_scroll_offset: 0, graph_focus_index: None, previous_list_view: View::ResourceList, @@ -573,6 +580,17 @@ mod tests { .scroll_offset_mut(&mut ViewState::default()) .is_none() ); + + // The log view is a root-level text view: line-scrolled and + // searchable with /, but not nested and not a resource list. + assert!(!View::Logs.is_nested_view()); + assert!(!View::Logs.is_list_view()); + assert!(View::Logs.is_text_search_view()); + assert!( + View::Logs + .scroll_offset_mut(&mut ViewState::default()) + .is_some() + ); } #[test] diff --git a/src/tui/commands.rs b/src/tui/commands.rs index 0c0e672..12d23c6 100644 --- a/src/tui/commands.rs +++ b/src/tui/commands.rs @@ -21,6 +21,10 @@ pub const APP_COMMANDS: &[Command] = &[ name: "events", takes_args: false, }, + Command { + name: "logs", + takes_args: true, + }, Command { name: "healthy", takes_args: false, @@ -144,6 +148,15 @@ pub fn is_events_command(cmd: &str) -> bool { cmd_lower == "events" || cmd_lower == "event" || cmd_lower == "ev" } +/// Check if command opens the controller log viewer (with or without a pod argument) +pub fn is_logs_command(cmd: &str) -> bool { + let cmd_lower = cmd.to_lowercase(); + cmd_lower == "logs" + || cmd_lower == "log" + || cmd_lower.starts_with("logs ") + || cmd_lower.starts_with("log ") +} + /// Check if command is readonly (handles both "readonly" and "read-only") pub fn is_readonly_command(cmd: &str) -> bool { let cmd_lower = cmd.to_lowercase(); @@ -332,6 +345,42 @@ impl CommandSubmenu for ThemeSubmenu { /// Get submenu for a command if it supports submenus /// +/// Build the controller pod submenu for `:logs` from the watched controller +/// pods (shown with their readiness so a crashing controller stands out). +pub fn logs_submenu( + controller_pods: &[crate::tui::app::state::ControllerPodInfo], + no_icons: bool, +) -> Option { + if controller_pods.is_empty() { + return None; + } + + let mut pods: Vec<_> = controller_pods.to_vec(); + pods.sort_by(|a, b| a.name.cmp(&b.name)); + + let items: Vec = pods + .into_iter() + .map(|pod| { + // Readiness marker follows the list view's icon convention, + // with text alternatives when icons are disabled. + let marker = match (pod.ready, no_icons) { + (true, false) => "●", + (false, false) => "○", + (true, true) => "OK ", + (false, true) => "ERR", + }; + let display = format!("{} {}", marker, pod.name); + SubmenuItem::with_display(pod.name, display) + }) + .collect(); + + Some( + SubmenuState::new("logs".to_string(), items) + .with_title("Controller Logs".to_string()) + .with_help("j/k: Navigate | Enter: Stream logs | Esc: Cancel".to_string()), + ) +} + /// Returns None if the command doesn't support submenus or if an argument was provided. pub fn get_command_submenu( cmd: &str, @@ -505,4 +554,47 @@ mod tests { assert!(is_all_command("CLEAR")); assert!(!is_all_command("ks")); } + + #[test] + fn test_is_logs_command_with_and_without_args() { + assert!(is_logs_command("logs")); + assert!(is_logs_command("log")); + assert!(is_logs_command("LOGS source-controller")); + assert!(is_logs_command("log source-controller")); + assert!(!is_logs_command("logstash")); + assert!(!is_logs_command("ks")); + } + + fn controller_pod(name: &str, ready: bool) -> crate::tui::app::state::ControllerPodInfo { + crate::tui::app::state::ControllerPodInfo { + name: name.to_string(), + ready, + version: None, + } + } + + #[test] + fn test_logs_submenu_sorts_pods_and_marks_readiness() { + assert!( + logs_submenu(&[], false).is_none(), + "no pods means no submenu" + ); + + let pods = vec![ + controller_pod("source-controller-b", true), + controller_pod("helm-controller-a", false), + ]; + let submenu = logs_submenu(&pods, false).expect("pods produce a submenu"); + assert_eq!(submenu.command, "logs"); + // Sorted by name; value is the bare pod name for the :logs dispatch + assert_eq!(submenu.items[0].value, "helm-controller-a"); + assert_eq!(submenu.items[1].value, "source-controller-b"); + assert!(submenu.items[0].display_text.starts_with('○')); + assert!(submenu.items[1].display_text.starts_with('●')); + + // no_icons swaps the markers for text alternatives + let submenu = logs_submenu(&pods, true).unwrap(); + assert!(submenu.items[0].display_text.starts_with("ERR")); + assert!(submenu.items[1].display_text.starts_with("OK")); + } } diff --git a/src/tui/mod.rs b/src/tui/mod.rs index c697565..af4fc04 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -449,9 +449,64 @@ pub async fn run_tui_with_async_init( let _ = tx.send(result); }); } + + // Start a queued controller log stream. The task tails the + // pod and follows new output until aborted (view closed) + // or the stream ends. + if let Some((req, tx)) = app.logs.dispatch() { + let client = client.clone(); + let handle = tokio::spawn(async move { + use crate::tui::app::logs::LogEvent; + use futures::{AsyncBufReadExt, TryStreamExt}; + use k8s_openapi::api::core::v1::Pod; + + tracing::debug!("Streaming logs for {}/{}", req.namespace, req.pod); + let api: kube::Api = kube::Api::namespaced(client, &req.namespace); + let params = kube::api::LogParams { + follow: true, + tail_lines: Some(crate::constants::LOG_TAIL_LINES), + ..Default::default() + }; + match api.log_stream(&req.pod, ¶ms).await { + Ok(stream) => { + let mut lines = stream.lines(); + loop { + match lines.try_next().await { + Ok(Some(line)) => { + if tx.send(LogEvent::Line(line)).is_err() { + break; // View closed + } + } + Ok(None) => { + let _ = tx.send(LogEvent::Ended); + break; + } + Err(e) => { + let _ = tx.send(LogEvent::Error(e.to_string())); + break; + } + } + } + } + Err(e) => { + tracing::warn!( + "Failed to start log stream for {}/{}: {}", + req.namespace, + req.pod, + e + ); + let _ = tx.send(LogEvent::Error(e.to_string())); + } + } + }); + app.logs.set_handle(handle); + } } } + // Drain streamed log lines into the log view's buffer. + app.logs.drain(); + // Poll fetch results and store them for the views. if let Some(result) = app.async_state.yaml.try_recv() { match result { diff --git a/src/tui/views/detail.rs b/src/tui/views/detail.rs index 32df401..3fb467c 100644 --- a/src/tui/views/detail.rs +++ b/src/tui/views/detail.rs @@ -29,6 +29,181 @@ fn capitalize_first(key: &str) -> String { } } +/// Display phase of one ResourceSet step, derived from spec order and the +/// status conditions. `None` phases mean the conditions carry no step +/// information (e.g. suspended or never reconciled) — names still render. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum StepPhase { + Done, + Applying, + Failed, + Pending, +} + +impl StepPhase { + fn label(self) -> &'static str { + match self { + StepPhase::Done => "done", + StepPhase::Applying => "applying", + StepPhase::Failed => "failed", + StepPhase::Pending => "pending", + } + } +} + +/// One `spec.steps` entry reduced to what the detail view shows. +#[derive(Debug)] +struct StepInfo { + name: String, + /// Number of inline `resources` entries. + resources: usize, + /// Whether the step also renders a `resourcesTemplate`. + has_template: bool, + /// The step's own timeout, verbatim (e.g. "5m"). + timeout: Option, + phase: Option, +} + +/// Find a condition of the given type in `status.conditions`. +fn find_condition<'a>( + obj: &'a serde_json::Value, + cond_type: &str, +) -> Option<&'a serde_json::Value> { + obj.pointer("/status/conditions")? + .as_array()? + .iter() + .find(|c| c["type"].as_str() == Some(cond_type)) +} + +/// Extract the ResourceSet steps and derive each step's phase. +/// +/// The operator reports step progress only through condition messages: +/// while running, the Reconciling condition says `Applying step / ""`; +/// on failure, the Ready condition message contains `step ""`. Steps +/// before the referenced one have applied; later ones are pending. A Ready +/// ResourceSet has completed every step. +fn extract_resource_set_steps(obj: &serde_json::Value) -> Vec { + let Some(steps) = obj.pointer("/spec/steps").and_then(|s| s.as_array()) else { + return Vec::new(); + }; + + let mut infos: Vec = steps + .iter() + .map(|step| StepInfo { + name: step["name"].as_str().unwrap_or_default().to_string(), + resources: step["resources"].as_array().map_or(0, |r| r.len()), + has_template: step["resourcesTemplate"] + .as_str() + .is_some_and(|t| !t.is_empty()), + timeout: step["timeout"].as_str().map(|t| t.to_string()), + phase: None, + }) + .collect(); + + // A step named in a condition message appears as `step ""` / + // `Applying step / ""` — match on the quoted name. + let named_step_position = |message: &str| { + infos + .iter() + .position(|info| message.contains(&format!("\"{}\"", info.name))) + }; + + let reconciling = find_condition(obj, "Reconciling") + .filter(|c| c["status"].as_str() == Some("True")) + .and_then(|c| c["message"].as_str()) + .filter(|m| m.contains("Applying step")) + .and_then(named_step_position); + let ready = find_condition(obj, "Ready"); + let failed = ready + .filter(|c| c["status"].as_str() == Some("False")) + .and_then(|c| c["message"].as_str()) + .and_then(named_step_position); + + let marker = if let Some(pos) = reconciling { + Some((pos, StepPhase::Applying)) + } else if let Some(pos) = failed { + Some((pos, StepPhase::Failed)) + } else if ready.is_some_and(|c| c["status"].as_str() == Some("True")) { + // Fully reconciled: every step applied. + for info in &mut infos { + info.phase = Some(StepPhase::Done); + } + None + } else { + None + }; + + if let Some((pos, phase)) = marker { + for (idx, info) in infos.iter_mut().enumerate() { + info.phase = Some(match idx.cmp(&pos) { + std::cmp::Ordering::Less => StepPhase::Done, + std::cmp::Ordering::Equal => phase, + std::cmp::Ordering::Greater => StepPhase::Pending, + }); + } + } + + infos +} + +/// Append the Steps section for step-based ResourceSets. All pushed content +/// is owned, so this works with any line lifetime. +fn push_steps_section<'a>(lines: &mut Vec>, steps: &[StepInfo], theme: &Theme) { + if steps.is_empty() { + return; + } + + lines.push(Line::from("")); + lines.push(Line::from(vec![Span::styled( + format!("Steps ({}): ", steps.len()), + Style::default().fg(theme.text_label), + )])); + + let name_width = steps.iter().map(|s| s.name.len()).max().unwrap_or(0); + for (idx, step) in steps.iter().enumerate() { + let phase_style = match step.phase { + Some(StepPhase::Done) => theme.status_ready_style(), + Some(StepPhase::Applying) => Style::default().fg(theme.text_primary), + Some(StepPhase::Failed) => theme.status_error_style(), + Some(StepPhase::Pending) | None => Style::default().fg(theme.text_secondary), + }; + + let mut contents = Vec::new(); + if step.resources > 0 { + contents.push(format!( + "{} resource{}", + step.resources, + if step.resources == 1 { "" } else { "s" } + )); + } + if step.has_template { + contents.push("template".to_string()); + } + if let Some(ref timeout) = step.timeout { + contents.push(format!("timeout {}", timeout)); + } + + lines.push(Line::from(vec![ + Span::raw(format!(" {}. ", idx + 1)), + Span::styled( + format!("{: serde_json::Value { + serde_json::json!({ + "apiVersion": "fluxcd.controlplane.io/v1", + "kind": "ResourceSet", + "metadata": {"name": "app", "namespace": "flux-system"}, + "spec": {"steps": steps}, + "status": {"conditions": conditions} + }) + } + + fn three_steps() -> serde_json::Value { + serde_json::json!([ + {"name": "pre-deploy", "resources": [{}, {}]}, + {"name": "deploy", "resources": [{}], "resourcesTemplate": "apiVersion: v1\n"}, + {"name": "post-deploy", "resourcesTemplate": "apiVersion: v1\n", "timeout": "5m"} + ]) + } + + fn phases(infos: &[StepInfo]) -> Vec> { + infos.iter().map(|i| i.phase).collect() + } + + #[test] + fn steps_parse_names_resources_template_timeout() { + let obj = rset_with(three_steps(), serde_json::json!([])); + let infos = extract_resource_set_steps(&obj); + assert_eq!(infos.len(), 3); + assert_eq!(infos[0].name, "pre-deploy"); + assert_eq!(infos[0].resources, 2); + assert!(!infos[0].has_template); + assert!(infos[1].has_template); + assert_eq!(infos[2].timeout.as_deref(), Some("5m")); + // No usable conditions: no phases, names still listed + assert_eq!(phases(&infos), vec![None, None, None]); + } + + #[test] + fn reconciling_condition_marks_applying_step() { + // Mirrors the operator's message: Applying step / "" + let obj = rset_with( + three_steps(), + serde_json::json!([ + {"type": "Reconciling", "status": "True", + "message": "Applying step 2/3 \"deploy\""}, + {"type": "Ready", "status": "False", "reason": "Progressing", + "message": "Reconciliation in progress"} + ]), + ); + let infos = extract_resource_set_steps(&obj); + assert_eq!( + phases(&infos), + vec![ + Some(StepPhase::Done), + Some(StepPhase::Applying), + Some(StepPhase::Pending) + ] + ); + } + + #[test] + fn failed_ready_condition_marks_failed_step() { + // Mirrors the operator's failure wrapping: step "" : + let obj = rset_with( + three_steps(), + serde_json::json!([ + {"type": "Ready", "status": "False", "reason": "ReconciliationFailed", + "message": "step \"deploy\" apply: dry-run failed"} + ]), + ); + let infos = extract_resource_set_steps(&obj); + assert_eq!( + phases(&infos), + vec![ + Some(StepPhase::Done), + Some(StepPhase::Failed), + Some(StepPhase::Pending) + ] + ); + } + + #[test] + fn ready_resource_set_marks_all_steps_done() { + let obj = rset_with( + three_steps(), + serde_json::json!([ + {"type": "Ready", "status": "True", "reason": "ReconciliationSucceeded", + "message": "Reconciliation finished in 2s"} + ]), + ); + let infos = extract_resource_set_steps(&obj); + assert!(phases(&infos).iter().all(|p| *p == Some(StepPhase::Done))); + } + + #[test] + fn flat_resource_set_has_no_steps_section() { + let obj = serde_json::json!({ + "spec": {"resources": [{}]}, + "status": {} + }); + assert!(extract_resource_set_steps(&obj).is_empty()); + + let mut lines = Vec::new(); + push_steps_section(&mut lines, &[], &Theme::default()); + assert!(lines.is_empty(), "no section rendered without steps"); + } + + #[test] + fn steps_section_renders_phase_and_contents() { + let obj = rset_with( + three_steps(), + serde_json::json!([ + {"type": "Reconciling", "status": "True", + "message": "Applying step 2/3 \"deploy\""} + ]), + ); + let mut lines = Vec::new(); + push_steps_section( + &mut lines, + &extract_resource_set_steps(&obj), + &Theme::default(), + ); + let text: Vec = lines + .iter() + .map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect()) + .collect(); + + assert!(text[1].contains("Steps (3)") || text[0].contains("Steps (3)")); + let deploy_row = text.iter().find(|l| l.contains("2. deploy")).unwrap(); + assert!(deploy_row.contains("applying")); + assert!(deploy_row.contains("1 resource, template")); + let post_row = text.iter().find(|l| l.contains("3. post-deploy")).unwrap(); + assert!(post_row.contains("pending")); + assert!(post_row.contains("timeout 5m")); + } +} diff --git a/src/tui/views/help.rs b/src/tui/views/help.rs index 226c7f4..246dab7 100644 --- a/src/tui/views/help.rs +++ b/src/tui/views/help.rs @@ -56,6 +56,7 @@ pub fn render_help(f: &mut Frame, area: Rect, theme: &Theme, namespace_hotkeys: (":favorites", "View favorites"), (":fav", "View favorites"), (":events", "Live Kubernetes events feed"), + (":logs [pod]", "Stream controller logs"), (":q", "Quit application"), ]; render_help_column(f, column_chunks[1], "GENERAL", &general_items, theme); @@ -68,8 +69,9 @@ pub fn render_help(f: &mut Frame, area: Rect, theme: &Theme, namespace_hotkeys: ("/", "Page up"), ("", "Open details / focused graph node"), ("///", "Sort name/age/type/status"), - ("", "Search in YAML/describe/trace"), + ("", "Search in YAML/describe/trace/logs"), ("/", "Next/prev search match"), + ("", "Follow newest line (logs view)"), ("/", "Back / quit at root"), ]; render_help_column(f, column_chunks[2], "NAVIGATION", &nav_items, theme); diff --git a/src/tui/views/logs.rs b/src/tui/views/logs.rs new file mode 100644 index 0000000..4fff138 --- /dev/null +++ b/src/tui/views/logs.rs @@ -0,0 +1,200 @@ +//! Controller pod log view rendering +//! +//! Renders the `:logs` stream: a scrollable text view over the bounded line +//! buffer, following the newest output until the user scrolls up (`G` +//! resumes). Reuses the shared text-search machinery (`/`, `n`/`N`). + +use crate::tui::app::logs::LogSession; +use crate::tui::app::state::TextSearchState; +use crate::tui::theme::Theme; +use crate::tui::views::yaml::{apply_text_search, decorate_title_with_search, find_match_lines}; +use ratatui::{ + Frame, + layout::Rect, + style::{Modifier, Style}, + text::{Line, Span}, + widgets::Paragraph, +}; + +/// Render the controller log view. +pub fn render_controller_logs( + f: &mut Frame, + area: Rect, + session: Option<&LogSession>, + loading: bool, + follow: bool, + scroll_offset: &mut usize, + search: &mut TextSearchState, + theme: &Theme, +) { + let Some(session) = session else { + if loading { + crate::tui::views::helpers::render_loading_state( + f, + area, + "Logs", + "Starting log stream...", + theme, + ); + } else { + crate::tui::views::helpers::render_empty_state( + f, + area, + "Logs", + "No log stream active", + "Run :logs to stream a Flux controller pod", + theme, + ); + } + return; + }; + + let mut title = format!("Logs: {}/{}", session.namespace, session.pod); + if let Some(ref status) = session.status { + title.push_str(&format!(" ({})", status)); + } else if follow { + title.push_str(" [following]"); + } + + let lines: Vec<&str> = session.lines().iter().map(String::as_str).collect(); + let visible_height = (area.height as usize).saturating_sub(2); + let max_scroll = lines.len().saturating_sub(visible_height); + + // Text search: matches pin the scroll position; following would yank it + // back to the bottom, so an active search pauses the auto-follow. + let match_lines = find_match_lines(&lines, &search.query); + let current_match_line = apply_text_search(search, &match_lines, scroll_offset, visible_height); + decorate_title_with_search(&mut title, search); + + if follow && !search.is_active() { + *scroll_offset = max_scroll; + } + *scroll_offset = (*scroll_offset).min(max_scroll); + + let visible_lines: Vec = lines + .iter() + .enumerate() + .skip(*scroll_offset) + .take(visible_height) + .map(|(idx, line)| { + let styled = Line::from(Span::styled( + (*line).to_string(), + Style::default().fg(theme.text_primary), + )); + if Some(idx) == current_match_line { + styled.style(Style::default().add_modifier(Modifier::REVERSED)) + } else if match_lines.binary_search(&idx).is_ok() { + styled.style(Style::default().add_modifier(Modifier::UNDERLINED)) + } else { + styled + } + }) + .collect(); + + if lines.len() > visible_height { + let first = *scroll_offset + 1; + let last = (*scroll_offset + visible_height).min(lines.len()); + title.push_str(&format!(" [{}-{}/{}]", first, last, lines.len())); + } + + let block = crate::tui::views::helpers::create_themed_block(&title, theme); + let paragraph = Paragraph::new(visible_lines).block(block); + f.render_widget(paragraph, area); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tui::app::logs::{LogEvent, LogState}; + use ratatui::Terminal; + use ratatui::backend::TestBackend; + + /// Build a LogState with an active session containing the given lines. + /// Channels work without a runtime, so no tokio::test needed. + fn state_with_lines(lines: &[&str]) -> LogState { + let mut state = LogState::default(); + state.request( + "flux-system".to_string(), + "source-controller-abc".to_string(), + ); + let (_, tx) = state.dispatch().unwrap(); + for line in lines { + tx.send(LogEvent::Line((*line).to_string())).unwrap(); + } + state.drain(); + state + } + + fn render_to_text(state: &mut LogState, scroll_offset: &mut usize, height: u16) -> String { + let mut terminal = Terminal::new(TestBackend::new(60, height)).unwrap(); + terminal + .draw(|frame| { + render_controller_logs( + frame, + frame.area(), + state.session.as_ref(), + state.is_loading(), + state.follow, + scroll_offset, + &mut TextSearchState::default(), + &Theme::default(), + ); + }) + .unwrap(); + let buffer = terminal.backend().buffer().clone(); + let mut text = String::new(); + for y in 0..buffer.area.height { + for x in 0..buffer.area.width { + text.push_str(buffer[(x, y)].symbol()); + } + text.push('\n'); + } + text + } + + #[test] + fn no_session_renders_empty_state() { + let mut state = LogState::default(); + let mut scroll = 0; + let text = render_to_text(&mut state, &mut scroll, 8); + assert!(text.contains("No log stream active")); + } + + #[test] + fn follow_pins_view_to_newest_lines() { + // 20 lines into an 8-row terminal (6 content rows): following must + // show the newest lines, not the oldest. + let lines: Vec = (1..=20).map(|i| format!("log line {i}")).collect(); + let refs: Vec<&str> = lines.iter().map(String::as_str).collect(); + let mut state = state_with_lines(&refs); + let mut scroll = 0; + + let text = render_to_text(&mut state, &mut scroll, 8); + assert!(text.contains("log line 20"), "newest line visible"); + assert!(!text.contains("log line 1 "), "oldest line scrolled out"); + assert!(text.contains("[following]"), "title shows follow mode"); + assert!(text.contains("source-controller-abc")); + + // Follow paused: the manual scroll position wins + state.follow = false; + scroll = 0; + let text = render_to_text(&mut state, &mut scroll, 8); + assert!(text.contains("log line 1"), "top of buffer visible"); + assert!(!text.contains("[following]")); + } + + #[test] + fn stream_status_shows_in_title() { + let mut state = LogState::default(); + state.request("ns".to_string(), "pod".to_string()); + let (_, tx) = state.dispatch().unwrap(); + tx.send(LogEvent::Line("only line".to_string())).unwrap(); + tx.send(LogEvent::Ended).unwrap(); + state.drain(); + + let mut scroll = 0; + let text = render_to_text(&mut state, &mut scroll, 8); + assert!(text.contains("stream ended")); + assert!(text.contains("only line")); + } +} diff --git a/src/tui/views/mod.rs b/src/tui/views/mod.rs index f9b2caf..05eb3ca 100644 --- a/src/tui/views/mod.rs +++ b/src/tui/views/mod.rs @@ -15,6 +15,7 @@ mod header; mod help; mod helpers; mod history; +mod logs; mod quit_confirm; pub mod resource_fields; mod resource_list; @@ -36,6 +37,7 @@ pub use help::*; #[allow(unused_imports)] // Used via fully qualified paths (crate::tui::views::helpers::) pub use helpers::*; pub use history::*; +pub use logs::*; pub use quit_confirm::*; pub use resource_fields::*; pub use resource_list::*;