• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

kdash-rs / kdash / 24133291995

08 Apr 2026 11:36AM UTC coverage: 65.009% (+0.2%) from 64.777%
24133291995

push

github

deepu105
fix: tests

7972 of 12263 relevant lines covered (65.01%)

155.05 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

86.95
/src/app/mod.rs
1
pub(crate) mod configmaps;
2
pub(crate) mod contexts;
3
pub(crate) mod cronjobs;
4
pub(crate) mod daemonsets;
5
pub(crate) mod deployments;
6
pub(crate) mod dynamic;
7
pub(crate) mod ingress;
8
pub(crate) mod jobs;
9
pub(crate) mod key_binding;
10
pub(crate) mod metrics;
11
pub(crate) mod models;
12
pub(crate) mod network_policies;
13
pub(crate) mod nodes;
14
pub(crate) mod ns;
15
pub(crate) mod pods;
16
pub(crate) mod pvcs;
17
pub(crate) mod pvs;
18
pub(crate) mod replicasets;
19
pub(crate) mod replication_controllers;
20
pub(crate) mod roles;
21
pub(crate) mod secrets;
22
pub(crate) mod serviceaccounts;
23
pub(crate) mod statefulsets;
24
pub(crate) mod storageclass;
25
pub(crate) mod svcs;
26
pub(crate) mod troubleshoot;
27
pub(crate) mod utils;
28

29
use anyhow::anyhow;
30
use chrono::Local;
31
use kube::config::Kubeconfig;
32
use kubectl_view_allocations::{GroupBy, QtyByQualifier};
33
use log::{error, info};
34
use ratatui::layout::Rect;
35
use std::collections::VecDeque;
36
use std::time::{Duration, Instant};
37
use tokio::sync::{mpsc::Sender, watch};
38

39
use self::{
40
  configmaps::KubeConfigMap,
41
  contexts::KubeContext,
42
  cronjobs::KubeCronJob,
43
  daemonsets::KubeDaemonSet,
44
  deployments::KubeDeployment,
45
  dynamic::{DynamicResourceCache, KubeDynamicKind, KubeDynamicResource},
46
  ingress::KubeIngress,
47
  jobs::KubeJob,
48
  key_binding::DEFAULT_KEYBINDING,
49
  metrics::KubeNodeMetrics,
50
  models::{
51
    FilterableTable, LogsState, ScrollableTxt, StatefulList, StatefulTable, TabRoute, TabsState,
52
  },
53
  network_policies::KubeNetworkPolicy,
54
  nodes::KubeNode,
55
  ns::KubeNs,
56
  pods::{KubeContainer, KubePod},
57
  pvcs::KubePVC,
58
  pvs::KubePV,
59
  replicasets::KubeReplicaSet,
60
  replication_controllers::KubeReplicationController,
61
  roles::{KubeClusterRole, KubeClusterRoleBinding, KubeRole, KubeRoleBinding},
62
  secrets::KubeSecret,
63
  serviceaccounts::KubeSvcAcct,
64
  statefulsets::KubeStatefulSet,
65
  storageclass::KubeStorageClass,
66
  svcs::KubeSvc,
67
};
68
use super::{
69
  cmd::IoCmdEvent,
70
  config::KdashConfig,
71
  network::{stream::IoStreamEvent, IoEvent},
72
};
73

74
const MAX_NAV_STACK: usize = 128;
75
const STATUS_MESSAGE_DURATION: Duration = Duration::from_secs(5);
76
pub const DEFAULT_LOG_TAIL_LINES: u32 = 100;
77
pub const MAX_ERROR_HISTORY: usize = 100;
78

79
#[derive(Clone, Debug, Eq, PartialEq)]
80
pub struct ErrorRecord {
81
  pub timestamp: String,
82
  pub message: String,
83
}
84

85
#[derive(Clone, Debug)]
86
pub struct StatusMessage {
87
  pub text: String,
88
  pub duration: Duration,
89
  expires_at: Option<Instant>,
90
}
91

92
impl Default for StatusMessage {
93
  fn default() -> Self {
72✔
94
    Self {
72✔
95
      text: String::new(),
72✔
96
      duration: STATUS_MESSAGE_DURATION,
72✔
97
      expires_at: None,
72✔
98
    }
72✔
99
  }
72✔
100
}
101

102
impl StatusMessage {
103
  pub fn is_empty(&self) -> bool {
19✔
104
    self.text.is_empty()
19✔
105
  }
19✔
106

107
  pub fn text(&self) -> &str {
2✔
108
    &self.text
2✔
109
  }
2✔
110

111
  pub fn show(&mut self, message: impl Into<String>) {
2✔
112
    self.show_at(message, Instant::now());
2✔
113
  }
2✔
114

115
  pub fn show_at(&mut self, message: impl Into<String>, now: Instant) {
3✔
116
    self.text = message.into();
3✔
117
    self.expires_at = Some(now + self.duration);
3✔
118
  }
3✔
119

120
  pub fn clear(&mut self) {
108✔
121
    self.text.clear();
108✔
122
    self.expires_at = None;
108✔
123
  }
108✔
124

125
  pub fn clear_if_expired(&mut self, now: Instant) {
8✔
126
    if self.expires_at.is_some_and(|expires_at| now >= expires_at) {
8✔
127
      self.clear();
1✔
128
    }
7✔
129
  }
8✔
130
}
131

132
#[derive(Clone, Copy, Eq, PartialEq, Debug)]
133
pub enum ActiveBlock {
134
  Help,
135
  Pods,
136
  Containers,
137
  Logs,
138
  Services,
139
  Nodes,
140
  Deployments,
141
  ConfigMaps,
142
  StatefulSets,
143
  ReplicaSets,
144
  Namespaces,
145
  Describe,
146
  Yaml,
147
  Contexts,
148
  Utilization,
149
  Troubleshoot,
150
  Jobs,
151
  DaemonSets,
152
  CronJobs,
153
  DynamicResource,
154
  Secrets,
155
  ReplicationControllers,
156
  StorageClasses,
157
  Roles,
158
  RoleBindings,
159
  ClusterRoles,
160
  ClusterRoleBindings,
161
  Ingresses,
162
  PersistentVolumeClaims,
163
  PersistentVolumes,
164
  NetworkPolicies,
165
  ServiceAccounts,
166
  More,
167
  DynamicView,
168
}
169

170
#[derive(Clone, Eq, PartialEq, Debug)]
171
pub enum RouteId {
172
  Home,
173
  Contexts,
174
  Utilization,
175
  Troubleshoot,
176
  HelpMenu,
177
}
178

179
#[derive(Debug, Clone, Eq, PartialEq)]
180
pub struct Route {
181
  pub id: RouteId,
182
  pub active_block: ActiveBlock,
183
}
184

185
const DEFAULT_ROUTE: Route = Route {
186
  id: RouteId::Home,
187
  active_block: ActiveBlock::Pods,
188
};
189

190
/// Holds CLI version info
191
pub struct Cli {
192
  pub name: String,
193
  pub version: String,
194
  pub status: bool,
195
}
196

197
/// Holds data state for various views
198
pub struct Data {
199
  pub selected: Selected,
200
  pub clis: Vec<Cli>,
201
  pub kubeconfig: Option<Kubeconfig>,
202
  pub contexts: StatefulTable<KubeContext>,
203
  pub active_context: Option<KubeContext>,
204
  pub node_metrics: Vec<KubeNodeMetrics>,
205
  pub logs: LogsState,
206
  pub describe_out: ScrollableTxt,
207
  pub metrics: StatefulTable<(Vec<String>, Option<QtyByQualifier>)>,
208
  pub troubleshoot_findings: StatefulTable<troubleshoot::DisplayFinding>,
209
  pub namespaces: StatefulTable<KubeNs>,
210
  pub nodes: StatefulTable<KubeNode>,
211
  pub pods: StatefulTable<KubePod>,
212
  pub containers: StatefulTable<KubeContainer>,
213
  pub services: StatefulTable<KubeSvc>,
214
  pub config_maps: StatefulTable<KubeConfigMap>,
215
  pub stateful_sets: StatefulTable<KubeStatefulSet>,
216
  pub replica_sets: StatefulTable<KubeReplicaSet>,
217
  pub deployments: StatefulTable<KubeDeployment>,
218
  pub jobs: StatefulTable<KubeJob>,
219
  pub daemon_sets: StatefulTable<KubeDaemonSet>,
220
  pub cronjobs: StatefulTable<KubeCronJob>,
221
  pub secrets: StatefulTable<KubeSecret>,
222
  pub replication_controllers: StatefulTable<KubeReplicationController>,
223
  pub storage_classes: StatefulTable<KubeStorageClass>,
224
  pub roles: StatefulTable<KubeRole>,
225
  pub role_bindings: StatefulTable<KubeRoleBinding>,
226
  pub cluster_roles: StatefulTable<KubeClusterRole>,
227
  pub cluster_role_bindings: StatefulTable<KubeClusterRoleBinding>,
228
  pub ingress: StatefulTable<KubeIngress>,
229
  pub persistent_volume_claims: StatefulTable<KubePVC>,
230
  pub persistent_volumes: StatefulTable<KubePV>,
231
  pub network_policies: StatefulTable<KubeNetworkPolicy>,
232
  pub service_accounts: StatefulTable<KubeSvcAcct>,
233
  pub dynamic_kinds: Vec<KubeDynamicKind>,
234
  pub dynamic_resources: StatefulTable<KubeDynamicResource>,
235
  pub dynamic_resource_cache: DynamicResourceCache,
236
}
237

238
/// selected data items
239
pub struct Selected {
240
  pub ns: Option<String>,
241
  pub pod: Option<String>,
242
  pub container: Option<String>,
243
  pub context: Option<String>,
244
  pub dynamic_kind: Option<KubeDynamicKind>,
245
  /// Label selector for pod drill-down from workload resources
246
  pub pod_selector: Option<String>,
247
  /// Namespace for pod drill-down (the workload resource's namespace)
248
  pub pod_selector_ns: Option<String>,
249
  /// Parent resource name for display in drill-down title breadcrumbs
250
  pub pod_selector_resource: Option<String>,
251
}
252

253
/// Holds main application state
254
pub struct App {
255
  navigation_stack: Vec<Route>,
256
  io_tx: Option<Sender<IoEvent>>,
257
  io_stream_tx: Option<Sender<IoStreamEvent>>,
258
  io_cmd_tx: Option<Sender<IoCmdEvent>>,
259
  log_cancel_tx: watch::Sender<bool>,
260
  loading_counter: u32,
261
  background_cache_pending: bool,
262
  pub title: &'static str,
263
  pub should_quit: bool,
264
  pub main_tabs: TabsState,
265
  pub context_tabs: TabsState,
266
  pub more_resources_menu: StatefulList<(String, ActiveBlock)>,
267
  pub dynamic_resources_menu: StatefulList<(String, ActiveBlock)>,
268
  pub menu_filter: String,
269
  pub menu_filter_active: bool,
270
  pub ns_filter: String,
271
  pub ns_filter_active: bool,
272
  pub show_info_bar: bool,
273
  pub is_streaming: bool,
274
  pub is_routing: bool,
275
  pub tick_until_poll: u64,
276
  pub tick_count: u64,
277
  pub enhanced_graphics: bool,
278
  pub size: Rect,
279
  pub api_error: String,
280
  pub status_message: StatusMessage,
281
  pub light_theme: bool,
282
  pub refresh: bool,
283
  pub log_auto_scroll: bool,
284
  pub log_tail_lines: u32,
285
  pub utilization_group_by: Vec<GroupBy>,
286
  pub help_docs: StatefulTable<Vec<String>>,
287
  pub error_history: VecDeque<ErrorRecord>,
288
  pub config: KdashConfig,
289
  pub data: Data,
290
}
291

292
impl Default for Data {
293
  fn default() -> Self {
74✔
294
    Data {
74✔
295
      clis: vec![],
74✔
296
      kubeconfig: None,
74✔
297
      contexts: StatefulTable::new(),
74✔
298
      active_context: None,
74✔
299
      node_metrics: vec![],
74✔
300
      namespaces: StatefulTable::new(),
74✔
301
      selected: Selected {
74✔
302
        ns: None,
74✔
303
        pod: None,
74✔
304
        container: None,
74✔
305
        context: None,
74✔
306
        dynamic_kind: None,
74✔
307
        pod_selector: None,
74✔
308
        pod_selector_ns: None,
74✔
309
        pod_selector_resource: None,
74✔
310
      },
74✔
311
      logs: LogsState::new(String::default()),
74✔
312
      describe_out: ScrollableTxt::new(),
74✔
313
      metrics: StatefulTable::new(),
74✔
314
      troubleshoot_findings: StatefulTable::new(),
74✔
315
      nodes: StatefulTable::new(),
74✔
316
      pods: StatefulTable::new(),
74✔
317
      containers: StatefulTable::new(),
74✔
318
      services: StatefulTable::new(),
74✔
319
      config_maps: StatefulTable::new(),
74✔
320
      stateful_sets: StatefulTable::new(),
74✔
321
      replica_sets: StatefulTable::new(),
74✔
322
      deployments: StatefulTable::new(),
74✔
323
      jobs: StatefulTable::new(),
74✔
324
      daemon_sets: StatefulTable::new(),
74✔
325
      cronjobs: StatefulTable::new(),
74✔
326
      secrets: StatefulTable::new(),
74✔
327
      replication_controllers: StatefulTable::new(),
74✔
328
      storage_classes: StatefulTable::new(),
74✔
329
      roles: StatefulTable::new(),
74✔
330
      role_bindings: StatefulTable::new(),
74✔
331
      cluster_roles: StatefulTable::new(),
74✔
332
      cluster_role_bindings: StatefulTable::new(),
74✔
333
      ingress: StatefulTable::new(),
74✔
334
      persistent_volume_claims: StatefulTable::new(),
74✔
335
      persistent_volumes: StatefulTable::new(),
74✔
336
      network_policies: StatefulTable::new(),
74✔
337
      service_accounts: StatefulTable::new(),
74✔
338
      dynamic_kinds: vec![],
74✔
339
      dynamic_resources: StatefulTable::new(),
74✔
340
      dynamic_resource_cache: DynamicResourceCache::default(),
74✔
341
    }
74✔
342
  }
74✔
343
}
344

345
impl Default for App {
346
  fn default() -> Self {
72✔
347
    let (log_cancel_tx, _) = watch::channel(false);
72✔
348
    App {
72✔
349
      navigation_stack: vec![DEFAULT_ROUTE],
72✔
350
      io_tx: None,
72✔
351
      io_stream_tx: None,
72✔
352
      io_cmd_tx: None,
72✔
353
      log_cancel_tx,
72✔
354
      title: " KDash - A simple Kubernetes dashboard ",
72✔
355
      should_quit: false,
72✔
356
      main_tabs: TabsState::new(vec![
72✔
357
        TabRoute {
72✔
358
          title: format!(
72✔
359
            "Active Context {}",
72✔
360
            DEFAULT_KEYBINDING.jump_to_current_context.key
72✔
361
          ),
72✔
362
          route: Route {
72✔
363
            active_block: ActiveBlock::Pods,
72✔
364
            id: RouteId::Home,
72✔
365
          },
72✔
366
        },
72✔
367
        TabRoute {
72✔
368
          title: format!(
72✔
369
            "All Contexts {}",
72✔
370
            DEFAULT_KEYBINDING.jump_to_all_context.key
72✔
371
          ),
72✔
372
          route: Route {
72✔
373
            active_block: ActiveBlock::Contexts,
72✔
374
            id: RouteId::Contexts,
72✔
375
          },
72✔
376
        },
72✔
377
        TabRoute {
72✔
378
          title: format!("Utilization {}", DEFAULT_KEYBINDING.jump_to_utilization.key),
72✔
379
          route: Route {
72✔
380
            active_block: ActiveBlock::Utilization,
72✔
381
            id: RouteId::Utilization,
72✔
382
          },
72✔
383
        },
72✔
384
        TabRoute {
72✔
385
          title: format!(
72✔
386
            "Troubleshoot {}",
72✔
387
            DEFAULT_KEYBINDING.jump_to_troubleshoot.key
72✔
388
          ),
72✔
389
          route: Route {
72✔
390
            active_block: ActiveBlock::Troubleshoot,
72✔
391
            id: RouteId::Troubleshoot,
72✔
392
          },
72✔
393
        },
72✔
394
      ]),
72✔
395
      context_tabs: TabsState::new(vec![
72✔
396
        TabRoute {
72✔
397
          title: format!("Pods {}", DEFAULT_KEYBINDING.jump_to_pods.key),
72✔
398
          route: Route {
72✔
399
            active_block: ActiveBlock::Pods,
72✔
400
            id: RouteId::Home,
72✔
401
          },
72✔
402
        },
72✔
403
        TabRoute {
72✔
404
          title: format!("Services {}", DEFAULT_KEYBINDING.jump_to_services.key),
72✔
405
          route: Route {
72✔
406
            active_block: ActiveBlock::Services,
72✔
407
            id: RouteId::Home,
72✔
408
          },
72✔
409
        },
72✔
410
        TabRoute {
72✔
411
          title: format!("Nodes {}", DEFAULT_KEYBINDING.jump_to_nodes.key),
72✔
412
          route: Route {
72✔
413
            active_block: ActiveBlock::Nodes,
72✔
414
            id: RouteId::Home,
72✔
415
          },
72✔
416
        },
72✔
417
        TabRoute {
72✔
418
          title: format!("ConfigMaps {}", DEFAULT_KEYBINDING.jump_to_configmaps.key),
72✔
419
          route: Route {
72✔
420
            active_block: ActiveBlock::ConfigMaps,
72✔
421
            id: RouteId::Home,
72✔
422
          },
72✔
423
        },
72✔
424
        TabRoute {
72✔
425
          title: format!(
72✔
426
            "StatefulSets {}",
72✔
427
            DEFAULT_KEYBINDING.jump_to_statefulsets.key
72✔
428
          ),
72✔
429
          route: Route {
72✔
430
            active_block: ActiveBlock::StatefulSets,
72✔
431
            id: RouteId::Home,
72✔
432
          },
72✔
433
        },
72✔
434
        TabRoute {
72✔
435
          title: format!("ReplicaSets {}", DEFAULT_KEYBINDING.jump_to_replicasets.key),
72✔
436
          route: Route {
72✔
437
            active_block: ActiveBlock::ReplicaSets,
72✔
438
            id: RouteId::Home,
72✔
439
          },
72✔
440
        },
72✔
441
        TabRoute {
72✔
442
          title: format!("Deployments {}", DEFAULT_KEYBINDING.jump_to_deployments.key),
72✔
443
          route: Route {
72✔
444
            active_block: ActiveBlock::Deployments,
72✔
445
            id: RouteId::Home,
72✔
446
          },
72✔
447
        },
72✔
448
        TabRoute {
72✔
449
          title: format!("Jobs {}", DEFAULT_KEYBINDING.jump_to_jobs.key),
72✔
450
          route: Route {
72✔
451
            active_block: ActiveBlock::Jobs,
72✔
452
            id: RouteId::Home,
72✔
453
          },
72✔
454
        },
72✔
455
        TabRoute {
72✔
456
          title: format!("DaemonSets {}", DEFAULT_KEYBINDING.jump_to_daemonsets.key),
72✔
457
          route: Route {
72✔
458
            active_block: ActiveBlock::DaemonSets,
72✔
459
            id: RouteId::Home,
72✔
460
          },
72✔
461
        },
72✔
462
        TabRoute {
72✔
463
          title: format!("More {}", DEFAULT_KEYBINDING.jump_to_more_resources.key),
72✔
464
          route: Route {
72✔
465
            active_block: ActiveBlock::More,
72✔
466
            id: RouteId::Home,
72✔
467
          },
72✔
468
        },
72✔
469
        TabRoute {
72✔
470
          title: format!(
72✔
471
            "Dynamic {}",
72✔
472
            DEFAULT_KEYBINDING.jump_to_dynamic_resources.key
72✔
473
          ),
72✔
474
          route: Route {
72✔
475
            active_block: ActiveBlock::DynamicView,
72✔
476
            id: RouteId::Home,
72✔
477
          },
72✔
478
        },
72✔
479
      ]),
72✔
480
      more_resources_menu: StatefulList::with_items(vec![
72✔
481
        ("CronJobs".into(), ActiveBlock::CronJobs),
72✔
482
        ("Secrets".into(), ActiveBlock::Secrets),
72✔
483
        (
72✔
484
          "ReplicationControllers".into(),
72✔
485
          ActiveBlock::ReplicationControllers,
72✔
486
        ),
72✔
487
        (
72✔
488
          "PersistentVolumeClaims".into(),
72✔
489
          ActiveBlock::PersistentVolumeClaims,
72✔
490
        ),
72✔
491
        ("PersistentVolumes".into(), ActiveBlock::PersistentVolumes),
72✔
492
        ("StorageClasses".into(), ActiveBlock::StorageClasses),
72✔
493
        ("Roles".into(), ActiveBlock::Roles),
72✔
494
        ("RoleBindings".into(), ActiveBlock::RoleBindings),
72✔
495
        ("ClusterRoles".into(), ActiveBlock::ClusterRoles),
72✔
496
        (
72✔
497
          "ClusterRoleBinding".into(),
72✔
498
          ActiveBlock::ClusterRoleBindings,
72✔
499
        ),
72✔
500
        ("ServiceAccounts".into(), ActiveBlock::ServiceAccounts),
72✔
501
        ("Ingresses".into(), ActiveBlock::Ingresses),
72✔
502
        ("NetworkPolicies".into(), ActiveBlock::NetworkPolicies),
72✔
503
      ]),
72✔
504
      dynamic_resources_menu: StatefulList::new(),
72✔
505
      menu_filter: String::new(),
72✔
506
      menu_filter_active: false,
72✔
507
      ns_filter: String::new(),
72✔
508
      ns_filter_active: false,
72✔
509
      show_info_bar: true,
72✔
510
      loading_counter: 0,
72✔
511
      is_streaming: false,
72✔
512
      is_routing: false,
72✔
513
      tick_until_poll: 0,
72✔
514
      tick_count: 0,
72✔
515
      enhanced_graphics: false,
72✔
516
      //   table_cols: 0,
72✔
517
      //   dialog: None,
72✔
518
      //   confirm: false,
72✔
519
      size: Rect::default(),
72✔
520
      api_error: String::new(),
72✔
521
      status_message: StatusMessage::default(),
72✔
522
      light_theme: false,
72✔
523
      refresh: true,
72✔
524
      log_auto_scroll: true,
72✔
525
      log_tail_lines: DEFAULT_LOG_TAIL_LINES,
72✔
526
      utilization_group_by: Self::default_utilization_group_by(),
72✔
527
      help_docs: StatefulTable::with_items(key_binding::get_help_docs()),
72✔
528
      background_cache_pending: false,
72✔
529
      error_history: VecDeque::with_capacity(MAX_ERROR_HISTORY),
72✔
530
      config: KdashConfig::default(),
72✔
531
      data: Data::default(),
72✔
532
    }
72✔
533
  }
72✔
534
}
535

536
impl App {
537
  fn default_utilization_group_by() -> Vec<GroupBy> {
73✔
538
    vec![
73✔
539
      GroupBy::resource,
73✔
540
      GroupBy::node,
73✔
541
      GroupBy::namespace,
73✔
542
      GroupBy::pod,
73✔
543
    ]
544
  }
73✔
545

546
  fn resource_block_for_context_tab(index: usize) -> Option<ActiveBlock> {
11✔
547
    match index {
11✔
548
      0 => Some(ActiveBlock::Pods),
1✔
549
      1 => Some(ActiveBlock::Services),
1✔
550
      2 => Some(ActiveBlock::Nodes),
1✔
551
      3 => Some(ActiveBlock::ConfigMaps),
1✔
552
      4 => Some(ActiveBlock::StatefulSets),
1✔
553
      5 => Some(ActiveBlock::ReplicaSets),
1✔
554
      6 => Some(ActiveBlock::Deployments),
1✔
555
      7 => Some(ActiveBlock::Jobs),
1✔
556
      8 => Some(ActiveBlock::DaemonSets),
1✔
557
      _ => None,
2✔
558
    }
559
  }
11✔
560

561
  pub fn resource_table(&self, block: ActiveBlock) -> Option<&dyn FilterableTable> {
76✔
562
    match block {
76✔
563
      ActiveBlock::Help => Some(&self.help_docs),
7✔
564
      ActiveBlock::Contexts => Some(&self.data.contexts),
7✔
565
      ActiveBlock::Utilization => Some(&self.data.metrics),
6✔
566
      ActiveBlock::Troubleshoot => Some(&self.data.troubleshoot_findings),
6✔
567
      ActiveBlock::Pods => Some(&self.data.pods),
13✔
568
      ActiveBlock::Services => Some(&self.data.services),
1✔
569
      ActiveBlock::Nodes => Some(&self.data.nodes),
1✔
570
      ActiveBlock::ConfigMaps => Some(&self.data.config_maps),
1✔
571
      ActiveBlock::StatefulSets => Some(&self.data.stateful_sets),
1✔
572
      ActiveBlock::ReplicaSets => Some(&self.data.replica_sets),
1✔
573
      ActiveBlock::Deployments => Some(&self.data.deployments),
1✔
574
      ActiveBlock::Jobs => Some(&self.data.jobs),
1✔
575
      ActiveBlock::DaemonSets => Some(&self.data.daemon_sets),
1✔
576
      ActiveBlock::CronJobs => Some(&self.data.cronjobs),
×
577
      ActiveBlock::Secrets => Some(&self.data.secrets),
1✔
578
      ActiveBlock::ReplicationControllers => Some(&self.data.replication_controllers),
×
579
      ActiveBlock::StorageClasses => Some(&self.data.storage_classes),
×
580
      ActiveBlock::Roles => Some(&self.data.roles),
×
581
      ActiveBlock::RoleBindings => Some(&self.data.role_bindings),
×
582
      ActiveBlock::ClusterRoles => Some(&self.data.cluster_roles),
×
583
      ActiveBlock::ClusterRoleBindings => Some(&self.data.cluster_role_bindings),
×
584
      ActiveBlock::Ingresses => Some(&self.data.ingress),
×
585
      ActiveBlock::PersistentVolumeClaims => Some(&self.data.persistent_volume_claims),
×
586
      ActiveBlock::PersistentVolumes => Some(&self.data.persistent_volumes),
×
587
      ActiveBlock::NetworkPolicies => Some(&self.data.network_policies),
×
588
      ActiveBlock::ServiceAccounts => Some(&self.data.service_accounts),
×
589
      ActiveBlock::DynamicResource => Some(&self.data.dynamic_resources),
1✔
590
      _ => None,
27✔
591
    }
592
  }
76✔
593

594
  pub fn resource_table_mut(&mut self, block: ActiveBlock) -> Option<&mut dyn FilterableTable> {
50✔
595
    match block {
50✔
596
      ActiveBlock::Help => Some(&mut self.help_docs),
9✔
597
      ActiveBlock::Contexts => Some(&mut self.data.contexts),
9✔
598
      ActiveBlock::Utilization => Some(&mut self.data.metrics),
8✔
599
      ActiveBlock::Troubleshoot => Some(&mut self.data.troubleshoot_findings),
8✔
600
      ActiveBlock::Pods => Some(&mut self.data.pods),
13✔
601
      ActiveBlock::Services => Some(&mut self.data.services),
×
602
      ActiveBlock::Nodes => Some(&mut self.data.nodes),
×
603
      ActiveBlock::ConfigMaps => Some(&mut self.data.config_maps),
×
604
      ActiveBlock::StatefulSets => Some(&mut self.data.stateful_sets),
×
605
      ActiveBlock::ReplicaSets => Some(&mut self.data.replica_sets),
×
606
      ActiveBlock::Deployments => Some(&mut self.data.deployments),
×
607
      ActiveBlock::Jobs => Some(&mut self.data.jobs),
×
608
      ActiveBlock::DaemonSets => Some(&mut self.data.daemon_sets),
×
609
      ActiveBlock::CronJobs => Some(&mut self.data.cronjobs),
×
610
      ActiveBlock::Secrets => Some(&mut self.data.secrets),
×
611
      ActiveBlock::ReplicationControllers => Some(&mut self.data.replication_controllers),
×
612
      ActiveBlock::StorageClasses => Some(&mut self.data.storage_classes),
×
613
      ActiveBlock::Roles => Some(&mut self.data.roles),
×
614
      ActiveBlock::RoleBindings => Some(&mut self.data.role_bindings),
×
615
      ActiveBlock::ClusterRoles => Some(&mut self.data.cluster_roles),
×
616
      ActiveBlock::ClusterRoleBindings => Some(&mut self.data.cluster_role_bindings),
×
617
      ActiveBlock::Ingresses => Some(&mut self.data.ingress),
×
618
      ActiveBlock::PersistentVolumeClaims => Some(&mut self.data.persistent_volume_claims),
×
619
      ActiveBlock::PersistentVolumes => Some(&mut self.data.persistent_volumes),
×
620
      ActiveBlock::NetworkPolicies => Some(&mut self.data.network_policies),
×
621
      ActiveBlock::ServiceAccounts => Some(&mut self.data.service_accounts),
×
622
      ActiveBlock::DynamicResource => Some(&mut self.data.dynamic_resources),
×
623
      _ => None,
3✔
624
    }
625
  }
50✔
626

627
  pub fn current_resource_table(&self) -> Option<&dyn FilterableTable> {
67✔
628
    self.resource_table(self.get_current_route().active_block)
67✔
629
  }
67✔
630

631
  pub fn current_or_selected_resource_table(&self) -> Option<&dyn FilterableTable> {
1✔
632
    self.current_resource_table().or_else(|| {
1✔
633
      Self::resource_block_for_context_tab(self.context_tabs.index)
×
634
        .and_then(|block| self.resource_table(block))
×
635
    })
×
636
  }
1✔
637

638
  pub fn context_tab_resource_table(&self, index: usize) -> Option<&dyn FilterableTable> {
11✔
639
    Self::resource_block_for_context_tab(index).and_then(|block| self.resource_table(block))
11✔
640
  }
11✔
641

642
  pub fn new(
1✔
643
    io_tx: Sender<IoEvent>,
1✔
644
    io_stream_tx: Sender<IoStreamEvent>,
1✔
645
    io_cmd_tx: Sender<IoCmdEvent>,
1✔
646
    enhanced_graphics: bool,
1✔
647
    tick_until_poll: u64,
1✔
648
    log_tail_lines: u32,
1✔
649
    config: KdashConfig,
1✔
650
  ) -> Self {
1✔
651
    App {
1✔
652
      io_tx: Some(io_tx),
1✔
653
      io_stream_tx: Some(io_stream_tx),
1✔
654
      io_cmd_tx: Some(io_cmd_tx),
1✔
655
      enhanced_graphics,
1✔
656
      tick_until_poll,
1✔
657
      log_tail_lines,
1✔
658
      config,
1✔
659
      ..App::default()
1✔
660
    }
1✔
661
  }
1✔
662

663
  pub fn is_menu_active(&self) -> bool {
143✔
664
    matches!(
114✔
665
      self.get_current_route().active_block,
143✔
666
      ActiveBlock::More | ActiveBlock::DynamicView
667
    )
668
  }
143✔
669

670
  pub fn current_resource_filter_mut(
50✔
671
    &mut self,
50✔
672
  ) -> Option<(&mut String, &mut bool, &mut ratatui::widgets::TableState)> {
50✔
673
    self
50✔
674
      .resource_table_mut(self.get_current_route().active_block)
50✔
675
      .map(FilterableTable::filter_parts_mut)
50✔
676
  }
50✔
677

678
  pub fn deactivate_current_resource_filter(&mut self) {
2✔
679
    if let Some((_, filter_active, _)) = self.current_resource_filter_mut() {
2✔
680
      *filter_active = false;
2✔
681
    }
2✔
682
  }
2✔
683

684
  pub fn is_loading(&self) -> bool {
5✔
685
    self.loading_counter > 0
5✔
686
  }
5✔
687

688
  pub fn loading_complete(&mut self) {
×
689
    self.loading_counter = self.loading_counter.saturating_sub(1);
×
690
  }
×
691

692
  /// Signal any active log stream to stop
693
  pub fn cancel_log_stream(&self) {
4✔
694
    let _ = self.log_cancel_tx.send(true);
4✔
695
  }
4✔
696

697
  /// Get a new receiver for log cancellation.
698
  /// Resets the channel so the next stream starts clean.
699
  pub fn new_log_cancel_rx(&self) -> watch::Receiver<bool> {
×
700
    let _ = self.log_cancel_tx.send(false);
×
701
    self.log_cancel_tx.subscribe()
×
702
  }
×
703

704
  pub fn initial_log_tail_lines(&self) -> i64 {
×
705
    i64::from(self.log_tail_lines)
×
706
  }
×
707

708
  pub fn reset(&mut self) {
1✔
709
    self.cancel_log_stream();
1✔
710
    self.loading_counter = 0;
1✔
711
    self.tick_count = 0;
1✔
712
    self.api_error = String::new();
1✔
713
    self.status_message.clear();
1✔
714
    self.utilization_group_by = Self::default_utilization_group_by();
1✔
715
    self.data = Data::default();
1✔
716
    self.route_home();
1✔
717
  }
1✔
718

719
  pub fn selected_dynamic_cache_key(&self) -> Option<String> {
1✔
720
    self
1✔
721
      .data
1✔
722
      .selected
1✔
723
      .dynamic_kind
1✔
724
      .as_ref()
1✔
725
      .map(|kind| dynamic::dynamic_cache_key(kind, self.data.selected.ns.as_deref()))
1✔
726
  }
1✔
727

728
  pub fn apply_cached_dynamic_resources(&mut self) -> bool {
1✔
729
    let Some(cache_key) = self.selected_dynamic_cache_key() else {
1✔
730
      return false;
×
731
    };
732

733
    let Some(items) = self.data.dynamic_resource_cache.get_cloned(&cache_key) else {
1✔
734
      return false;
×
735
    };
736

737
    self.data.dynamic_resources.set_items(items);
1✔
738
    true
1✔
739
  }
1✔
740

741
  // Send a network event to the network thread
742
  pub async fn dispatch(&mut self, action: IoEvent) {
45✔
743
    // `loading_counter` will be decremented after the async action has finished in network/mod.rs
744
    if let Some(io_tx) = &self.io_tx {
45✔
745
      self.loading_counter += 1;
42✔
746
      if let Err(e) = io_tx.send(action).await {
42✔
747
        self.loading_counter = self.loading_counter.saturating_sub(1);
×
748
        self.handle_error(anyhow!(e));
×
749
      };
42✔
750
    }
3✔
751
  }
45✔
752

753
  // Send a stream event to the stream network thread
754
  pub async fn dispatch_stream(&mut self, action: IoStreamEvent) {
4✔
755
    // `loading_counter` will be decremented after the async action has finished in network/stream.rs
756
    if let Some(io_stream_tx) = &self.io_stream_tx {
4✔
757
      self.loading_counter += 1;
2✔
758
      if let Err(e) = io_stream_tx.send(action).await {
2✔
759
        self.loading_counter = self.loading_counter.saturating_sub(1);
×
760
        self.handle_error(anyhow!(e));
×
761
      };
2✔
762
    }
2✔
763
  }
4✔
764

765
  // Send a cmd event to the cmd runner thread
766
  pub async fn dispatch_cmd(&mut self, action: IoCmdEvent) {
8✔
767
    // `loading_counter` will be decremented after the async action has finished in cmd/mod.rs
768
    if let Some(io_cmd_tx) = &self.io_cmd_tx {
8✔
769
      self.loading_counter += 1;
4✔
770
      if let Err(e) = io_cmd_tx.send(action).await {
4✔
771
        self.loading_counter = self.loading_counter.saturating_sub(1);
×
772
        self.handle_error(anyhow!(e));
×
773
      };
4✔
774
    }
4✔
775
  }
8✔
776

777
  pub fn set_contexts(&mut self, contexts: Vec<KubeContext>) {
2✔
778
    self.data.active_context = contexts.iter().find_map(|ctx| {
3✔
779
      if ctx.is_active {
3✔
780
        Some(ctx.clone())
1✔
781
      } else {
782
        None
2✔
783
      }
784
    });
3✔
785
    self.data.contexts.set_items(contexts);
2✔
786
  }
2✔
787

788
  pub fn handle_error(&mut self, e: anyhow::Error) {
106✔
789
    // Log the full debug output for diagnostics
790
    error!("{:?}", e);
106✔
791
    // Show a cleaned-up message in the UI
792
    let message = crate::app::utils::sanitize_error_message(&e);
106✔
793
    self.record_error(message.clone());
106✔
794
    self.status_message.clear();
106✔
795
    self.api_error = message;
106✔
796
  }
106✔
797

798
  pub fn record_error(&mut self, message: String) {
109✔
799
    self.error_history.push_back(ErrorRecord {
109✔
800
      timestamp: Local::now().format("%Y-%m-%d %H:%M:%S").to_string(),
109✔
801
      message,
109✔
802
    });
109✔
803

804
    while self.error_history.len() > MAX_ERROR_HISTORY {
114✔
805
      self.error_history.pop_front();
5✔
806
    }
5✔
807
  }
109✔
808

809
  pub fn set_status_message(&mut self, message: impl Into<String>) {
1✔
810
    self.api_error.clear();
1✔
811
    self.status_message.show(message);
1✔
812
  }
1✔
813

814
  pub fn clear_status_message(&mut self) {
×
815
    self.status_message.clear();
×
816
  }
×
817

818
  fn clear_expired_status_message(&mut self, now: Instant) {
8✔
819
    self.status_message.clear_if_expired(now);
8✔
820
  }
8✔
821

822
  pub fn push_navigation_stack(&mut self, id: RouteId, active_block: ActiveBlock) {
185✔
823
    self.push_navigation_route(Route { id, active_block });
185✔
824
  }
185✔
825

826
  pub fn push_navigation_route(&mut self, route: Route) {
216✔
827
    self.navigation_stack.push(route);
216✔
828
    if self.navigation_stack.len() > MAX_NAV_STACK {
216✔
829
      self
23✔
830
        .navigation_stack
23✔
831
        .drain(..self.navigation_stack.len() - MAX_NAV_STACK);
23✔
832
    }
193✔
833
    self.is_routing = true;
216✔
834
  }
216✔
835

836
  pub fn pop_navigation_stack(&mut self) -> Option<Route> {
4✔
837
    self.is_routing = true;
4✔
838
    if self.navigation_stack.len() == 1 {
4✔
839
      None
×
840
    } else {
841
      self.navigation_stack.pop()
4✔
842
    }
843
  }
4✔
844

845
  pub fn get_current_route(&self) -> &Route {
396✔
846
    // if for some reason there is no route return the default
847
    self.navigation_stack.last().unwrap_or(&DEFAULT_ROUTE)
396✔
848
  }
396✔
849

850
  pub fn get_prev_route(&self) -> &Route {
2✔
851
    // get the previous route
852
    self.get_nth_route_from_last(1)
2✔
853
  }
2✔
854

855
  pub fn get_nth_route_from_last(&self, index: usize) -> &Route {
2✔
856
    // get the previous route by index
857
    let index = self.navigation_stack.len().saturating_sub(index + 1);
2✔
858
    if index > 0 {
2✔
859
      &self.navigation_stack[index]
×
860
    } else {
861
      &self.navigation_stack[0]
2✔
862
    }
863
  }
2✔
864

865
  pub fn cycle_main_routes(&mut self) {
×
866
    self.main_tabs.next();
×
867
    let route = self.main_tabs.get_active_route().clone();
×
868
    self.push_navigation_route(route);
×
869
  }
×
870

871
  pub fn route_home(&mut self) {
15✔
872
    let route = self.main_tabs.set_index(0).route.clone();
15✔
873
    self.push_navigation_route(route);
15✔
874
  }
15✔
875

876
  pub fn route_contexts(&mut self) {
5✔
877
    let route = self.main_tabs.set_index(1).route.clone();
5✔
878
    self.push_navigation_route(route);
5✔
879
  }
5✔
880

881
  pub fn route_utilization(&mut self) {
1✔
882
    let route = self.main_tabs.set_index(2).route.clone();
1✔
883
    self.push_navigation_route(route);
1✔
884
  }
1✔
885

886
  pub fn route_troubleshoot(&mut self) {
1✔
887
    let route = self.main_tabs.set_index(3).route.clone();
1✔
888
    self.push_navigation_route(route);
1✔
889
  }
1✔
890

891
  /// Navigate from a node to its pods via field selector.
892
  pub async fn dispatch_node_pods(&mut self, node_name: String, route_id: RouteId) {
1✔
893
    self.data.selected.pod_selector = Some(node_name.clone());
1✔
894
    self.data.selected.pod_selector_ns = None;
1✔
895
    self.data.selected.pod_selector_resource = Some("node".into());
1✔
896
    self.dispatch(IoEvent::GetPodsByNode { node_name }).await;
1✔
897
    self.push_navigation_stack(route_id, ActiveBlock::Pods);
1✔
898
  }
1✔
899

900
  /// Navigate from a workload resource to its owned pods via label selector drill-down.
901
  pub async fn dispatch_resource_pods(
1✔
902
    &mut self,
1✔
903
    namespace: String,
1✔
904
    selector: String,
1✔
905
    resource_name: String,
1✔
906
    route_id: RouteId,
1✔
907
  ) {
1✔
908
    self.data.selected.pod_selector = Some(selector.clone());
1✔
909
    self.data.selected.pod_selector_ns = Some(namespace.clone());
1✔
910
    self.data.selected.pod_selector_resource = Some(resource_name);
1✔
911
    self
1✔
912
      .dispatch(IoEvent::GetPodsBySelector {
1✔
913
        namespace,
1✔
914
        selector,
1✔
915
      })
1✔
916
      .await;
1✔
917
    self.push_navigation_stack(route_id, ActiveBlock::Pods);
1✔
918
  }
1✔
919

920
  pub async fn dispatch_pod_logs(&mut self, pod_name: String, route_id: RouteId) {
×
921
    self.cancel_log_stream();
×
922
    self.data.logs = LogsState::new(format!("agg:{}", pod_name));
×
923
    self.push_navigation_stack(route_id, ActiveBlock::Logs);
×
924
    self
×
925
      .dispatch_stream(IoStreamEvent::GetPodAllContainerLogs)
×
926
      .await;
×
927
  }
×
928

929
  pub async fn dispatch_container_logs(&mut self, id: String, route_id: RouteId) {
×
930
    self.cancel_log_stream();
×
931
    self.data.logs = LogsState::new(id);
×
932
    self.push_navigation_stack(route_id, ActiveBlock::Logs);
×
933
    self.dispatch_stream(IoStreamEvent::GetPodLogs(true)).await;
×
934
  }
×
935

936
  /// Start aggregate log streaming from all pods matching a label selector.
937
  pub async fn dispatch_aggregate_logs(
1✔
938
    &mut self,
1✔
939
    name: String,
1✔
940
    namespace: String,
1✔
941
    selector: String,
1✔
942
    resource_name: String,
1✔
943
    route_id: RouteId,
1✔
944
  ) {
1✔
945
    self.cancel_log_stream();
1✔
946
    self.data.selected.pod_selector_resource = Some(resource_name);
1✔
947
    self.data.logs = LogsState::new(format!("agg:{}", name));
1✔
948
    self.push_navigation_stack(route_id, ActiveBlock::Logs);
1✔
949
    self
1✔
950
      .dispatch_stream(IoStreamEvent::GetAggregateLogs {
1✔
951
        namespace,
1✔
952
        selector,
1✔
953
      })
1✔
954
      .await;
1✔
955
  }
1✔
956

957
  pub fn refresh(&mut self) {
3✔
958
    self.refresh = true;
3✔
959
  }
3✔
960

961
  pub fn restore_route_state(
1✔
962
    &mut self,
1✔
963
    main_tab_index: usize,
1✔
964
    context_tab_index: usize,
1✔
965
    route: Route,
1✔
966
  ) {
1✔
967
    self.main_tabs.set_index(main_tab_index);
1✔
968
    self.context_tabs.set_index(context_tab_index);
1✔
969
    self.navigation_stack = vec![route];
1✔
970
    self.is_routing = true;
1✔
971
  }
1✔
972

973
  pub fn refresh_restore_route(&self) -> Route {
6✔
974
    match self.main_tabs.index {
6✔
975
      0 => self.context_tabs.get_active_route().clone(),
5✔
976
      _ => self.main_tabs.get_active_route().clone(),
1✔
977
    }
978
  }
6✔
979

980
  pub fn queue_background_resource_cache(&mut self) {
2✔
981
    self.background_cache_pending = true;
2✔
982
  }
2✔
983

984
  fn background_home_resource_events() -> &'static [IoEvent] {
1✔
985
    &[
1✔
986
      IoEvent::GetPods,
1✔
987
      IoEvent::GetServices,
1✔
988
      IoEvent::GetConfigMaps,
1✔
989
      IoEvent::GetStatefulSets,
1✔
990
      IoEvent::GetReplicaSets,
1✔
991
      IoEvent::GetDeployments,
1✔
992
      IoEvent::GetJobs,
1✔
993
      IoEvent::GetDaemonSets,
1✔
994
      IoEvent::GetCronJobs,
1✔
995
      IoEvent::GetSecrets,
1✔
996
      IoEvent::GetReplicationControllers,
1✔
997
      IoEvent::GetStorageClasses,
1✔
998
      IoEvent::GetRoles,
1✔
999
      IoEvent::GetRoleBindings,
1✔
1000
      IoEvent::GetClusterRoles,
1✔
1001
      IoEvent::GetClusterRoleBinding,
1✔
1002
      IoEvent::GetIngress,
1✔
1003
      IoEvent::GetPvcs,
1✔
1004
      IoEvent::GetPvs,
1✔
1005
      IoEvent::GetServiceAccounts,
1✔
1006
      IoEvent::GetNetworkPolicies,
1✔
1007
    ]
1✔
1008
  }
1✔
1009

1010
  fn active_home_cache_block(&self) -> ActiveBlock {
3✔
1011
    match self.get_current_route().active_block {
3✔
1012
      ActiveBlock::Namespaces | ActiveBlock::Describe | ActiveBlock::Yaml | ActiveBlock::Logs => {
1013
        self.get_prev_route().active_block
×
1014
      }
1015
      active_block => active_block,
3✔
1016
    }
1017
  }
3✔
1018

1019
  fn background_home_event_to_skip(&self) -> Option<IoEvent> {
1✔
1020
    match self.active_home_cache_block() {
1✔
1021
      ActiveBlock::Pods | ActiveBlock::Containers => Some(IoEvent::GetPods),
1✔
1022
      ActiveBlock::Services => Some(IoEvent::GetServices),
×
1023
      ActiveBlock::ConfigMaps => Some(IoEvent::GetConfigMaps),
×
1024
      ActiveBlock::StatefulSets => Some(IoEvent::GetStatefulSets),
×
1025
      ActiveBlock::ReplicaSets => Some(IoEvent::GetReplicaSets),
×
1026
      ActiveBlock::Deployments => Some(IoEvent::GetDeployments),
×
1027
      ActiveBlock::Jobs => Some(IoEvent::GetJobs),
×
1028
      ActiveBlock::DaemonSets => Some(IoEvent::GetDaemonSets),
×
1029
      ActiveBlock::CronJobs => Some(IoEvent::GetCronJobs),
×
1030
      ActiveBlock::Secrets => Some(IoEvent::GetSecrets),
×
1031
      ActiveBlock::ReplicationControllers => Some(IoEvent::GetReplicationControllers),
×
1032
      ActiveBlock::StorageClasses => Some(IoEvent::GetStorageClasses),
×
1033
      ActiveBlock::Roles => Some(IoEvent::GetRoles),
×
1034
      ActiveBlock::RoleBindings => Some(IoEvent::GetRoleBindings),
×
1035
      ActiveBlock::ClusterRoles => Some(IoEvent::GetClusterRoles),
×
1036
      ActiveBlock::ClusterRoleBindings => Some(IoEvent::GetClusterRoleBinding),
×
1037
      ActiveBlock::Ingresses => Some(IoEvent::GetIngress),
×
1038
      ActiveBlock::PersistentVolumeClaims => Some(IoEvent::GetPvcs),
×
1039
      ActiveBlock::PersistentVolumes => Some(IoEvent::GetPvs),
×
1040
      ActiveBlock::ServiceAccounts => Some(IoEvent::GetServiceAccounts),
×
1041
      ActiveBlock::NetworkPolicies => Some(IoEvent::GetNetworkPolicies),
×
1042
      _ => None,
×
1043
    }
1044
  }
1✔
1045

1046
  pub async fn cache_essential_data(&mut self) {
2✔
1047
    info!("Caching essential resource data");
2✔
1048
    self.dispatch(IoEvent::GetNamespaces).await;
2✔
1049
    self.dispatch(IoEvent::GetNodes).await;
2✔
1050

1051
    match self.get_current_route().id {
2✔
1052
      RouteId::Home => {
1053
        self
2✔
1054
          .dispatch_by_active_block(self.active_home_cache_block())
2✔
1055
          .await;
2✔
1056
      }
1057
      RouteId::Utilization => {
1058
        self.dispatch(IoEvent::GetMetrics).await;
×
1059
      }
1060
      RouteId::Troubleshoot => {
1061
        if self.get_current_route().active_block == ActiveBlock::Troubleshoot {
×
1062
          self.dispatch(IoEvent::GetTroubleshootFindings).await;
×
1063
        }
×
1064
      }
1065
      _ => {}
×
1066
    }
1067
  }
2✔
1068

1069
  pub async fn cache_background_resource_data(&mut self) {
1✔
1070
    info!("Caching background resource data");
1✔
1071
    self.dispatch(IoEvent::DiscoverDynamicRes).await;
1✔
1072

1073
    let skip_home_event = if self.get_current_route().id == RouteId::Home {
1✔
1074
      self.background_home_event_to_skip()
1✔
1075
    } else {
1076
      None
×
1077
    };
1078

1079
    for event in Self::background_home_resource_events() {
21✔
1080
      if skip_home_event.as_ref() == Some(event) {
21✔
1081
        continue;
1✔
1082
      }
20✔
1083
      self.dispatch(event.clone()).await;
20✔
1084
    }
1085

1086
    if self.get_current_route().id != RouteId::Utilization {
1✔
1087
      self.dispatch(IoEvent::GetMetrics).await;
1✔
1088
    }
×
1089
  }
1✔
1090

1091
  pub async fn dispatch_by_active_block(&mut self, active_block: ActiveBlock) {
6✔
1092
    match active_block {
6✔
1093
      ActiveBlock::Pods | ActiveBlock::Containers => {
1094
        // If we're in a workload drill-down, refresh using the label selector
1095
        if let (Some(selector), Some(namespace)) = (
×
1096
          self.data.selected.pod_selector.clone(),
4✔
1097
          self.data.selected.pod_selector_ns.clone(),
4✔
1098
        ) {
1099
          self
×
1100
            .dispatch(IoEvent::GetPodsBySelector {
×
1101
              namespace,
×
1102
              selector,
×
1103
            })
×
1104
            .await;
×
1105
        } else {
1106
          self.dispatch(IoEvent::GetPods).await;
4✔
1107
        }
1108
      }
1109
      ActiveBlock::Services => {
1110
        self.dispatch(IoEvent::GetServices).await;
1✔
1111
      }
1112
      ActiveBlock::ConfigMaps => {
1113
        self.dispatch(IoEvent::GetConfigMaps).await;
×
1114
      }
1115
      ActiveBlock::StatefulSets => {
1116
        self.dispatch(IoEvent::GetStatefulSets).await;
×
1117
      }
1118
      ActiveBlock::ReplicaSets => {
1119
        self.dispatch(IoEvent::GetReplicaSets).await;
×
1120
      }
1121
      ActiveBlock::Deployments => {
1122
        self.dispatch(IoEvent::GetDeployments).await;
×
1123
      }
1124
      ActiveBlock::Jobs => {
1125
        self.dispatch(IoEvent::GetJobs).await;
×
1126
      }
1127
      ActiveBlock::DaemonSets => {
1128
        self.dispatch(IoEvent::GetDaemonSets).await;
×
1129
      }
1130
      ActiveBlock::CronJobs => {
1131
        self.dispatch(IoEvent::GetCronJobs).await;
×
1132
      }
1133
      ActiveBlock::Secrets => {
1134
        self.dispatch(IoEvent::GetSecrets).await;
×
1135
      }
1136
      ActiveBlock::ReplicationControllers => {
1137
        self.dispatch(IoEvent::GetReplicationControllers).await;
×
1138
      }
1139
      ActiveBlock::StorageClasses => {
1140
        self.dispatch(IoEvent::GetStorageClasses).await;
×
1141
      }
1142
      ActiveBlock::Roles => {
1143
        self.dispatch(IoEvent::GetRoles).await;
×
1144
      }
1145
      ActiveBlock::RoleBindings => {
1146
        self.dispatch(IoEvent::GetRoleBindings).await;
×
1147
      }
1148
      ActiveBlock::ClusterRoles => {
1149
        self.dispatch(IoEvent::GetClusterRoles).await;
×
1150
      }
1151
      ActiveBlock::ClusterRoleBindings => {
1152
        self.dispatch(IoEvent::GetClusterRoleBinding).await;
×
1153
      }
1154
      ActiveBlock::Ingresses => {
1155
        self.dispatch(IoEvent::GetIngress).await;
×
1156
      }
1157
      ActiveBlock::PersistentVolumeClaims => {
1158
        self.dispatch(IoEvent::GetPvcs).await;
×
1159
      }
1160
      ActiveBlock::PersistentVolumes => {
1161
        self.dispatch(IoEvent::GetPvs).await;
×
1162
      }
1163
      ActiveBlock::ServiceAccounts => {
1164
        self.dispatch(IoEvent::GetServiceAccounts).await;
×
1165
      }
1166
      ActiveBlock::DynamicResource => {
1167
        self.dispatch(IoEvent::GetDynamicRes).await;
×
1168
      }
1169
      ActiveBlock::Logs => {
1170
        if !self.is_streaming {
1✔
1171
          self.dispatch_stream(IoStreamEvent::GetPodLogs(false)).await;
1✔
1172
        }
×
1173
      }
1174
      _ => {}
×
1175
    }
1176
  }
6✔
1177

1178
  pub async fn on_tick(&mut self, first_render: bool) {
6✔
1179
    self.clear_expired_status_message(Instant::now());
6✔
1180

1181
    // Make one time requests on first render or refresh
1182
    let mut did_refresh = false;
6✔
1183
    if self.refresh {
6✔
1184
      if !first_render {
2✔
1185
        self.dispatch(IoEvent::RefreshClient).await;
1✔
1186
        self.dispatch_stream(IoStreamEvent::RefreshClient).await;
1✔
1187
      }
1✔
1188
      self.dispatch(IoEvent::GetKubeConfig).await;
2✔
1189
      self.cache_essential_data().await;
2✔
1190
      self.queue_background_resource_cache();
2✔
1191
      self.refresh = false;
2✔
1192
      did_refresh = true;
2✔
1193
    }
4✔
1194

1195
    if self.background_cache_pending && !first_render && !did_refresh {
6✔
1196
      self.cache_background_resource_data().await;
1✔
1197
      self.background_cache_pending = false;
1✔
1198
    }
5✔
1199

1200
    // make network requests only in intervals to avoid hogging up the network
1201
    if self.tick_count.is_multiple_of(self.tick_until_poll) || self.is_routing {
6✔
1202
      // Safety-net kubeconfig reload (~60s) in case the file watcher misses an event
1203
      if self.tick_until_poll > 0
4✔
1204
        && self.tick_count > 0
4✔
1205
        && self.tick_count.is_multiple_of(self.tick_until_poll * 12)
2✔
1206
      {
1207
        self.dispatch(IoEvent::GetKubeConfig).await;
×
1208
      }
4✔
1209
      // make periodic network calls based on active route and active block to avoid hogging
1210
      match self.get_current_route().id {
4✔
1211
        RouteId::Home => {
1212
          if self.data.clis.is_empty() {
4✔
1213
            self.dispatch_cmd(IoCmdEvent::GetCliInfo).await;
4✔
1214
          }
×
1215
          self.dispatch(IoEvent::GetNamespaces).await;
4✔
1216
          self.dispatch(IoEvent::GetNodes).await;
4✔
1217

1218
          let active_block = self.get_current_route().active_block;
4✔
1219
          if active_block == ActiveBlock::Namespaces {
4✔
1220
            self
×
1221
              .dispatch_by_active_block(self.get_prev_route().active_block)
×
1222
              .await;
×
1223
          } else {
1224
            self.dispatch_by_active_block(active_block).await;
4✔
1225
          }
1226
        }
1227
        RouteId::Utilization => {
1228
          self.dispatch(IoEvent::GetMetrics).await;
×
1229
        }
1230
        RouteId::Troubleshoot => {
1231
          if self.get_current_route().active_block == ActiveBlock::Troubleshoot {
×
1232
            self.dispatch(IoEvent::GetTroubleshootFindings).await;
×
1233
          }
×
1234
        }
1235
        _ => {}
×
1236
      }
1237
      self.is_routing = false;
4✔
1238
    }
2✔
1239

1240
    self.tick_count += 1;
6✔
1241
  }
6✔
1242
}
1243

1244
/// utility methods for tests
1245
#[cfg(test)]
1246
#[macro_use]
1247
mod test_utils {
1248
  use std::{fmt, fs};
1249

1250
  use chrono::{DateTime, Utc};
1251
  use k8s_openapi::apimachinery::pkg::apis::meta::v1::Time;
1252
  use kube::{api::ObjectList, Resource};
1253
  use serde::{de::DeserializeOwned, Serialize};
1254

1255
  use super::models::KubeResource;
1256

1257
  pub fn convert_resource_from_file<K, T>(filename: &str) -> (Vec<T>, Vec<K>)
25✔
1258
  where
25✔
1259
    K: Serialize + Resource + Clone + DeserializeOwned + fmt::Debug,
25✔
1260
    T: KubeResource<K> + From<K>,
25✔
1261
  {
1262
    let res_list = load_resource_from_file(filename);
25✔
1263
    let original_res_list = res_list.items.clone();
25✔
1264

1265
    let resources: Vec<T> = res_list.into_iter().map(K::into).collect::<Vec<_>>();
25✔
1266

1267
    (resources, original_res_list)
25✔
1268
  }
25✔
1269

1270
  pub fn load_resource_from_file<K>(filename: &str) -> ObjectList<K>
28✔
1271
  where
28✔
1272
    K: Clone + DeserializeOwned + fmt::Debug,
28✔
1273
    K: Resource,
28✔
1274
  {
1275
    let yaml = fs::read_to_string(format!("./test_data/{}.yaml", filename))
28✔
1276
      .expect("Something went wrong reading yaml file");
28✔
1277
    assert_ne!(yaml, "".to_string());
28✔
1278

1279
    let res_list: serde_yaml::Result<ObjectList<K>> = serde_yaml::from_str(&yaml);
28✔
1280
    assert!(res_list.is_ok(), "{:?}", res_list.err());
28✔
1281
    res_list.unwrap()
28✔
1282
  }
28✔
1283

1284
  pub fn get_time(s: &str) -> Time {
65✔
1285
    let dt = to_utc(s);
65✔
1286
    Time(k8s_openapi::jiff::Timestamp::from_second(dt.timestamp()).unwrap())
65✔
1287
  }
65✔
1288

1289
  fn to_utc(s: &str) -> DateTime<Utc> {
65✔
1290
    DateTime::parse_from_str(&format!("{} +0000", s), "%Y-%m-%dT%H:%M:%SZ %z")
65✔
1291
      .unwrap()
65✔
1292
      .into()
65✔
1293
  }
65✔
1294

1295
  #[macro_export]
1296
  macro_rules! map_string_object {
1297
    // map-like
1298
    ($($k:expr => $v:expr),* $(,)?) => {
1299
        std::iter::Iterator::collect(IntoIterator::into_iter([$(($k.to_string(), $v),)*]))
1300
    };
1301
  }
1302
}
1303

1304
#[cfg(test)]
1305
mod tests {
1306
  use anyhow::anyhow;
1307
  use tokio::sync::mpsc;
1308

1309
  use super::*;
1310

1311
  #[tokio::test]
1312
  async fn test_on_tick_first_render() {
1✔
1313
    let (sync_io_tx, mut sync_io_rx) = mpsc::channel::<IoEvent>(500);
1✔
1314
    let (sync_io_cmd_tx, mut sync_io_cmd_rx) = mpsc::channel::<IoCmdEvent>(500);
1✔
1315

1316
    let mut app = App {
1✔
1317
      tick_until_poll: 2,
1✔
1318
      io_tx: Some(sync_io_tx),
1✔
1319
      io_cmd_tx: Some(sync_io_cmd_tx),
1✔
1320
      ..App::default()
1✔
1321
    };
1✔
1322

1323
    assert_eq!(app.tick_count, 0);
1✔
1324
    // test first render — essential data loads immediately, background cache is deferred
1325
    app.on_tick(true).await;
1✔
1326
    assert_eq!(sync_io_rx.recv().await.unwrap(), IoEvent::GetKubeConfig);
1✔
1327
    assert_eq!(sync_io_rx.recv().await.unwrap(), IoEvent::GetNamespaces);
1✔
1328
    assert_eq!(sync_io_rx.recv().await.unwrap(), IoEvent::GetNodes);
1✔
1329
    assert_eq!(sync_io_rx.recv().await.unwrap(), IoEvent::GetPods);
1✔
1330
    // periodic polling also fires (tick_count 0 % 2 == 0), fetching active tab data
1331
    assert_eq!(sync_io_rx.recv().await.unwrap(), IoEvent::GetNamespaces);
1✔
1332
    assert_eq!(sync_io_rx.recv().await.unwrap(), IoEvent::GetNodes);
1✔
1333
    assert_eq!(sync_io_rx.recv().await.unwrap(), IoEvent::GetPods);
1✔
1334

1335
    assert_eq!(sync_io_cmd_rx.recv().await.unwrap(), IoCmdEvent::GetCliInfo);
1✔
1336

1337
    assert!(!app.refresh);
1✔
1338
    assert!(app.background_cache_pending);
1✔
1339
    assert!(!app.is_routing);
1✔
1340
    assert_eq!(app.tick_count, 1);
1✔
1341
  }
1✔
1342

1343
  #[test]
1344
  fn test_handle_error_preserves_only_last_100_errors() {
1✔
1345
    let mut app = App::default();
1✔
1346

1347
    for i in 0..105 {
105✔
1348
      app.handle_error(anyhow!("error {}", i));
105✔
1349
    }
105✔
1350

1351
    assert_eq!(app.error_history.len(), MAX_ERROR_HISTORY);
1✔
1352
    assert_eq!(app.error_history.front().unwrap().message, "error 5");
1✔
1353
    assert_eq!(app.error_history.back().unwrap().message, "error 104");
1✔
1354
    assert_eq!(app.api_error, "error 104");
1✔
1355
  }
1✔
1356

1357
  #[test]
1358
  fn test_handle_error_stores_unsanitized_history_but_sanitizes_ui_message() {
1✔
1359
    let mut app = App::default();
1✔
1360

1361
    app.handle_error(anyhow!(
1✔
1362
      "Failed to get namespaced resource kdash::app::pods::KubePod. timeout"
1363
    ));
1364

1365
    assert_eq!(
1✔
1366
      app.error_history.back().unwrap().message,
1✔
1367
      "Failed to get namespaced resource Pod. timeout"
1368
    );
1369
    assert_eq!(
1✔
1370
      app.api_error,
1371
      "Failed to get namespaced resource Pod. timeout"
1372
    );
1373
  }
1✔
1374

1375
  #[test]
1376
  fn test_status_message_expires_after_5_seconds() {
1✔
1377
    let mut app = App::default();
1✔
1378
    let now = Instant::now();
1✔
1379
    app.status_message.duration = Duration::from_secs(5);
1✔
1380
    app
1✔
1381
      .status_message
1✔
1382
      .show_at("Saved recent errors to /tmp/kdash-errors.log", now);
1✔
1383

1384
    app.clear_expired_status_message(now + Duration::from_secs(4));
1✔
1385
    assert_eq!(
1✔
1386
      app.status_message.text(),
1✔
1387
      "Saved recent errors to /tmp/kdash-errors.log"
1388
    );
1389

1390
    app.clear_expired_status_message(now + Duration::from_secs(5));
1✔
1391
    assert!(app.status_message.is_empty());
1✔
1392
  }
1✔
1393

1394
  #[tokio::test]
1395
  async fn test_on_tick_refresh_tick_limit() {
1✔
1396
    let (sync_io_tx, mut sync_io_rx) = mpsc::channel::<IoEvent>(500);
1✔
1397
    let (sync_io_stream_tx, mut sync_io_stream_rx) = mpsc::channel::<IoStreamEvent>(500);
1✔
1398
    let (sync_io_cmd_tx, mut sync_io_cmd_rx) = mpsc::channel::<IoCmdEvent>(500);
1✔
1399

1400
    let mut app = App {
1✔
1401
      tick_until_poll: 2,
1✔
1402
      tick_count: 2,
1✔
1403
      refresh: true,
1✔
1404
      io_tx: Some(sync_io_tx),
1✔
1405
      io_stream_tx: Some(sync_io_stream_tx),
1✔
1406
      io_cmd_tx: Some(sync_io_cmd_tx),
1✔
1407
      ..App::default()
1✔
1408
    };
1✔
1409

1410
    assert_eq!(app.tick_count, 2);
1✔
1411
    // test refresh (non-first-render) — essential data loads now, background cache waits
1412
    app.on_tick(false).await;
1✔
1413
    assert_eq!(sync_io_rx.recv().await.unwrap(), IoEvent::RefreshClient);
1✔
1414
    assert_eq!(sync_io_rx.recv().await.unwrap(), IoEvent::GetKubeConfig);
1✔
1415
    assert_eq!(sync_io_rx.recv().await.unwrap(), IoEvent::GetNamespaces);
1✔
1416
    assert_eq!(sync_io_rx.recv().await.unwrap(), IoEvent::GetNodes);
1✔
1417
    assert_eq!(sync_io_rx.recv().await.unwrap(), IoEvent::GetPods);
1✔
1418
    assert_eq!(sync_io_rx.recv().await.unwrap(), IoEvent::GetNamespaces);
1✔
1419
    assert_eq!(sync_io_rx.recv().await.unwrap(), IoEvent::GetNodes);
1✔
1420
    assert_eq!(sync_io_rx.recv().await.unwrap(), IoEvent::GetPods);
1✔
1421

1422
    assert_eq!(
1✔
1423
      sync_io_stream_rx.recv().await.unwrap(),
1✔
1424
      IoStreamEvent::RefreshClient
1425
    );
1426
    assert_eq!(sync_io_cmd_rx.recv().await.unwrap(), IoCmdEvent::GetCliInfo);
1✔
1427

1428
    assert!(!app.refresh);
1✔
1429
    assert!(app.background_cache_pending);
1✔
1430
    assert!(!app.is_routing);
1✔
1431
    assert_eq!(app.tick_count, 3);
1✔
1432
  }
1✔
1433

1434
  #[tokio::test]
1435
  async fn test_on_tick_dispatches_background_cache_on_followup_tick() {
1✔
1436
    let (sync_io_tx, mut sync_io_rx) = mpsc::channel::<IoEvent>(500);
1✔
1437

1438
    let mut app = App {
1✔
1439
      tick_until_poll: 5,
1✔
1440
      tick_count: 1,
1✔
1441
      refresh: false,
1✔
1442
      background_cache_pending: true,
1✔
1443
      io_tx: Some(sync_io_tx),
1✔
1444
      ..App::default()
1✔
1445
    };
1✔
1446

1447
    app.on_tick(false).await;
1✔
1448

1449
    assert_eq!(
1✔
1450
      sync_io_rx.recv().await.unwrap(),
1✔
1451
      IoEvent::DiscoverDynamicRes
1452
    );
1453
    assert_eq!(sync_io_rx.recv().await.unwrap(), IoEvent::GetServices);
1✔
1454
    assert_eq!(sync_io_rx.recv().await.unwrap(), IoEvent::GetConfigMaps);
1✔
1455
    assert_eq!(sync_io_rx.recv().await.unwrap(), IoEvent::GetStatefulSets);
1✔
1456
    assert_eq!(sync_io_rx.recv().await.unwrap(), IoEvent::GetReplicaSets);
1✔
1457
    assert_eq!(sync_io_rx.recv().await.unwrap(), IoEvent::GetDeployments);
1✔
1458
    assert_eq!(sync_io_rx.recv().await.unwrap(), IoEvent::GetJobs);
1✔
1459
    assert_eq!(sync_io_rx.recv().await.unwrap(), IoEvent::GetDaemonSets);
1✔
1460
    assert_eq!(sync_io_rx.recv().await.unwrap(), IoEvent::GetCronJobs);
1✔
1461
    assert_eq!(sync_io_rx.recv().await.unwrap(), IoEvent::GetSecrets);
1✔
1462
    assert_eq!(
1✔
1463
      sync_io_rx.recv().await.unwrap(),
1✔
1464
      IoEvent::GetReplicationControllers
1465
    );
1466
    assert_eq!(sync_io_rx.recv().await.unwrap(), IoEvent::GetStorageClasses);
1✔
1467
    assert_eq!(sync_io_rx.recv().await.unwrap(), IoEvent::GetRoles);
1✔
1468
    assert_eq!(sync_io_rx.recv().await.unwrap(), IoEvent::GetRoleBindings);
1✔
1469
    assert_eq!(sync_io_rx.recv().await.unwrap(), IoEvent::GetClusterRoles);
1✔
1470
    assert_eq!(
1✔
1471
      sync_io_rx.recv().await.unwrap(),
1✔
1472
      IoEvent::GetClusterRoleBinding
1473
    );
1474
    assert_eq!(sync_io_rx.recv().await.unwrap(), IoEvent::GetIngress);
1✔
1475
    assert_eq!(sync_io_rx.recv().await.unwrap(), IoEvent::GetPvcs);
1✔
1476
    assert_eq!(sync_io_rx.recv().await.unwrap(), IoEvent::GetPvs);
1✔
1477
    assert_eq!(
1✔
1478
      sync_io_rx.recv().await.unwrap(),
1✔
1479
      IoEvent::GetServiceAccounts
1480
    );
1481
    assert_eq!(
1✔
1482
      sync_io_rx.recv().await.unwrap(),
1✔
1483
      IoEvent::GetNetworkPolicies
1484
    );
1485
    assert_eq!(sync_io_rx.recv().await.unwrap(), IoEvent::GetMetrics);
1✔
1486

1487
    assert!(!app.background_cache_pending);
1✔
1488
    assert_eq!(app.tick_count, 2);
1✔
1489
  }
1✔
1490
  #[tokio::test]
1491
  async fn test_on_tick_routing() {
1✔
1492
    let (sync_io_tx, mut sync_io_rx) = mpsc::channel::<IoEvent>(500);
1✔
1493
    let (sync_io_stream_tx, mut sync_io_stream_rx) = mpsc::channel::<IoStreamEvent>(500);
1✔
1494

1495
    let mut app = App {
1✔
1496
      tick_until_poll: 2,
1✔
1497
      tick_count: 2,
1✔
1498
      is_routing: true,
1✔
1499
      refresh: false,
1✔
1500
      io_tx: Some(sync_io_tx),
1✔
1501
      io_stream_tx: Some(sync_io_stream_tx),
1✔
1502
      ..App::default()
1✔
1503
    };
1✔
1504

1505
    app.push_navigation_stack(RouteId::Home, ActiveBlock::Logs);
1✔
1506

1507
    assert_eq!(app.tick_count, 2);
1✔
1508
    // test first render
1509
    app.on_tick(false).await;
1✔
1510
    assert_eq!(sync_io_rx.recv().await.unwrap(), IoEvent::GetNamespaces);
1✔
1511
    assert_eq!(sync_io_rx.recv().await.unwrap(), IoEvent::GetNodes);
1✔
1512

1513
    assert_eq!(
1✔
1514
      sync_io_stream_rx.recv().await.unwrap(),
1✔
1515
      IoStreamEvent::GetPodLogs(false)
1516
    );
1517

1518
    assert!(!app.refresh);
1✔
1519
    assert!(!app.is_routing);
1✔
1520
    assert_eq!(app.tick_count, 3);
1✔
1521
  }
1✔
1522

1523
  #[tokio::test]
1524
  async fn test_on_tick_no_poll_non_refresh() {
1✔
1525
    // When tick_count is not a multiple of tick_until_poll and refresh=false,
1526
    // no IO events should be dispatched (lazy loading: only fetch when needed)
1527
    let (sync_io_tx, mut sync_io_rx) = mpsc::channel::<IoEvent>(500);
1✔
1528

1529
    let mut app = App {
1✔
1530
      tick_until_poll: 5,
1✔
1531
      tick_count: 3, // 3 % 5 != 0, so no polling
1✔
1532
      refresh: false,
1✔
1533
      is_routing: false,
1✔
1534
      io_tx: Some(sync_io_tx),
1✔
1535
      ..App::default()
1✔
1536
    };
1✔
1537

1538
    app.on_tick(false).await;
1✔
1539

1540
    // No IO events should have been dispatched
1541
    assert!(sync_io_rx.try_recv().is_err());
1✔
1542
    assert_eq!(app.tick_count, 4);
1✔
1543
  }
1✔
1544

1545
  #[tokio::test]
1546
  async fn test_on_tick_dispatch_by_active_block() {
1✔
1547
    // Verify that on polling tick, the active block's resource is fetched
1548
    let (sync_io_tx, mut sync_io_rx) = mpsc::channel::<IoEvent>(500);
1✔
1549
    let (sync_io_cmd_tx, mut sync_io_cmd_rx) = mpsc::channel::<IoCmdEvent>(500);
1✔
1550

1551
    let mut app = App {
1✔
1552
      tick_until_poll: 1, // poll every tick
1✔
1553
      tick_count: 0,
1✔
1554
      refresh: false,
1✔
1555
      io_tx: Some(sync_io_tx),
1✔
1556
      io_cmd_tx: Some(sync_io_cmd_tx),
1✔
1557
      ..App::default()
1✔
1558
    };
1✔
1559

1560
    // Navigate to Services tab
1561
    app.push_navigation_stack(RouteId::Home, ActiveBlock::Services);
1✔
1562

1563
    app.on_tick(false).await;
1✔
1564

1565
    // Should dispatch: GetNamespaces, GetNodes (always), then GetServices (active block)
1566
    assert_eq!(sync_io_rx.recv().await.unwrap(), IoEvent::GetNamespaces);
1✔
1567
    assert_eq!(sync_io_rx.recv().await.unwrap(), IoEvent::GetNodes);
1✔
1568
    assert_eq!(sync_io_rx.recv().await.unwrap(), IoEvent::GetServices);
1✔
1569
    assert_eq!(sync_io_cmd_rx.recv().await.unwrap(), IoCmdEvent::GetCliInfo);
1✔
1570
  }
1✔
1571

1572
  #[test]
1573
  fn test_navigation_stack_cap() {
1✔
1574
    let mut app = App::default();
1✔
1575
    // Push more than MAX_NAV_STACK routes
1576
    for _i in 0..150 {
150✔
1577
      app.push_navigation_stack(RouteId::Home, ActiveBlock::Pods);
150✔
1578
    }
150✔
1579
    // Stack should be capped at MAX_NAV_STACK
1580
    assert!(app.navigation_stack.len() <= MAX_NAV_STACK);
1✔
1581
    // Current route should still be the most recently pushed
1582
    assert_eq!(app.get_current_route().active_block, ActiveBlock::Pods);
1✔
1583
  }
1✔
1584

1585
  #[test]
1586
  fn test_navigation_stack_within_cap() {
1✔
1587
    let mut app = App::default();
1✔
1588
    // Push fewer than cap - default already has 1 route
1589
    app.push_navigation_stack(RouteId::Home, ActiveBlock::Services);
1✔
1590
    app.push_navigation_stack(RouteId::Home, ActiveBlock::Pods);
1✔
1591
    assert_eq!(app.navigation_stack.len(), 3); // 1 default + 2 pushed
1✔
1592
    assert_eq!(app.get_current_route().active_block, ActiveBlock::Pods);
1✔
1593
    // Pop should work normally
1594
    let popped = app.pop_navigation_stack();
1✔
1595
    assert!(popped.is_some());
1✔
1596
    assert_eq!(app.get_current_route().active_block, ActiveBlock::Services);
1✔
1597
  }
1✔
1598

1599
  #[test]
1600
  fn test_loading_counter_default() {
1✔
1601
    let app = App::default();
1✔
1602
    assert!(!app.is_loading());
1✔
1603
  }
1✔
1604

1605
  #[test]
1606
  fn test_refresh_restore_route_uses_parent_for_transient_home_views() {
1✔
1607
    let mut app = App::default();
1✔
1608
    app.context_tabs.set_index(1);
1✔
1609
    app.push_navigation_route(app.context_tabs.get_active_route().clone());
1✔
1610
    app.push_navigation_stack(RouteId::Home, ActiveBlock::Describe);
1✔
1611

1612
    assert_eq!(
1✔
1613
      app.refresh_restore_route(),
1✔
1614
      Route {
1615
        id: RouteId::Home,
1616
        active_block: ActiveBlock::Services,
1617
      }
1618
    );
1619
  }
1✔
1620

1621
  #[test]
1622
  fn test_refresh_restore_route_uses_parent_for_help_menu() {
1✔
1623
    let mut app = App::default();
1✔
1624
    app.route_contexts();
1✔
1625
    app.push_navigation_stack(RouteId::HelpMenu, ActiveBlock::Help);
1✔
1626

1627
    assert_eq!(
1✔
1628
      app.refresh_restore_route(),
1✔
1629
      Route {
1630
        id: RouteId::Contexts,
1631
        active_block: ActiveBlock::Contexts,
1632
      }
1633
    );
1634
  }
1✔
1635

1636
  #[test]
1637
  fn test_refresh_restore_route_uses_parent_for_filtered_pod_drilldown() {
1✔
1638
    let mut app = App::default();
1✔
1639
    app.context_tabs.set_index(6);
1✔
1640
    app.push_navigation_route(app.context_tabs.get_active_route().clone());
1✔
1641
    app.data.selected.pod_selector = Some("app=nginx".into());
1✔
1642
    app.push_navigation_stack(RouteId::Home, ActiveBlock::Pods);
1✔
1643

1644
    assert_eq!(
1✔
1645
      app.refresh_restore_route(),
1✔
1646
      Route {
1647
        id: RouteId::Home,
1648
        active_block: ActiveBlock::Deployments,
1649
      }
1650
    );
1651
  }
1✔
1652

1653
  #[test]
1654
  fn test_refresh_restore_route_uses_more_menu_for_more_resources() {
1✔
1655
    let mut app = App::default();
1✔
1656
    app.context_tabs.set_index(9);
1✔
1657
    app.push_navigation_route(app.context_tabs.get_active_route().clone());
1✔
1658
    app.push_navigation_stack(RouteId::Home, ActiveBlock::Secrets);
1✔
1659

1660
    assert_eq!(
1✔
1661
      app.refresh_restore_route(),
1✔
1662
      Route {
1663
        id: RouteId::Home,
1664
        active_block: ActiveBlock::More,
1665
      }
1666
    );
1667
  }
1✔
1668

1669
  #[test]
1670
  fn test_refresh_restore_route_uses_dynamic_menu_for_dynamic_resources() {
1✔
1671
    let mut app = App::default();
1✔
1672
    app.context_tabs.set_index(10);
1✔
1673
    app.push_navigation_route(app.context_tabs.get_active_route().clone());
1✔
1674
    app.push_navigation_stack(RouteId::Home, ActiveBlock::DynamicResource);
1✔
1675

1676
    assert_eq!(
1✔
1677
      app.refresh_restore_route(),
1✔
1678
      Route {
1679
        id: RouteId::Home,
1680
        active_block: ActiveBlock::DynamicView,
1681
      }
1682
    );
1683
  }
1✔
1684

1685
  #[tokio::test]
1686
  async fn test_dispatch_without_sender_does_not_set_loading() {
1✔
1687
    let mut app = App::default();
1✔
1688

1689
    app.dispatch(IoEvent::GetNamespaces).await;
1✔
1690

1691
    assert!(!app.is_loading());
1✔
1692
  }
1✔
1693

1694
  #[tokio::test]
1695
  async fn test_dispatch_stream_without_sender_does_not_set_loading() {
1✔
1696
    let mut app = App::default();
1✔
1697

1698
    app.dispatch_stream(IoStreamEvent::GetPodLogs(false)).await;
1✔
1699

1700
    assert!(!app.is_loading());
1✔
1701
  }
1✔
1702

1703
  #[tokio::test]
1704
  async fn test_dispatch_cmd_without_sender_does_not_set_loading() {
1✔
1705
    let mut app = App::default();
1✔
1706

1707
    app.dispatch_cmd(IoCmdEvent::GetCliInfo).await;
1✔
1708

1709
    assert!(!app.is_loading());
1✔
1710
  }
1✔
1711

1712
  #[test]
1713
  fn test_set_contexts_tracks_active_context() {
1✔
1714
    use crate::app::contexts::KubeContext;
1715

1716
    let mut app = App::default();
1✔
1717
    let contexts = vec![
1✔
1718
      KubeContext {
1✔
1719
        name: "ctx-a".into(),
1✔
1720
        namespace: Some("ns-a".into()),
1✔
1721
        is_active: false,
1✔
1722
        ..Default::default()
1✔
1723
      },
1✔
1724
      KubeContext {
1✔
1725
        name: "ctx-b".into(),
1✔
1726
        namespace: Some("ns-b".into()),
1✔
1727
        is_active: true,
1✔
1728
        ..Default::default()
1✔
1729
      },
1✔
1730
    ];
1731

1732
    app.set_contexts(contexts);
1✔
1733

1734
    let active = app
1✔
1735
      .data
1✔
1736
      .active_context
1✔
1737
      .as_ref()
1✔
1738
      .expect("should have active context");
1✔
1739
    assert_eq!(active.name, "ctx-b");
1✔
1740
    assert_eq!(active.namespace, Some("ns-b".into()));
1✔
1741
    assert_eq!(app.data.contexts.items.len(), 2);
1✔
1742
  }
1✔
1743

1744
  #[test]
1745
  fn test_set_contexts_no_active() {
1✔
1746
    use crate::app::contexts::KubeContext;
1747

1748
    let mut app = App::default();
1✔
1749
    let contexts = vec![KubeContext {
1✔
1750
      name: "ctx-a".into(),
1✔
1751
      is_active: false,
1✔
1752
      ..Default::default()
1✔
1753
    }];
1✔
1754

1755
    app.set_contexts(contexts);
1✔
1756

1757
    assert!(app.data.active_context.is_none());
1✔
1758
  }
1✔
1759
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc