Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <pod|prefix>` 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
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
24 changes: 24 additions & 0 deletions docs/content/user-guide/_index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <pod>` | Stream a controller pod by name/prefix |
| `:skin <name>` | Change theme/skin (direct) |
| `:skin` | Open interactive theme selection menu |
| `:readonly` | Toggle readonly mode |
Expand Down Expand Up @@ -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 <pod>` 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:
Expand Down
7 changes: 7 additions & 0 deletions src/constants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
26 changes: 25 additions & 1 deletion src/tui/app/core.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<std::path::PathBuf>,
/// Watchers currently in a degraded (erroring/reconnecting) state.
Expand Down Expand Up @@ -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(),
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<crate::watcher::ResourceInfo> {
Expand Down
167 changes: 167 additions & 0 deletions src/tui/app/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
];

Expand 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 {
Expand All @@ -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) {
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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;
}
}
_ => {}
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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"
);
}
}
Loading
Loading