Skip to content

Commit e9b8bc7

Browse files
Mana (마나)claude
andcommitted
perf: cached header/status-bar info, 500ms API timeout, zero per-frame format!()
- Cache HeaderInfo struct (cluster name, endpoint, k8s ver, config path) via sync_header_info() — eliminates O(n) clusters.iter().find() per frame - Pre-compute status_bar_self_line on fetch arrival — eliminates per-frame format!() for RSS/latency display - Pre-compute NodeInfo.top_display during fetch — eliminates per-node format!() per frame in Top tab - Cache ctx_title_span on cluster/namespace change — eliminates per-frame format!("| {} ", ctx_label) in center panel title - Static render_usage_bar label spans — eliminates 2 format!() per bar - Reduce API_CALL_TIMEOUT from 1s to 500ms — halves worst-case fetch latency - Fix headless run_headless() to use join_all instead of sequential await Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 362edb4 commit e9b8bc7

File tree

5 files changed

+122
-45
lines changed

5 files changed

+122
-45
lines changed

CLAUDE.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ scalex dash --headless --resource pods # Filter by resource type (pods, nodes,
107107
- **Node VERSION column**: `NodeInfo.kubelet_version` populated from `node.status.nodeInfo.kubeletVersion`. Shown in nodes table after ROLES column and in Top tab after node name. Useful for upgrade planning.
108108
- **Service EXTERNAL-IP column**: `ServiceInfo.external_ip` populated from `status.loadBalancer.ingress[].ip/hostname`. Shows `<none>` for non-LB services. Column appears between CLUSTER-IP and PORTS.
109109
- **Alphabetical resource sorting**: Deployments, services, configmaps, and nodes sorted by name after fetch. Pods retain severity-first sorting (CrashLoopBackOff first, then pending, running, completed).
110-
- **Reduced API timeouts**: `API_CALL_TIMEOUT` reduced from 2s to 1s, `DISCOVER_TIMEOUT` from 3s to 2s. Healthy clusters respond in <200ms; tighter timeouts halve worst-case fetch latency.
110+
- **Reduced API timeouts**: `API_CALL_TIMEOUT` reduced from 2s to 500ms, `DISCOVER_TIMEOUT` from 3s to 2s. Healthy clusters respond in <200ms; tighter timeouts minimize worst-case fetch latency.
111111
- **Zero-clone tree index lookups**: `tree_index_at_cursor()` reads from cached visible indices without cloning `Vec<usize>`. `ensure_visible_indices_cached()` populates cache; callers avoid `visible_tree_indices_cached()` clone where possible.
112112
- **Static sidebar padding**: `render_sidebar` uses static `SPACES` buffer for row padding instead of per-row `" ".repeat(pad)` heap allocation.
113113
- **Cached row count**: `cached_row_count: Option<usize>` avoids redundant O(n) filter iterations in `move_down`/`page_down`/`jump_end`/`render_center`. Invalidated per event cycle.
@@ -183,6 +183,12 @@ The TUI header is k9s-style and responsive:
183183
- **CF Tunnel SA token auth**: CF Tunnel cannot proxy mTLS client certs, so `build_client_with_endpoint()` strips kubeconfig CA + client cert and injects a ServiceAccount bearer token. SA `scalex-dash` in `scalex-system` namespace, bound to `view` ClusterRole. Token cached at `_generated/clusters/{name}/dash-token`. Auto-provisioned on first run via SSH through bastion if cached token absent. Module: `sa_provisioner.rs`. To re-provision: delete `dash-token` and relaunch.
184184
- **k9s attribution**: help overlay (`?` key) footer shows "Inspired by k9s (github.com/derailed/k9s)" in DarkGray.
185185
- **Cached data persistence**: `render_tab_preamble` returns cached snapshot even when `ConnectionStatus::Failed` — error shown as 1-line red banner via `render_connection_error_banner()`, not full-area replacement. Data stability: once displayed, data stays visible until next fetch result arrives.
186+
- **Cached header info**: `HeaderInfo` struct (cluster_name, endpoint, k8s_version, config_path) pre-computed via `sync_header_info()` on cluster selection change and discovery events. `render_header` reads cached struct instead of O(n) `clusters.iter().find()` + `display().to_string()` per frame.
187+
- **Pre-computed status bar self/latency**: `status_bar_self_line` caches `"| self: 42MB | latency: 150ms"` on fetch result arrival. Spinner appended dynamically only when fetching. Eliminates per-frame `format!()` for rss + latency.
188+
- **Pre-computed top tab node display**: `NodeInfo.top_display` pre-computed during fetch as `" v1.33.1 CPU: 8/8 MEM: 7.5Gi/7.8Gi"`. `render_top_tab` borrows pre-computed string instead of per-node `format!()` per frame.
189+
- **Cached center panel title**: `ctx_title_span` pre-computed on cluster/namespace change via `sync_ctx_label()`. `render_center` borrows cached string instead of per-frame `format!("| {} ", ctx_label)`.
190+
- **Static usage bar labels**: `render_usage_bar` uses static `label` + `" ["` spans instead of `format!("{} [", label)`. Eliminates 2 format allocations per bar per frame.
191+
- **Headless parallel fetch**: `run_headless` uses `futures::future::join_all` instead of sequential `for handle in handles { handle.await }` for cluster data fetching.
186192
- **Per-resource fetch tracking**: `fetched_resources: HashSet<ActiveResource>` distinguishes "not yet fetched" (empty vec, not in set) from "fetched but truly empty" (empty vec, in set). Cleared on cluster/namespace change. View switch to unfetched resource shows "Loading {type}..." spinner instead of empty table.
187193

188194
## Key Patterns

scalex-cli/src/dash/app.rs

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,31 @@ pub enum NodeType {
102102
InfraItem(String),
103103
}
104104

105+
// ---------------------------------------------------------------------------
106+
// Cached header info
107+
// ---------------------------------------------------------------------------
108+
109+
/// Pre-computed header info to avoid O(n) cluster search per frame.
110+
/// Updated via sync_header_info() on cluster selection or discovery events.
111+
#[derive(Debug, Clone)]
112+
pub struct HeaderInfo {
113+
pub cluster_name: String,
114+
pub endpoint: String,
115+
pub k8s_version: String,
116+
pub config_path: String,
117+
}
118+
119+
impl Default for HeaderInfo {
120+
fn default() -> Self {
121+
Self {
122+
cluster_name: "--".to_string(),
123+
endpoint: "--".to_string(),
124+
k8s_version: "N/A".to_string(),
125+
config_path: "--".to_string(),
126+
}
127+
}
128+
}
129+
105130
// ---------------------------------------------------------------------------
106131
// Connection status for per-cluster discovery tracking
107132
// ---------------------------------------------------------------------------
@@ -246,6 +271,9 @@ pub struct App {
246271
/// Eliminates per-frame `format!()` in render_center.
247272
pub ctx_label: String,
248273

274+
/// Pre-computed "| context_label " for center panel title. Updated on cluster/namespace change.
275+
pub ctx_title_span: String,
276+
249277
/// Cached row count for current resource view + search filter.
250278
/// Invalidated (None) on: data change, view switch, search change, cluster/namespace change.
251279
/// Avoids redundant O(n) filter iteration in move_down/page_down/jump_end/render_center.
@@ -254,6 +282,14 @@ pub struct App {
254282
/// Pre-computed status bar health strings per cluster. Updated on fetch result arrival.
255283
/// Eliminates per-frame format!() for pod/node counts in render_status_bar.
256284
pub status_bar_health_strings: Vec<(String, String)>, // (narrow_str, wide_str) per snapshot
285+
286+
/// Pre-computed "| self: 42MB | latency: 150ms" for status bar.
287+
/// Updated on fetch result arrival. Spinner appended dynamically in render.
288+
pub status_bar_self_line: String,
289+
290+
/// Pre-computed header info (cluster name, endpoint, k8s version, config path).
291+
/// Updated via sync_header_info() on cluster selection or discovery events.
292+
pub header_info: HeaderInfo,
257293
}
258294

259295
/// ASCII case-insensitive substring search without allocation.
@@ -368,8 +404,11 @@ impl App {
368404
needs_redraw: true,
369405
render_visible_indices: Vec::new(),
370406
ctx_label: "No cluster selected".to_string(),
407+
ctx_title_span: "| No cluster selected ".to_string(),
371408
cached_row_count: None,
372409
status_bar_health_strings: Vec::new(),
410+
status_bar_self_line: "| self: N/A | latency: 0ms".to_string(),
411+
header_info: HeaderInfo::default(),
373412
}
374413
}
375414

@@ -463,8 +502,11 @@ impl App {
463502
needs_redraw: true,
464503
render_visible_indices: Vec::new(),
465504
ctx_label: "No cluster selected".to_string(),
505+
ctx_title_span: "| No cluster selected ".to_string(),
466506
cached_row_count: None,
467507
status_bar_health_strings: Vec::new(),
508+
status_bar_self_line: "| self: N/A | latency: 0ms".to_string(),
509+
header_info: HeaderInfo::default(),
468510
}
469511
}
470512

@@ -726,6 +768,26 @@ impl App {
726768
(Some(c), None) => format!("{} > All Namespaces", c),
727769
_ => "No cluster selected".to_string(),
728770
};
771+
self.ctx_title_span = format!("| {} ", self.ctx_label);
772+
}
773+
774+
/// Sync cached header info. Called on cluster selection change and discovery events.
775+
pub fn sync_header_info(&mut self) {
776+
let selected = self
777+
.selected_cluster
778+
.as_ref()
779+
.and_then(|name| self.clusters.iter().find(|c| &c.name == name))
780+
.or_else(|| self.clusters.first());
781+
782+
self.header_info = match selected {
783+
Some(c) => HeaderInfo {
784+
cluster_name: c.name.clone(),
785+
endpoint: c.endpoint.as_deref().unwrap_or("--").to_string(),
786+
k8s_version: c.server_version.as_deref().unwrap_or("N/A").to_string(),
787+
config_path: c.kubeconfig_path.display().to_string(),
788+
},
789+
None => HeaderInfo::default(),
790+
};
729791
}
730792

731793
/// Sync the pre-lowercased search query cache. Called once per event cycle.
@@ -1014,6 +1076,7 @@ impl App {
10141076
self.is_fetching = false;
10151077
self.fetched_resources.clear();
10161078
self.sync_ctx_label();
1079+
self.sync_header_info();
10171080
// Immediately populate tree from cached snapshots
10181081
self.sync_tree_from_snapshots();
10191082
}
@@ -1030,6 +1093,7 @@ impl App {
10301093
self.table_cursor = 0;
10311094
self.table_scroll_offset = 0;
10321095
self.sync_ctx_label();
1096+
self.sync_header_info();
10331097
self.needs_refresh = true;
10341098
self.refresh_selected_only = true;
10351099
self.fetch_generation += 1;
@@ -1544,6 +1608,16 @@ impl App {
15441608
(narrow, wide)
15451609
})
15461610
.collect();
1611+
1612+
// Pre-compute self/latency line (changes only on fetch result arrival)
1613+
let rss_str = self
1614+
.self_rss_mb
1615+
.map(|mb| format!("{:.0}MB", mb))
1616+
.unwrap_or_else(|| "N/A".into());
1617+
self.status_bar_self_line = format!(
1618+
"| self: {} | latency: {}ms",
1619+
rss_str, self.api_latency_ms
1620+
);
15471621
}
15481622

15491623
pub fn current_snapshot(&self) -> Option<&ClusterSnapshot> {
@@ -1682,8 +1756,11 @@ mod tests {
16821756
needs_redraw: true,
16831757
render_visible_indices: Vec::new(),
16841758
ctx_label: "No cluster selected".to_string(),
1759+
ctx_title_span: "| No cluster selected ".to_string(),
16851760
cached_row_count: None,
16861761
status_bar_health_strings: Vec::new(),
1762+
status_bar_self_line: "| self: N/A | latency: 0ms".to_string(),
1763+
header_info: HeaderInfo::default(),
16871764
};
16881765
// Move cursor to first cluster (tower)
16891766
app.tree_cursor = 1;
@@ -3836,6 +3913,7 @@ pub async fn run_tui(args: DashArgs, kubeconfig_dir: PathBuf) -> Result<()> {
38363913
app.cluster_connection_status
38373914
.insert(name.clone(), ConnectionStatus::Connected);
38383915
app.clusters.push(client);
3916+
app.sync_header_info();
38393917
// Auto-select first connected cluster if none selected
38403918
if app.selected_cluster.is_none() {
38413919
app.selected_cluster = Some(name.clone());
@@ -3928,6 +4006,7 @@ pub async fn run_tui(args: DashArgs, kubeconfig_dir: PathBuf) -> Result<()> {
39284006
app.fetched_resources.insert(ActiveResource::Nodes);
39294007
app.sync_tree_from_snapshots();
39304008
app.sync_status_bar_strings();
4009+
app.sync_header_info();
39314010
app.self_rss_mb = result.self_rss_mb;
39324011
// Apply infra data loaded on worker thread
39334012
if let Some(infra_snap) = result.infra {

scalex-cli/src/dash/data.rs

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,9 @@ pub enum ActiveResource {
1919
}
2020

2121
/// Per-API-call timeout to prevent slow calls from blocking the entire fetch.
22-
/// Reduced from 2s to 1s — K8s API calls on a healthy cluster complete in <200ms;
23-
/// 1s is generous while halving worst-case fetch latency.
24-
const API_CALL_TIMEOUT: Duration = Duration::from_secs(1);
22+
/// Reduced from 1s to 500ms — K8s API calls on a healthy cluster complete in <200ms;
23+
/// 500ms is generous while halving worst-case fetch latency.
24+
const API_CALL_TIMEOUT: Duration = Duration::from_millis(500);
2525

2626
// ---------------------------------------------------------------------------
2727
// Data models
@@ -69,6 +69,8 @@ pub struct NodeInfo {
6969
pub mem_display: String,
7070
/// Kubelet version (e.g., "v1.33.1") from node.status.nodeInfo
7171
pub kubelet_version: String,
72+
/// Pre-computed display string for Top tab: " v1.33.1 CPU: 8/8 MEM: 7.5Gi/7.8Gi"
73+
pub top_display: String,
7274
}
7375

7476
#[derive(Debug, Clone, Serialize)]
@@ -312,6 +314,11 @@ pub async fn fetch_nodes(client: &Client) -> Result<Vec<NodeInfo>> {
312314
.map(|ni| ni.kubelet_version.clone())
313315
.unwrap_or_default();
314316

317+
let top_display = format!(
318+
" {} CPU: {} MEM: {}",
319+
kubelet_version, cpu_display, mem_display
320+
);
321+
315322
NodeInfo {
316323
name: meta.name.clone().unwrap_or_default(),
317324
status: node_status,
@@ -327,6 +334,7 @@ pub async fn fetch_nodes(client: &Client) -> Result<Vec<NodeInfo>> {
327334
cpu_display,
328335
mem_display,
329336
kubelet_version,
337+
top_display,
330338
}
331339
})
332340
.collect())

scalex-cli/src/dash/mod.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,9 +70,10 @@ pub mod headless {
7070
data::fetch_cluster_snapshot(&client, &name, ns.as_deref(), None).await
7171
}));
7272
}
73+
let results = futures::future::join_all(handles).await;
7374
let mut cluster_data = Vec::new();
74-
for handle in handles {
75-
if let Ok(Ok(snapshot)) = handle.await {
75+
for result in results {
76+
if let Ok(Ok(snapshot)) = result {
7677
cluster_data.push(snapshot);
7778
}
7879
}

scalex-cli/src/dash/ui.rs

Lines changed: 22 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -88,21 +88,12 @@ const LOGO: [&str; 6] = [
8888
fn render_header(f: &mut Frame, app: &App, area: Rect) {
8989
let is_full = area.height >= 8;
9090

91-
// Gather cluster info for the selected (or first) cluster
92-
let selected = app
93-
.selected_cluster
94-
.as_ref()
95-
.and_then(|name| app.clusters.iter().find(|c| &c.name == name))
96-
.or_else(|| app.clusters.first());
97-
98-
let cluster_name = selected.map(|c| c.name.as_str()).unwrap_or("--");
99-
let endpoint_str = selected.and_then(|c| c.endpoint.as_deref()).unwrap_or("--");
100-
let k8s_ver = selected
101-
.and_then(|c| c.server_version.as_deref())
102-
.unwrap_or("N/A");
103-
let config_path = selected
104-
.map(|c| c.kubeconfig_path.display().to_string())
105-
.unwrap_or_else(|| "--".into());
91+
// Use pre-computed header info (O(1) instead of O(n) cluster search per frame)
92+
let hi = &app.header_info;
93+
let cluster_name = hi.cluster_name.as_str();
94+
let endpoint_str = hi.endpoint.as_str();
95+
let k8s_ver = hi.k8s_version.as_str();
96+
let config_path = &hi.config_path;
10697

10798
let scalex_ver = env!("CARGO_PKG_VERSION");
10899

@@ -124,7 +115,7 @@ fn render_header(f: &mut Frame, app: &App, area: Rect) {
124115
cluster_name,
125116
endpoint_str,
126117
k8s_ver,
127-
&config_path,
118+
config_path,
128119
scalex_ver,
129120
total_clusters,
130121
connected_clusters,
@@ -544,8 +535,6 @@ fn render_center(f: &mut Frame, app: &App, area: Rect) {
544535
theme::BG3
545536
};
546537

547-
let ctx_label = &app.ctx_label;
548-
549538
// Resource shortcut indicator: p d s c n with active one highlighted
550539
// Static strings avoid per-frame format!() allocation for tab labels
551540
const SHORTCUTS_ACTIVE: [&str; 5] = ["[p]Pods ", "[d]Deploy ", "[s]Svc ", "[c]CM ", "[n]Nodes "];
@@ -612,7 +601,7 @@ fn render_center(f: &mut Frame, app: &App, area: Rect) {
612601
}
613602
}
614603
title_spans.push(Span::styled(
615-
format!("| {} ", ctx_label),
604+
app.ctx_title_span.as_str(),
616605
Style::default().fg(theme::FG),
617606
));
618607
let block = Block::default()
@@ -1211,10 +1200,7 @@ fn render_top_tab(f: &mut Frame, app: &App, area: Rect) {
12111200
Style::default().fg(theme::FG).add_modifier(Modifier::BOLD),
12121201
),
12131202
Span::styled(
1214-
format!(
1215-
" {} CPU: {} MEM: {}",
1216-
node.kubelet_version, node.cpu_display, node.mem_display
1217-
),
1203+
node.top_display.as_str(),
12181204
Style::default().fg(theme::FG3),
12191205
),
12201206
]));
@@ -1253,8 +1239,8 @@ const BAR_EMPTY: &str = "--------------------"; // 20 chars max
12531239
fn render_usage_bar<'a>(label: &'a str, percent: f64, width: usize, color: Color) -> Vec<Span<'a>> {
12541240
if percent < 0.0 {
12551241
return vec![
1256-
Span::styled(format!("{} ", label), Style::default().fg(theme::FG4)),
1257-
Span::styled("N/A ", Style::default().fg(theme::FG4)),
1242+
Span::styled(label, Style::default().fg(theme::FG4)),
1243+
Span::styled(" N/A ", Style::default().fg(theme::FG4)),
12581244
];
12591245
}
12601246
let width = width.min(BAR_FILL.len());
@@ -1270,7 +1256,8 @@ fn render_usage_bar<'a>(label: &'a str, percent: f64, width: usize, color: Color
12701256
};
12711257

12721258
vec![
1273-
Span::styled(format!("{} [", label), Style::default().fg(theme::FG4)),
1259+
Span::styled(label, Style::default().fg(theme::FG4)),
1260+
Span::styled(" [", Style::default().fg(theme::FG4)),
12741261
Span::styled(&BAR_FILL[..filled], Style::default().fg(bar_color)),
12751262
Span::styled(&BAR_EMPTY[..empty], Style::default().fg(theme::FG4)),
12761263
Span::styled(
@@ -1344,15 +1331,7 @@ fn render_status_bar(f: &mut Frame, app: &App, area: Rect) {
13441331
let very_narrow = inner.width < 60;
13451332

13461333
// Self overhead + fetch indicator (always shown)
1347-
let rss_str = app
1348-
.self_rss_mb
1349-
.map(|mb| format!("{:.0}MB", mb))
1350-
.unwrap_or_else(|| "N/A".into());
1351-
let fetch_indicator = if app.is_fetching {
1352-
format!(" {} ", spinner)
1353-
} else {
1354-
String::new()
1355-
};
1334+
// rss_str and latency are pre-computed in status_bar_self_line; spinner appended dynamically
13561335

13571336
if !very_narrow {
13581337
let bar_width = if narrow { 5 } else { 8 };
@@ -1377,13 +1356,17 @@ fn render_status_bar(f: &mut Frame, app: &App, area: Rect) {
13771356
}
13781357
}
13791358

1359+
// Pre-computed self/latency base string; append spinner dynamically if fetching
13801360
usage_spans.push(Span::styled(
1381-
format!(
1382-
"| self: {} | latency: {}ms{}",
1383-
rss_str, app.api_latency_ms, fetch_indicator
1384-
),
1361+
app.status_bar_self_line.as_str(),
13851362
Style::default().fg(theme::BRIGHT_AQUA),
13861363
));
1364+
if app.is_fetching {
1365+
usage_spans.push(Span::styled(
1366+
format!(" {} ", spinner),
1367+
Style::default().fg(theme::BRIGHT_AQUA),
1368+
));
1369+
}
13871370
if app.fetch_timed_out {
13881371
usage_spans.push(Span::styled(
13891372
" [!] fetch timed out — press 'r' to retry",

0 commit comments

Comments
 (0)