Skip to content

Commit ecfbfd0

Browse files
authored
pod logs and resourceset (#206)
1 parent a25e0c4 commit ecfbfd0

16 files changed

Lines changed: 1183 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
- Controller pod log viewer (#192): `:logs` opens a submenu of the discovered
12+
Flux controller pods (`:logs <pod|prefix>` streams one directly). Logs are
13+
tailed and followed live in a bounded buffer; scrolling up pauses following
14+
and `G` jumps back to the newest line; `/` searches the buffer. The stream
15+
runs only while the view is open.
16+
- ResourceSet step visualization (#193): the detail view of a step-based
17+
ResourceSet lists its ordered steps with per-step phase (done / applying /
18+
failed / pending) derived from the status conditions, plus each step's
19+
resource count, template marker, and timeout.
20+
1021
## [0.11.1] - 2026-07-13
1122

1223
### Changes\n- Version bump to 0.11.1

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ A [K9s](https://github.com/derailed/k9s)-inspired terminal UI for monitoring Flu
3232
- **Resource operations** - Suspend, resume, reconcile, and delete Flux resources
3333
- **YAML viewing** - Inspect full resource manifests
3434
- **Kubernetes Events** - Per-resource events in the describe view, plus a live `:events` feed for the current namespace or cluster
35+
- **Controller logs** - Stream any Flux controller pod's logs live with `:logs` (follow, search, bounded buffer)
3536
- **Graph visualization** - Visualize resource relationships and dependencies (Kustomization, HelmRelease, etc.)
3637
- **Reconciliation history** - View reconciliation history for resources that track it
3738
- **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
145146
- `:ns all` - View all namespaces
146147
- `:favorites` or `:fav` - View favorite resources
147148
- `:events` or `:ev` - Live Kubernetes events feed (current namespace scope)
149+
- `:logs [pod]` - Stream a Flux controller pod's logs (submenu without argument)
148150
- `:skin {skin-name}` - set skin directly
149151
- `:skin` - open interactive theme selection menu with live preview (17 built-in themes + custom)
150152
- `:q` or `:q!` - Quit

docs/content/user-guide/_index.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,8 @@ Type these commands in command mode (press `:`):
9797
| `:fav` | Alias for `:favorites` |
9898
| `:events` | Live Kubernetes events feed |
9999
| `:ev` | Alias for `:events` |
100+
| `:logs` | Controller log viewer (pod submenu) |
101+
| `:logs <pod>` | Stream a controller pod by name/prefix |
100102
| `:skin <name>` | Change theme/skin (direct) |
101103
| `:skin` | Open interactive theme selection menu |
102104
| `:readonly` | Toggle readonly mode |
@@ -278,6 +280,28 @@ resource list's MESSAGE column truncates.
278280
Events also appear in the describe view (`d`): each resource's describe output
279281
ends with a kubectl-style Events section listing that resource's recent events.
280282

283+
### Controller Logs (`:logs`)
284+
285+
Stream the logs of any Flux controller pod without leaving flux9s — the next
286+
step after Events when a reconciliation fails in a way conditions don't
287+
explain.
288+
289+
- `:logs` opens a submenu of the discovered controller pods (readiness shown),
290+
`:logs <pod>` streams one directly by exact name or unique prefix
291+
- The stream tails recent lines and follows new output live; scrolling up
292+
(`j`/`k`, page keys) pauses following and `G` jumps back to the newest line
293+
- `/` searches the log buffer with `n`/`N` to cycle matches
294+
- The buffer is bounded (oldest lines evicted), and the stream runs only while
295+
the view is open — `Esc` stops it and returns to where you came from
296+
297+
### ResourceSet Steps
298+
299+
Step-based ResourceSets (Flux Operator v0.53+) show their ordered steps in the
300+
detail view (`Enter`), with each step's phase — done, applying, failed, or
301+
pending — derived live from the reconciliation status, alongside the step's
302+
resource count, template marker, and timeout. The reconciliation history view
303+
(`h`) includes the step count of each snapshot.
304+
281305
## Operations
282306

283307
Perform actions on selected resources:

src/constants.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,13 @@ pub const MAX_RECONCILIATION_HISTORY: usize = 50;
1414
/// busy cluster can't grow memory without limit.
1515
pub const MAX_KUBE_EVENTS: usize = 1000;
1616

17+
/// Cap on the controller log view's line buffer; oldest lines are evicted.
18+
pub const MAX_LOG_LINES: usize = 5000;
19+
20+
/// How many existing lines the log stream starts with (`tail_lines`) before
21+
/// following new output.
22+
pub const LOG_TAIL_LINES: i64 = 500;
23+
1724
/// Status message timeout in seconds
1825
pub const STATUS_MESSAGE_TIMEOUT_SECS: u64 = 4;
1926

src/tui/app/core.rs

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ pub struct App {
3434
pub(crate) controller_pods: ControllerPodState,
3535
/// Live Kubernetes events feed (populated while the events view is open).
3636
pub(crate) kube_events: KubeEventStore,
37+
/// Controller pod log stream (active while the log view is open).
38+
pub(crate) logs: super::logs::LogState,
3739
/// Path to the active log file, shown on the connection error screen.
3840
pub(crate) log_path: Option<std::path::PathBuf>,
3941
/// Watchers currently in a degraded (erroring/reconnecting) state.
@@ -84,6 +86,7 @@ impl App {
8486
pending_context_switch: None,
8587
controller_pods: ControllerPodState::default(),
8688
kube_events: KubeEventStore::default(),
89+
logs: super::logs::LogState::default(),
8790
log_path: None,
8891
degraded_watchers: HashSet::new(),
8992
}
@@ -378,6 +381,7 @@ impl App {
378381
self.resource_objects.clear();
379382
self.controller_pods.clear();
380383
self.kube_events.clear();
384+
self.logs.stop();
381385
self.degraded_watchers.clear();
382386
self.view_state.selected_index = 0;
383387
self.view_state.scroll_offset = 0;
@@ -544,10 +548,30 @@ impl App {
544548
.selected_resource_key
545549
.as_deref()
546550
.and_then(ResourceKey::parse),
547-
View::Help => None,
551+
// Logs show controller pods, which are not watched Flux resources.
552+
View::Logs | View::Help => None,
548553
}
549554
}
550555

556+
/// Open the log view streaming the given controller pod. The pod list
557+
/// comes from the controller pod watch, so the namespace is the
558+
/// configured controller namespace.
559+
pub(crate) fn open_log_view(&mut self, pod_name: &str) {
560+
// Remember the root view we came from so Back returns there (and a
561+
// live events feed keeps its watcher).
562+
if matches!(
563+
self.view_state.current_view,
564+
View::ResourceList | View::ResourceFavorites | View::EventList
565+
) {
566+
self.view_state.previous_list_view = self.view_state.current_view;
567+
}
568+
let namespace = self.config.default_controller_namespace.clone();
569+
self.logs.request(namespace, pod_name.to_string());
570+
self.view_state.log_scroll_offset = 0;
571+
self.view_state.text_search.clear();
572+
self.view_state.current_view = View::Logs;
573+
}
574+
551575
/// The watched Flux resource the current view points at, when the
552576
/// [`Self::view_target`] is one flux9s watches.
553577
pub(crate) fn get_current_resource(&self) -> Option<crate::watcher::ResourceInfo> {

src/tui/app/events.rs

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)