Skip to content

Commit bfb493f

Browse files
authored
fluxreport dashboard (#211)
Signed-off-by: Daniel Guns <danbguns@gmail.com>
1 parent 2b1c345 commit bfb493f

12 files changed

Lines changed: 613 additions & 13 deletions

File tree

CHANGELOG.md

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

88
## [Unreleased]
99

10+
### Added
11+
- Cluster pulse dashboard (#195): `:pulse` answers "is my GitOps pipeline
12+
healthy?" at a glance — ready/failed/suspended totals and per-kind counts
13+
for the current namespace scope, the most recent failures with their
14+
messages for fast triage, and the FluxReport's distribution, entitlement,
15+
operator, and sync info alongside live controller pod health. Renders
16+
instantly from the watch state (no fetch) and updates in real time.
17+
1018
### Added
1119
- Workload drill-down (#194): `Enter` on a graph workload group opens the
1220
workload list; `Enter` on a workload opens its detail — rollout status and

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ A [K9s](https://github.com/derailed/k9s)-inspired terminal UI for monitoring Flu
3434
- **Kubernetes Events** - Per-resource events in the describe view, plus a live `:events` feed for the current namespace or cluster
3535
- **Controller logs** - Stream any Flux controller pod's logs live with `:logs` (follow, search, bounded buffer)
3636
- **Workload drill-down** - Open a graph workload group to walk Deployments/StatefulSets/DaemonSets: rollout status, containers, pods, events, and pod logs
37+
- **Pulse dashboard** - `:pulse` shows cluster health at a glance: per-kind counts, recent failures, and Flux distribution info
3738
- **Graph visualization** - Visualize resource relationships and dependencies (Kustomization, HelmRelease, etc.)
3839
- **Reconciliation history** - View reconciliation history for resources that track it
3940
- **Favorites** - Mark frequently accessed resources for quick access

docs/content/user-guide/_index.md

Lines changed: 15 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+
| `:pulse` | Cluster health dashboard |
101+
| `:dashboard` | Alias for `:pulse` |
100102
| `:logs` | Controller log viewer (pod submenu) |
101103
| `:logs <pod>` | Stream a controller pod by name/prefix |
102104
| `:skin <name>` | Change theme/skin (direct) |
@@ -287,6 +289,19 @@ resource list's MESSAGE column truncates.
287289
Events also appear in the describe view (`d`): each resource's describe output
288290
ends with a kubectl-style Events section listing that resource's recent events.
289291

292+
### Pulse Dashboard (`:pulse`)
293+
294+
An at-a-glance answer to "is my GitOps pipeline healthy?", updating in real
295+
time from the watch state:
296+
297+
- Ready / failed / suspended totals and a per-kind breakdown, scoped to the
298+
current namespace (or the whole cluster with `:ns all`)
299+
- The most recent failures with their reconcile messages, for fast triage
300+
(jump to the full list with `:unhealthy`)
301+
- Flux distribution info from the FluxReport — version, install status,
302+
entitlement, operator version, and sync source — plus live controller
303+
pod health
304+
290305
### Controller Logs (`:logs`)
291306

292307
Stream the logs of any Flux controller pod without leaving flux9s — the next

src/tui/app/core.rs

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,25 @@ impl App {
332332
self.kube_events.clear();
333333
}
334334

335+
/// All watched resources in the current namespace scope — the pulse
336+
/// dashboard's input. Ignores the list's type/text filters so the pulse
337+
/// always shows the whole scope.
338+
pub(crate) fn pulse_resources(&self) -> Vec<crate::watcher::ResourceInfo> {
339+
let mut resources = self.state.all();
340+
if let Some(ref namespace) = self.namespace {
341+
resources.retain(|r| r.namespace == *namespace);
342+
}
343+
resources
344+
}
345+
346+
/// The FluxReport object, when the Flux Operator publishes one.
347+
pub(crate) fn flux_report_object(&self) -> Option<&serde_json::Value> {
348+
self.resource_objects
349+
.iter()
350+
.find(|(key, _)| key.starts_with("FluxReport:"))
351+
.map(|(_, obj)| obj)
352+
}
353+
335354
/// The live events feed filtered by the list filter (matches type, reason,
336355
/// object, source, namespace and message), newest first. Returns owned
337356
/// clones so callers can hold the list while mutating view state.
@@ -552,9 +571,11 @@ impl App {
552571
.selected_resource_key
553572
.as_deref()
554573
.and_then(ResourceKey::parse),
555-
// Logs and workload views show pods/workloads, which are not
556-
// watched Flux resources.
557-
View::Logs | View::WorkloadList | View::WorkloadDetail | View::Help => None,
574+
// Logs, workload views and the pulse dashboard don't point at a
575+
// single watched Flux resource.
576+
View::Logs | View::WorkloadList | View::WorkloadDetail | View::Pulse | View::Help => {
577+
None
578+
}
558579
}
559580
}
560581

@@ -704,18 +725,10 @@ impl App {
704725

705726
match self.view_state.health_filter {
706727
HealthFilter::Healthy => {
707-
resources.retain(|r| {
708-
let is_ready = r.ready.unwrap_or(true);
709-
let is_suspended = r.suspended.unwrap_or(false);
710-
is_ready && !is_suspended
711-
});
728+
resources.retain(crate::watcher::ResourceInfo::is_healthy);
712729
}
713730
HealthFilter::Unhealthy => {
714-
resources.retain(|r| {
715-
let is_ready = r.ready.unwrap_or(true);
716-
let is_suspended = r.suspended.unwrap_or(false);
717-
!is_ready || is_suspended
718-
});
731+
resources.retain(|r| !r.is_healthy());
719732
}
720733
HealthFilter::All => {}
721734
}

src/tui/app/events.rs

Lines changed: 31 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_pulse_command, App::cmd_show_pulse),
2930
(commands::is_logs_command, App::cmd_show_logs),
3031
(commands::is_all_command, App::cmd_show_all),
3132
];
@@ -653,6 +654,9 @@ impl App {
653654
self.logs_after_workload_load = false;
654655
self.view_state.text_search.clear();
655656
self.view_state.current_view = View::WorkloadList;
657+
} else if self.view_state.current_view == View::Pulse {
658+
self.view_state.text_search.clear();
659+
self.view_state.current_view = View::ResourceList;
656660
}
657661
}
658662
_ => {}
@@ -1130,6 +1134,11 @@ impl App {
11301134
self.view_state.current_view = View::WorkloadList;
11311135
None
11321136
}
1137+
View::Pulse => {
1138+
self.view_state.text_search.clear();
1139+
self.view_state.current_view = View::ResourceList;
1140+
None
1141+
}
11331142
View::Help => {
11341143
self.view_state.current_view = View::ResourceList;
11351144
None
@@ -1715,6 +1724,13 @@ impl App {
17151724
self.reset_list_position();
17161725
}
17171726

1727+
/// `:pulse` — open the cluster pulse dashboard.
1728+
fn cmd_show_pulse(&mut self, _cmd: &str) {
1729+
self.view_state.pulse_scroll_offset = 0;
1730+
self.view_state.text_search.clear();
1731+
self.view_state.current_view = View::Pulse;
1732+
}
1733+
17181734
/// `:logs [pod]` — stream a Flux controller pod's logs. Without an
17191735
/// argument, opens a submenu of the discovered controller pods; with one,
17201736
/// matches a pod by exact name or unique prefix.
@@ -2852,4 +2868,19 @@ mod tests {
28522868
assert!(app.view_state.submenu_state.is_none(), "submenu closes");
28532869
assert!(app.ui_state.command_mode, "command mode opens");
28542870
}
2871+
2872+
#[test]
2873+
fn pulse_command_opens_dashboard_and_esc_returns() {
2874+
let mut app = create_test_app(false);
2875+
add_resource(&mut app);
2876+
2877+
app.ui_state.command_buffer = "pulse".to_string();
2878+
let result = app.execute_command();
2879+
2880+
assert_eq!(result, None);
2881+
assert_eq!(app.view_state.current_view, View::Pulse);
2882+
2883+
app.handle_key(make_key(KeyCode::Esc));
2884+
assert_eq!(app.view_state.current_view, View::ResourceList);
2885+
}
28552886
}

src/tui/app/rendering.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -515,6 +515,23 @@ impl App {
515515
&self.theme,
516516
);
517517
}
518+
View::Pulse => {
519+
let resources = self.pulse_resources();
520+
// Cloned so the &self method borrow ends before the
521+
// &mut view_state borrows below.
522+
let flux_report = self.flux_report_object().cloned();
523+
views::render_pulse(
524+
f,
525+
area,
526+
&resources,
527+
flux_report.as_ref(),
528+
&self.controller_pods.get_all_pods(),
529+
self.namespace.as_deref(),
530+
&mut self.view_state.pulse_scroll_offset,
531+
&mut self.view_state.text_search,
532+
&self.theme,
533+
);
534+
}
518535
View::Help => {
519536
render_help(f, area, &self.theme, self.namespace_hotkeys());
520537
}

src/tui/app/state.rs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@ pub enum View {
3131
/// One workload's rollout status, containers, pods, and events (#194).
3232
/// Back returns to the workload list.
3333
WorkloadDetail,
34+
/// Cluster pulse dashboard (#195): per-kind health counts, recent
35+
/// failures, and FluxReport distribution info. Opened with `:pulse`.
36+
Pulse,
3437
#[allow(dead_code)] // Reserved for future alternative help view implementation
3538
Help,
3639
}
@@ -49,6 +52,7 @@ impl View {
4952
View::ResourceHistory => Some(&mut vs.history_scroll_offset),
5053
View::Logs => Some(&mut vs.log_scroll_offset),
5154
View::WorkloadDetail => Some(&mut vs.workload_scroll_offset),
55+
View::Pulse => Some(&mut vs.pulse_scroll_offset),
5256
_ => None,
5357
}
5458
}
@@ -69,6 +73,7 @@ impl View {
6973
| View::ResourceTrace
7074
| View::Logs
7175
| View::WorkloadDetail
76+
| View::Pulse
7277
)
7378
}
7479

@@ -211,6 +216,8 @@ pub struct ViewState {
211216
pub log_scroll_offset: usize,
212217
/// Scroll offset for the workload detail view
213218
pub workload_scroll_offset: usize,
219+
/// Scroll offset for the pulse dashboard
220+
pub pulse_scroll_offset: usize,
214221
/// Where Back from the log view returns to, when logs were opened from
215222
/// somewhere other than a root list view (e.g. a workload's pods).
216223
/// Consumed on Back; `None` falls back to `previous_list_view`.
@@ -259,6 +266,7 @@ impl Default for ViewState {
259266
history_scroll_offset: 0,
260267
log_scroll_offset: 0,
261268
workload_scroll_offset: 0,
269+
pulse_scroll_offset: 0,
262270
logs_back_view: None,
263271
workload_rows: Vec::new(),
264272
graph_scroll_offset: 0,
@@ -617,6 +625,17 @@ mod tests {
617625
.scroll_offset_mut(&mut ViewState::default())
618626
.is_some()
619627
);
628+
629+
// The pulse dashboard behaves the same way: a root-level,
630+
// searchable, line-scrolled text view.
631+
assert!(!View::Pulse.is_nested_view());
632+
assert!(!View::Pulse.is_list_view());
633+
assert!(View::Pulse.is_text_search_view());
634+
assert!(
635+
View::Pulse
636+
.scroll_offset_mut(&mut ViewState::default())
637+
.is_some()
638+
);
620639
}
621640

622641
#[test]

src/tui/commands.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ pub const APP_COMMANDS: &[Command] = &[
2121
name: "events",
2222
takes_args: false,
2323
},
24+
Command {
25+
name: "pulse",
26+
takes_args: false,
27+
},
2428
Command {
2529
name: "logs",
2630
takes_args: true,
@@ -148,6 +152,12 @@ pub fn is_events_command(cmd: &str) -> bool {
148152
cmd_lower == "events" || cmd_lower == "event" || cmd_lower == "ev"
149153
}
150154

155+
/// Check if command opens the pulse dashboard
156+
pub fn is_pulse_command(cmd: &str) -> bool {
157+
let cmd_lower = cmd.to_lowercase();
158+
cmd_lower == "pulse" || cmd_lower == "dashboard"
159+
}
160+
151161
/// Check if command opens the controller log viewer (with or without a pod argument)
152162
pub fn is_logs_command(cmd: &str) -> bool {
153163
let cmd_lower = cmd.to_lowercase();

src/tui/views/help.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ pub fn render_help(f: &mut Frame, area: Rect, theme: &Theme, namespace_hotkeys:
5757
(":favorites", "View favorites"),
5858
(":fav", "View favorites"),
5959
(":events", "Live Kubernetes events feed"),
60+
(":pulse", "Cluster health dashboard"),
6061
(":logs [pod]", "Stream controller logs"),
6162
(":q", "Quit application"),
6263
];

src/tui/views/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ mod help;
1616
mod helpers;
1717
mod history;
1818
mod logs;
19+
mod pulse;
1920
mod quit_confirm;
2021
pub mod resource_fields;
2122
mod resource_list;
@@ -39,6 +40,7 @@ pub use help::*;
3940
pub use helpers::*;
4041
pub use history::*;
4142
pub use logs::*;
43+
pub use pulse::*;
4244
pub use quit_confirm::*;
4345
pub use resource_fields::*;
4446
pub use resource_list::*;

0 commit comments

Comments
 (0)