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

kdash-rs / kdash / 24138980530

08 Apr 2026 01:50PM UTC coverage: 65.099% (-0.07%) from 65.167%
24138980530

push

github

web-flow
Merge pull request #504 from sed-i/feature/events

feat: add "Events" under the "More" menu

99 of 176 new or added lines in 5 files covered. (56.25%)

1 existing line in 1 file now uncovered.

8125 of 12481 relevant lines covered (65.1%)

137.96 hits per line

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

86.84
/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 events;
8
pub(crate) mod ingress;
9
pub(crate) mod jobs;
10
pub(crate) mod key_binding;
11
pub(crate) mod metrics;
12
pub(crate) mod models;
13
pub(crate) mod network_policies;
14
pub(crate) mod nodes;
15
pub(crate) mod ns;
16
pub(crate) mod pods;
17
pub(crate) mod pvcs;
18
pub(crate) mod pvs;
19
pub(crate) mod replicasets;
20
pub(crate) mod replication_controllers;
21
pub(crate) mod roles;
22
pub(crate) mod secrets;
23
pub(crate) mod serviceaccounts;
24
pub(crate) mod statefulsets;
25
pub(crate) mod storageclass;
26
pub(crate) mod svcs;
27
pub(crate) mod troubleshoot;
28
pub(crate) mod utils;
29

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

188
const DEFAULT_ROUTE: Route = Route {
189
  id: RouteId::Home,
190
  active_block: ActiveBlock::Pods,
191
};
192

193
/// Holds CLI version info
194
pub struct Cli {
195
  pub name: String,
196
  pub version: String,
197
  pub status: bool,
198
}
199

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

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

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

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

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

542
impl App {
543
  fn default_utilization_group_by() -> Vec<GroupBy> {
73✔
544
    vec![
73✔
545
      GroupBy::resource,
73✔
546
      GroupBy::node,
73✔
547
      GroupBy::namespace,
73✔
548
      GroupBy::pod,
73✔
549
    ]
550
  }
73✔
551

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

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

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

633
  pub fn current_resource_table(&self) -> Option<&dyn FilterableTable> {
67✔
634
    self.resource_table(self.get_current_route().active_block)
67✔
635
  }
67✔
636

637
  pub fn current_or_selected_resource_table(&self) -> Option<&dyn FilterableTable> {
1✔
638
    self.current_resource_table().or_else(|| {
1✔
639
      Self::resource_block_for_context_tab(self.context_tabs.index)
×
640
        .and_then(|block| self.resource_table(block))
×
641
    })
×
642
  }
1✔
643

644
  pub fn context_tab_resource_table(&self, index: usize) -> Option<&dyn FilterableTable> {
11✔
645
    Self::resource_block_for_context_tab(index).and_then(|block| self.resource_table(block))
11✔
646
  }
11✔
647

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

669
  pub fn is_menu_active(&self) -> bool {
143✔
670
    matches!(
114✔
671
      self.get_current_route().active_block,
143✔
672
      ActiveBlock::More | ActiveBlock::DynamicView
673
    )
674
  }
143✔
675

676
  pub fn current_resource_filter_mut(
50✔
677
    &mut self,
50✔
678
  ) -> Option<(&mut String, &mut bool, &mut ratatui::widgets::TableState)> {
50✔
679
    self
50✔
680
      .resource_table_mut(self.get_current_route().active_block)
50✔
681
      .map(FilterableTable::filter_parts_mut)
50✔
682
  }
50✔
683

684
  pub fn deactivate_current_resource_filter(&mut self) {
2✔
685
    if let Some((_, filter_active, _)) = self.current_resource_filter_mut() {
2✔
686
      *filter_active = false;
2✔
687
    }
2✔
688
  }
2✔
689

690
  pub fn is_loading(&self) -> bool {
5✔
691
    self.loading_counter > 0
5✔
692
  }
5✔
693

694
  pub fn loading_complete(&mut self) {
×
695
    self.loading_counter = self.loading_counter.saturating_sub(1);
×
696
  }
×
697

698
  /// Signal any active log stream to stop
699
  pub fn cancel_log_stream(&self) {
4✔
700
    let _ = self.log_cancel_tx.send(true);
4✔
701
  }
4✔
702

703
  /// Get a new receiver for log cancellation.
704
  /// Resets the channel so the next stream starts clean.
705
  pub fn new_log_cancel_rx(&self) -> watch::Receiver<bool> {
×
706
    let _ = self.log_cancel_tx.send(false);
×
707
    self.log_cancel_tx.subscribe()
×
708
  }
×
709

710
  pub fn initial_log_tail_lines(&self) -> i64 {
×
711
    i64::from(self.log_tail_lines)
×
712
  }
×
713

714
  pub fn reset(&mut self) {
1✔
715
    self.cancel_log_stream();
1✔
716
    self.loading_counter = 0;
1✔
717
    self.tick_count = 0;
1✔
718
    self.api_error = String::new();
1✔
719
    self.status_message.clear();
1✔
720
    self.utilization_group_by = Self::default_utilization_group_by();
1✔
721
    self.data = Data::default();
1✔
722
    self.route_home();
1✔
723
  }
1✔
724

725
  pub fn selected_dynamic_cache_key(&self) -> Option<String> {
1✔
726
    self
1✔
727
      .data
1✔
728
      .selected
1✔
729
      .dynamic_kind
1✔
730
      .as_ref()
1✔
731
      .map(|kind| dynamic::dynamic_cache_key(kind, self.data.selected.ns.as_deref()))
1✔
732
  }
1✔
733

734
  pub fn apply_cached_dynamic_resources(&mut self) -> bool {
1✔
735
    let Some(cache_key) = self.selected_dynamic_cache_key() else {
1✔
736
      return false;
×
737
    };
738

739
    let Some(items) = self.data.dynamic_resource_cache.get_cloned(&cache_key) else {
1✔
740
      return false;
×
741
    };
742

743
    self.data.dynamic_resources.set_items(items);
1✔
744
    true
1✔
745
  }
1✔
746

747
  // Send a network event to the network thread
748
  pub async fn dispatch(&mut self, action: IoEvent) {
46✔
749
    // `loading_counter` will be decremented after the async action has finished in network/mod.rs
750
    if let Some(io_tx) = &self.io_tx {
46✔
751
      self.loading_counter += 1;
43✔
752
      if let Err(e) = io_tx.send(action).await {
43✔
753
        self.loading_counter = self.loading_counter.saturating_sub(1);
×
754
        self.handle_error(anyhow!(e));
×
755
      };
43✔
756
    }
3✔
757
  }
46✔
758

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

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

783
  pub fn set_contexts(&mut self, contexts: Vec<KubeContext>) {
2✔
784
    self.data.active_context = contexts.iter().find_map(|ctx| {
3✔
785
      if ctx.is_active {
3✔
786
        Some(ctx.clone())
1✔
787
      } else {
788
        None
2✔
789
      }
790
    });
3✔
791
    self.data.contexts.set_items(contexts);
2✔
792
  }
2✔
793

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

804
  pub fn record_error(&mut self, message: String) {
109✔
805
    self.error_history.push_back(ErrorRecord {
109✔
806
      timestamp: Local::now().format("%Y-%m-%d %H:%M:%S").to_string(),
109✔
807
      message,
109✔
808
    });
109✔
809

810
    while self.error_history.len() > MAX_ERROR_HISTORY {
114✔
811
      self.error_history.pop_front();
5✔
812
    }
5✔
813
  }
109✔
814

815
  pub fn set_status_message(&mut self, message: impl Into<String>) {
1✔
816
    self.api_error.clear();
1✔
817
    self.status_message.show(message);
1✔
818
  }
1✔
819

820
  pub fn clear_status_message(&mut self) {
×
821
    self.status_message.clear();
×
822
  }
×
823

824
  fn clear_expired_status_message(&mut self, now: Instant) {
8✔
825
    self.status_message.clear_if_expired(now);
8✔
826
  }
8✔
827

828
  pub fn push_navigation_stack(&mut self, id: RouteId, active_block: ActiveBlock) {
185✔
829
    self.push_navigation_route(Route { id, active_block });
185✔
830
  }
185✔
831

832
  pub fn push_navigation_route(&mut self, route: Route) {
216✔
833
    self.navigation_stack.push(route);
216✔
834
    if self.navigation_stack.len() > MAX_NAV_STACK {
216✔
835
      self
23✔
836
        .navigation_stack
23✔
837
        .drain(..self.navigation_stack.len() - MAX_NAV_STACK);
23✔
838
    }
193✔
839
    self.is_routing = true;
216✔
840
  }
216✔
841

842
  pub fn pop_navigation_stack(&mut self) -> Option<Route> {
4✔
843
    self.is_routing = true;
4✔
844
    if self.navigation_stack.len() == 1 {
4✔
845
      None
×
846
    } else {
847
      self.navigation_stack.pop()
4✔
848
    }
849
  }
4✔
850

851
  pub fn get_current_route(&self) -> &Route {
396✔
852
    // if for some reason there is no route return the default
853
    self.navigation_stack.last().unwrap_or(&DEFAULT_ROUTE)
396✔
854
  }
396✔
855

856
  pub fn get_prev_route(&self) -> &Route {
2✔
857
    // get the previous route
858
    self.get_nth_route_from_last(1)
2✔
859
  }
2✔
860

861
  pub fn get_nth_route_from_last(&self, index: usize) -> &Route {
2✔
862
    // get the previous route by index
863
    let index = self.navigation_stack.len().saturating_sub(index + 1);
2✔
864
    if index > 0 {
2✔
865
      &self.navigation_stack[index]
×
866
    } else {
867
      &self.navigation_stack[0]
2✔
868
    }
869
  }
2✔
870

871
  pub fn cycle_main_routes(&mut self) {
×
872
    self.main_tabs.next();
×
873
    let route = self.main_tabs.get_active_route().clone();
×
874
    self.push_navigation_route(route);
×
875
  }
×
876

877
  pub fn route_home(&mut self) {
15✔
878
    let route = self.main_tabs.set_index(0).route.clone();
15✔
879
    self.push_navigation_route(route);
15✔
880
  }
15✔
881

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

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

892
  pub fn route_troubleshoot(&mut self) {
1✔
893
    let route = self.main_tabs.set_index(3).route.clone();
1✔
894
    self.push_navigation_route(route);
1✔
895
  }
1✔
896

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

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

926
  pub async fn dispatch_pod_logs(&mut self, pod_name: String, route_id: RouteId) {
×
927
    self.cancel_log_stream();
×
928
    self.data.logs = LogsState::new(format!("agg:{}", pod_name));
×
929
    self.push_navigation_stack(route_id, ActiveBlock::Logs);
×
930
    self
×
931
      .dispatch_stream(IoStreamEvent::GetPodAllContainerLogs)
×
932
      .await;
×
933
  }
×
934

935
  pub async fn dispatch_container_logs(&mut self, id: String, route_id: RouteId) {
×
936
    self.cancel_log_stream();
×
937
    self.data.logs = LogsState::new(id);
×
938
    self.push_navigation_stack(route_id, ActiveBlock::Logs);
×
939
    self.dispatch_stream(IoStreamEvent::GetPodLogs(true)).await;
×
940
  }
×
941

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

963
  pub fn refresh(&mut self) {
3✔
964
    self.refresh = true;
3✔
965
  }
3✔
966

967
  pub fn restore_route_state(
1✔
968
    &mut self,
1✔
969
    main_tab_index: usize,
1✔
970
    context_tab_index: usize,
1✔
971
    route: Route,
1✔
972
  ) {
1✔
973
    self.main_tabs.set_index(main_tab_index);
1✔
974
    self.context_tabs.set_index(context_tab_index);
1✔
975
    self.navigation_stack = vec![route];
1✔
976
    self.is_routing = true;
1✔
977
  }
1✔
978

979
  pub fn refresh_restore_route(&self) -> Route {
6✔
980
    match self.main_tabs.index {
6✔
981
      0 => self.context_tabs.get_active_route().clone(),
5✔
982
      _ => self.main_tabs.get_active_route().clone(),
1✔
983
    }
984
  }
6✔
985

986
  pub fn queue_background_resource_cache(&mut self) {
2✔
987
    self.background_cache_pending = true;
2✔
988
  }
2✔
989

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

1017
  fn active_home_cache_block(&self) -> ActiveBlock {
3✔
1018
    match self.get_current_route().active_block {
3✔
1019
      ActiveBlock::Namespaces | ActiveBlock::Describe | ActiveBlock::Yaml | ActiveBlock::Logs => {
1020
        self.get_prev_route().active_block
×
1021
      }
1022
      active_block => active_block,
3✔
1023
    }
1024
  }
3✔
1025

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

1054
  pub async fn cache_essential_data(&mut self) {
2✔
1055
    info!("Caching essential resource data");
2✔
1056
    self.dispatch(IoEvent::GetNamespaces).await;
2✔
1057
    self.dispatch(IoEvent::GetNodes).await;
2✔
1058

1059
    match self.get_current_route().id {
2✔
1060
      RouteId::Home => {
1061
        self
2✔
1062
          .dispatch_by_active_block(self.active_home_cache_block())
2✔
1063
          .await;
2✔
1064
      }
1065
      RouteId::Utilization => {
1066
        self.dispatch(IoEvent::GetMetrics).await;
×
1067
      }
1068
      RouteId::Troubleshoot => {
1069
        if self.get_current_route().active_block == ActiveBlock::Troubleshoot {
×
1070
          self.dispatch(IoEvent::GetTroubleshootFindings).await;
×
1071
        }
×
1072
      }
1073
      _ => {}
×
1074
    }
1075
  }
2✔
1076

1077
  pub async fn cache_background_resource_data(&mut self) {
1✔
1078
    info!("Caching background resource data");
1✔
1079
    self.dispatch(IoEvent::DiscoverDynamicRes).await;
1✔
1080

1081
    let skip_home_event = if self.get_current_route().id == RouteId::Home {
1✔
1082
      self.background_home_event_to_skip()
1✔
1083
    } else {
1084
      None
×
1085
    };
1086

1087
    for event in Self::background_home_resource_events() {
22✔
1088
      if skip_home_event.as_ref() == Some(event) {
22✔
1089
        continue;
1✔
1090
      }
21✔
1091
      self.dispatch(event.clone()).await;
21✔
1092
    }
1093

1094
    if self.get_current_route().id != RouteId::Utilization {
1✔
1095
      self.dispatch(IoEvent::GetMetrics).await;
1✔
1096
    }
×
1097
  }
1✔
1098

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

1189
  pub async fn on_tick(&mut self, first_render: bool) {
6✔
1190
    self.clear_expired_status_message(Instant::now());
6✔
1191

1192
    // Make one time requests on first render or refresh
1193
    let mut did_refresh = false;
6✔
1194
    if self.refresh {
6✔
1195
      if !first_render {
2✔
1196
        self.dispatch(IoEvent::RefreshClient).await;
1✔
1197
        self.dispatch_stream(IoStreamEvent::RefreshClient).await;
1✔
1198
      }
1✔
1199
      self.dispatch(IoEvent::GetKubeConfig).await;
2✔
1200
      self.cache_essential_data().await;
2✔
1201
      self.queue_background_resource_cache();
2✔
1202
      self.refresh = false;
2✔
1203
      did_refresh = true;
2✔
1204
    }
4✔
1205

1206
    if self.background_cache_pending && !first_render && !did_refresh {
6✔
1207
      self.cache_background_resource_data().await;
1✔
1208
      self.background_cache_pending = false;
1✔
1209
    }
5✔
1210

1211
    // make network requests only in intervals to avoid hogging up the network
1212
    if self.tick_count.is_multiple_of(self.tick_until_poll) || self.is_routing {
6✔
1213
      // Safety-net kubeconfig reload (~60s) in case the file watcher misses an event
1214
      if self.tick_until_poll > 0
4✔
1215
        && self.tick_count > 0
4✔
1216
        && self.tick_count.is_multiple_of(self.tick_until_poll * 12)
2✔
1217
      {
1218
        self.dispatch(IoEvent::GetKubeConfig).await;
×
1219
      }
4✔
1220
      // make periodic network calls based on active route and active block to avoid hogging
1221
      match self.get_current_route().id {
4✔
1222
        RouteId::Home => {
1223
          if self.data.clis.is_empty() {
4✔
1224
            self.dispatch_cmd(IoCmdEvent::GetCliInfo).await;
4✔
1225
          }
×
1226
          self.dispatch(IoEvent::GetNamespaces).await;
4✔
1227
          self.dispatch(IoEvent::GetNodes).await;
4✔
1228

1229
          let active_block = self.get_current_route().active_block;
4✔
1230
          if active_block == ActiveBlock::Namespaces {
4✔
1231
            self
×
1232
              .dispatch_by_active_block(self.get_prev_route().active_block)
×
1233
              .await;
×
1234
          } else {
1235
            self.dispatch_by_active_block(active_block).await;
4✔
1236
          }
1237
        }
1238
        RouteId::Utilization => {
1239
          self.dispatch(IoEvent::GetMetrics).await;
×
1240
        }
1241
        RouteId::Troubleshoot => {
1242
          if self.get_current_route().active_block == ActiveBlock::Troubleshoot {
×
1243
            self.dispatch(IoEvent::GetTroubleshootFindings).await;
×
1244
          }
×
1245
        }
1246
        _ => {}
×
1247
      }
1248
      self.is_routing = false;
4✔
1249
    }
2✔
1250

1251
    self.tick_count += 1;
6✔
1252
  }
6✔
1253
}
1254

1255
/// utility methods for tests
1256
#[cfg(test)]
1257
#[macro_use]
1258
mod test_utils {
1259
  use std::{fmt, fs};
1260

1261
  use chrono::{DateTime, Utc};
1262
  use k8s_openapi::apimachinery::pkg::apis::meta::v1::Time;
1263
  use kube::{api::ObjectList, Resource};
1264
  use serde::{de::DeserializeOwned, Serialize};
1265

1266
  use super::models::KubeResource;
1267

1268
  pub fn convert_resource_from_file<K, T>(filename: &str) -> (Vec<T>, Vec<K>)
26✔
1269
  where
26✔
1270
    K: Serialize + Resource + Clone + DeserializeOwned + fmt::Debug,
26✔
1271
    T: KubeResource<K> + From<K>,
26✔
1272
  {
1273
    let res_list = load_resource_from_file(filename);
26✔
1274
    let original_res_list = res_list.items.clone();
26✔
1275

1276
    let resources: Vec<T> = res_list.into_iter().map(K::into).collect::<Vec<_>>();
26✔
1277

1278
    (resources, original_res_list)
26✔
1279
  }
26✔
1280

1281
  pub fn load_resource_from_file<K>(filename: &str) -> ObjectList<K>
29✔
1282
  where
29✔
1283
    K: Clone + DeserializeOwned + fmt::Debug,
29✔
1284
    K: Resource,
29✔
1285
  {
1286
    let yaml = fs::read_to_string(format!("./test_data/{}.yaml", filename))
29✔
1287
      .expect("Something went wrong reading yaml file");
29✔
1288
    assert_ne!(yaml, "".to_string());
29✔
1289

1290
    let res_list: serde_yaml::Result<ObjectList<K>> = serde_yaml::from_str(&yaml);
29✔
1291
    assert!(res_list.is_ok(), "{:?}", res_list.err());
29✔
1292
    res_list.unwrap()
29✔
1293
  }
29✔
1294

1295
  pub fn get_time(s: &str) -> Time {
73✔
1296
    let dt = to_utc(s);
73✔
1297
    Time(k8s_openapi::jiff::Timestamp::from_second(dt.timestamp()).unwrap())
73✔
1298
  }
73✔
1299

1300
  fn to_utc(s: &str) -> DateTime<Utc> {
73✔
1301
    DateTime::parse_from_str(&format!("{} +0000", s), "%Y-%m-%dT%H:%M:%SZ %z")
73✔
1302
      .unwrap()
73✔
1303
      .into()
73✔
1304
  }
73✔
1305

1306
  #[macro_export]
1307
  macro_rules! map_string_object {
1308
    // map-like
1309
    ($($k:expr => $v:expr),* $(,)?) => {
1310
        std::iter::Iterator::collect(IntoIterator::into_iter([$(($k.to_string(), $v),)*]))
1311
    };
1312
  }
1313
}
1314

1315
#[cfg(test)]
1316
mod tests {
1317
  use anyhow::anyhow;
1318
  use tokio::sync::mpsc;
1319

1320
  use super::*;
1321

1322
  #[tokio::test]
1323
  async fn test_on_tick_first_render() {
1✔
1324
    let (sync_io_tx, mut sync_io_rx) = mpsc::channel::<IoEvent>(500);
1✔
1325
    let (sync_io_cmd_tx, mut sync_io_cmd_rx) = mpsc::channel::<IoCmdEvent>(500);
1✔
1326

1327
    let mut app = App {
1✔
1328
      tick_until_poll: 2,
1✔
1329
      io_tx: Some(sync_io_tx),
1✔
1330
      io_cmd_tx: Some(sync_io_cmd_tx),
1✔
1331
      ..App::default()
1✔
1332
    };
1✔
1333

1334
    assert_eq!(app.tick_count, 0);
1✔
1335
    // test first render — essential data loads immediately, background cache is deferred
1336
    app.on_tick(true).await;
1✔
1337
    assert_eq!(sync_io_rx.recv().await.unwrap(), IoEvent::GetKubeConfig);
1✔
1338
    assert_eq!(sync_io_rx.recv().await.unwrap(), IoEvent::GetNamespaces);
1✔
1339
    assert_eq!(sync_io_rx.recv().await.unwrap(), IoEvent::GetNodes);
1✔
1340
    assert_eq!(sync_io_rx.recv().await.unwrap(), IoEvent::GetPods);
1✔
1341
    // periodic polling also fires (tick_count 0 % 2 == 0), fetching active tab data
1342
    assert_eq!(sync_io_rx.recv().await.unwrap(), IoEvent::GetNamespaces);
1✔
1343
    assert_eq!(sync_io_rx.recv().await.unwrap(), IoEvent::GetNodes);
1✔
1344
    assert_eq!(sync_io_rx.recv().await.unwrap(), IoEvent::GetPods);
1✔
1345

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

1348
    assert!(!app.refresh);
1✔
1349
    assert!(app.background_cache_pending);
1✔
1350
    assert!(!app.is_routing);
1✔
1351
    assert_eq!(app.tick_count, 1);
1✔
1352
  }
1✔
1353

1354
  #[test]
1355
  fn test_handle_error_preserves_only_last_100_errors() {
1✔
1356
    let mut app = App::default();
1✔
1357

1358
    for i in 0..105 {
105✔
1359
      app.handle_error(anyhow!("error {}", i));
105✔
1360
    }
105✔
1361

1362
    assert_eq!(app.error_history.len(), MAX_ERROR_HISTORY);
1✔
1363
    assert_eq!(app.error_history.front().unwrap().message, "error 5");
1✔
1364
    assert_eq!(app.error_history.back().unwrap().message, "error 104");
1✔
1365
    assert_eq!(app.api_error, "error 104");
1✔
1366
  }
1✔
1367

1368
  #[test]
1369
  fn test_handle_error_stores_unsanitized_history_but_sanitizes_ui_message() {
1✔
1370
    let mut app = App::default();
1✔
1371

1372
    app.handle_error(anyhow!(
1✔
1373
      "Failed to get namespaced resource kdash::app::pods::KubePod. timeout"
1374
    ));
1375

1376
    assert_eq!(
1✔
1377
      app.error_history.back().unwrap().message,
1✔
1378
      "Failed to get namespaced resource Pod. timeout"
1379
    );
1380
    assert_eq!(
1✔
1381
      app.api_error,
1382
      "Failed to get namespaced resource Pod. timeout"
1383
    );
1384
  }
1✔
1385

1386
  #[test]
1387
  fn test_status_message_expires_after_5_seconds() {
1✔
1388
    let mut app = App::default();
1✔
1389
    let now = Instant::now();
1✔
1390
    app.status_message.duration = Duration::from_secs(5);
1✔
1391
    app
1✔
1392
      .status_message
1✔
1393
      .show_at("Saved recent errors to /tmp/kdash-errors.log", now);
1✔
1394

1395
    app.clear_expired_status_message(now + Duration::from_secs(4));
1✔
1396
    assert_eq!(
1✔
1397
      app.status_message.text(),
1✔
1398
      "Saved recent errors to /tmp/kdash-errors.log"
1399
    );
1400

1401
    app.clear_expired_status_message(now + Duration::from_secs(5));
1✔
1402
    assert!(app.status_message.is_empty());
1✔
1403
  }
1✔
1404

1405
  #[tokio::test]
1406
  async fn test_on_tick_refresh_tick_limit() {
1✔
1407
    let (sync_io_tx, mut sync_io_rx) = mpsc::channel::<IoEvent>(500);
1✔
1408
    let (sync_io_stream_tx, mut sync_io_stream_rx) = mpsc::channel::<IoStreamEvent>(500);
1✔
1409
    let (sync_io_cmd_tx, mut sync_io_cmd_rx) = mpsc::channel::<IoCmdEvent>(500);
1✔
1410

1411
    let mut app = App {
1✔
1412
      tick_until_poll: 2,
1✔
1413
      tick_count: 2,
1✔
1414
      refresh: true,
1✔
1415
      io_tx: Some(sync_io_tx),
1✔
1416
      io_stream_tx: Some(sync_io_stream_tx),
1✔
1417
      io_cmd_tx: Some(sync_io_cmd_tx),
1✔
1418
      ..App::default()
1✔
1419
    };
1✔
1420

1421
    assert_eq!(app.tick_count, 2);
1✔
1422
    // test refresh (non-first-render) — essential data loads now, background cache waits
1423
    app.on_tick(false).await;
1✔
1424
    assert_eq!(sync_io_rx.recv().await.unwrap(), IoEvent::RefreshClient);
1✔
1425
    assert_eq!(sync_io_rx.recv().await.unwrap(), IoEvent::GetKubeConfig);
1✔
1426
    assert_eq!(sync_io_rx.recv().await.unwrap(), IoEvent::GetNamespaces);
1✔
1427
    assert_eq!(sync_io_rx.recv().await.unwrap(), IoEvent::GetNodes);
1✔
1428
    assert_eq!(sync_io_rx.recv().await.unwrap(), IoEvent::GetPods);
1✔
1429
    assert_eq!(sync_io_rx.recv().await.unwrap(), IoEvent::GetNamespaces);
1✔
1430
    assert_eq!(sync_io_rx.recv().await.unwrap(), IoEvent::GetNodes);
1✔
1431
    assert_eq!(sync_io_rx.recv().await.unwrap(), IoEvent::GetPods);
1✔
1432

1433
    assert_eq!(
1✔
1434
      sync_io_stream_rx.recv().await.unwrap(),
1✔
1435
      IoStreamEvent::RefreshClient
1436
    );
1437
    assert_eq!(sync_io_cmd_rx.recv().await.unwrap(), IoCmdEvent::GetCliInfo);
1✔
1438

1439
    assert!(!app.refresh);
1✔
1440
    assert!(app.background_cache_pending);
1✔
1441
    assert!(!app.is_routing);
1✔
1442
    assert_eq!(app.tick_count, 3);
1✔
1443
  }
1✔
1444

1445
  #[tokio::test]
1446
  async fn test_on_tick_dispatches_background_cache_on_followup_tick() {
1✔
1447
    let (sync_io_tx, mut sync_io_rx) = mpsc::channel::<IoEvent>(500);
1✔
1448

1449
    let mut app = App {
1✔
1450
      tick_until_poll: 5,
1✔
1451
      tick_count: 1,
1✔
1452
      refresh: false,
1✔
1453
      background_cache_pending: true,
1✔
1454
      io_tx: Some(sync_io_tx),
1✔
1455
      ..App::default()
1✔
1456
    };
1✔
1457

1458
    app.on_tick(false).await;
1✔
1459

1460
    assert_eq!(
1✔
1461
      sync_io_rx.recv().await.unwrap(),
1✔
1462
      IoEvent::DiscoverDynamicRes
1463
    );
1464
    assert_eq!(sync_io_rx.recv().await.unwrap(), IoEvent::GetServices);
1✔
1465
    assert_eq!(sync_io_rx.recv().await.unwrap(), IoEvent::GetConfigMaps);
1✔
1466
    assert_eq!(sync_io_rx.recv().await.unwrap(), IoEvent::GetStatefulSets);
1✔
1467
    assert_eq!(sync_io_rx.recv().await.unwrap(), IoEvent::GetReplicaSets);
1✔
1468
    assert_eq!(sync_io_rx.recv().await.unwrap(), IoEvent::GetDeployments);
1✔
1469
    assert_eq!(sync_io_rx.recv().await.unwrap(), IoEvent::GetJobs);
1✔
1470
    assert_eq!(sync_io_rx.recv().await.unwrap(), IoEvent::GetDaemonSets);
1✔
1471
    assert_eq!(sync_io_rx.recv().await.unwrap(), IoEvent::GetCronJobs);
1✔
1472
    assert_eq!(sync_io_rx.recv().await.unwrap(), IoEvent::GetSecrets);
1✔
1473
    assert_eq!(
1✔
1474
      sync_io_rx.recv().await.unwrap(),
1✔
1475
      IoEvent::GetReplicationControllers
1476
    );
1477
    assert_eq!(sync_io_rx.recv().await.unwrap(), IoEvent::GetStorageClasses);
1✔
1478
    assert_eq!(sync_io_rx.recv().await.unwrap(), IoEvent::GetRoles);
1✔
1479
    assert_eq!(sync_io_rx.recv().await.unwrap(), IoEvent::GetRoleBindings);
1✔
1480
    assert_eq!(sync_io_rx.recv().await.unwrap(), IoEvent::GetClusterRoles);
1✔
1481
    assert_eq!(
1✔
1482
      sync_io_rx.recv().await.unwrap(),
1✔
1483
      IoEvent::GetClusterRoleBinding
1484
    );
1485
    assert_eq!(sync_io_rx.recv().await.unwrap(), IoEvent::GetIngress);
1✔
1486
    assert_eq!(sync_io_rx.recv().await.unwrap(), IoEvent::GetPvcs);
1✔
1487
    assert_eq!(sync_io_rx.recv().await.unwrap(), IoEvent::GetPvs);
1✔
1488
    assert_eq!(
1✔
1489
      sync_io_rx.recv().await.unwrap(),
1✔
1490
      IoEvent::GetServiceAccounts
1491
    );
1492
    assert_eq!(sync_io_rx.recv().await.unwrap(), IoEvent::GetEvents);
1✔
1493
    assert_eq!(
1✔
1494
      sync_io_rx.recv().await.unwrap(),
1✔
1495
      IoEvent::GetNetworkPolicies
1496
    );
1497
    assert_eq!(sync_io_rx.recv().await.unwrap(), IoEvent::GetMetrics);
1✔
1498

1499
    assert!(!app.background_cache_pending);
1✔
1500
    assert_eq!(app.tick_count, 2);
1✔
1501
  }
1✔
1502
  #[tokio::test]
1503
  async fn test_on_tick_routing() {
1✔
1504
    let (sync_io_tx, mut sync_io_rx) = mpsc::channel::<IoEvent>(500);
1✔
1505
    let (sync_io_stream_tx, mut sync_io_stream_rx) = mpsc::channel::<IoStreamEvent>(500);
1✔
1506

1507
    let mut app = App {
1✔
1508
      tick_until_poll: 2,
1✔
1509
      tick_count: 2,
1✔
1510
      is_routing: true,
1✔
1511
      refresh: false,
1✔
1512
      io_tx: Some(sync_io_tx),
1✔
1513
      io_stream_tx: Some(sync_io_stream_tx),
1✔
1514
      ..App::default()
1✔
1515
    };
1✔
1516

1517
    app.push_navigation_stack(RouteId::Home, ActiveBlock::Logs);
1✔
1518

1519
    assert_eq!(app.tick_count, 2);
1✔
1520
    // test first render
1521
    app.on_tick(false).await;
1✔
1522
    assert_eq!(sync_io_rx.recv().await.unwrap(), IoEvent::GetNamespaces);
1✔
1523
    assert_eq!(sync_io_rx.recv().await.unwrap(), IoEvent::GetNodes);
1✔
1524

1525
    assert_eq!(
1✔
1526
      sync_io_stream_rx.recv().await.unwrap(),
1✔
1527
      IoStreamEvent::GetPodLogs(false)
1528
    );
1529

1530
    assert!(!app.refresh);
1✔
1531
    assert!(!app.is_routing);
1✔
1532
    assert_eq!(app.tick_count, 3);
1✔
1533
  }
1✔
1534

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

1541
    let mut app = App {
1✔
1542
      tick_until_poll: 5,
1✔
1543
      tick_count: 3, // 3 % 5 != 0, so no polling
1✔
1544
      refresh: false,
1✔
1545
      is_routing: false,
1✔
1546
      io_tx: Some(sync_io_tx),
1✔
1547
      ..App::default()
1✔
1548
    };
1✔
1549

1550
    app.on_tick(false).await;
1✔
1551

1552
    // No IO events should have been dispatched
1553
    assert!(sync_io_rx.try_recv().is_err());
1✔
1554
    assert_eq!(app.tick_count, 4);
1✔
1555
  }
1✔
1556

1557
  #[tokio::test]
1558
  async fn test_on_tick_dispatch_by_active_block() {
1✔
1559
    // Verify that on polling tick, the active block's resource is fetched
1560
    let (sync_io_tx, mut sync_io_rx) = mpsc::channel::<IoEvent>(500);
1✔
1561
    let (sync_io_cmd_tx, mut sync_io_cmd_rx) = mpsc::channel::<IoCmdEvent>(500);
1✔
1562

1563
    let mut app = App {
1✔
1564
      tick_until_poll: 1, // poll every tick
1✔
1565
      tick_count: 0,
1✔
1566
      refresh: false,
1✔
1567
      io_tx: Some(sync_io_tx),
1✔
1568
      io_cmd_tx: Some(sync_io_cmd_tx),
1✔
1569
      ..App::default()
1✔
1570
    };
1✔
1571

1572
    // Navigate to Services tab
1573
    app.push_navigation_stack(RouteId::Home, ActiveBlock::Services);
1✔
1574

1575
    app.on_tick(false).await;
1✔
1576

1577
    // Should dispatch: GetNamespaces, GetNodes (always), then GetServices (active block)
1578
    assert_eq!(sync_io_rx.recv().await.unwrap(), IoEvent::GetNamespaces);
1✔
1579
    assert_eq!(sync_io_rx.recv().await.unwrap(), IoEvent::GetNodes);
1✔
1580
    assert_eq!(sync_io_rx.recv().await.unwrap(), IoEvent::GetServices);
1✔
1581
    assert_eq!(sync_io_cmd_rx.recv().await.unwrap(), IoCmdEvent::GetCliInfo);
1✔
1582
  }
1✔
1583

1584
  #[test]
1585
  fn test_navigation_stack_cap() {
1✔
1586
    let mut app = App::default();
1✔
1587
    // Push more than MAX_NAV_STACK routes
1588
    for _i in 0..150 {
150✔
1589
      app.push_navigation_stack(RouteId::Home, ActiveBlock::Pods);
150✔
1590
    }
150✔
1591
    // Stack should be capped at MAX_NAV_STACK
1592
    assert!(app.navigation_stack.len() <= MAX_NAV_STACK);
1✔
1593
    // Current route should still be the most recently pushed
1594
    assert_eq!(app.get_current_route().active_block, ActiveBlock::Pods);
1✔
1595
  }
1✔
1596

1597
  #[test]
1598
  fn test_navigation_stack_within_cap() {
1✔
1599
    let mut app = App::default();
1✔
1600
    // Push fewer than cap - default already has 1 route
1601
    app.push_navigation_stack(RouteId::Home, ActiveBlock::Services);
1✔
1602
    app.push_navigation_stack(RouteId::Home, ActiveBlock::Pods);
1✔
1603
    assert_eq!(app.navigation_stack.len(), 3); // 1 default + 2 pushed
1✔
1604
    assert_eq!(app.get_current_route().active_block, ActiveBlock::Pods);
1✔
1605
    // Pop should work normally
1606
    let popped = app.pop_navigation_stack();
1✔
1607
    assert!(popped.is_some());
1✔
1608
    assert_eq!(app.get_current_route().active_block, ActiveBlock::Services);
1✔
1609
  }
1✔
1610

1611
  #[test]
1612
  fn test_loading_counter_default() {
1✔
1613
    let app = App::default();
1✔
1614
    assert!(!app.is_loading());
1✔
1615
  }
1✔
1616

1617
  #[test]
1618
  fn test_refresh_restore_route_uses_parent_for_transient_home_views() {
1✔
1619
    let mut app = App::default();
1✔
1620
    app.context_tabs.set_index(1);
1✔
1621
    app.push_navigation_route(app.context_tabs.get_active_route().clone());
1✔
1622
    app.push_navigation_stack(RouteId::Home, ActiveBlock::Describe);
1✔
1623

1624
    assert_eq!(
1✔
1625
      app.refresh_restore_route(),
1✔
1626
      Route {
1627
        id: RouteId::Home,
1628
        active_block: ActiveBlock::Services,
1629
      }
1630
    );
1631
  }
1✔
1632

1633
  #[test]
1634
  fn test_refresh_restore_route_uses_parent_for_help_menu() {
1✔
1635
    let mut app = App::default();
1✔
1636
    app.route_contexts();
1✔
1637
    app.push_navigation_stack(RouteId::HelpMenu, ActiveBlock::Help);
1✔
1638

1639
    assert_eq!(
1✔
1640
      app.refresh_restore_route(),
1✔
1641
      Route {
1642
        id: RouteId::Contexts,
1643
        active_block: ActiveBlock::Contexts,
1644
      }
1645
    );
1646
  }
1✔
1647

1648
  #[test]
1649
  fn test_refresh_restore_route_uses_parent_for_filtered_pod_drilldown() {
1✔
1650
    let mut app = App::default();
1✔
1651
    app.context_tabs.set_index(6);
1✔
1652
    app.push_navigation_route(app.context_tabs.get_active_route().clone());
1✔
1653
    app.data.selected.pod_selector = Some("app=nginx".into());
1✔
1654
    app.push_navigation_stack(RouteId::Home, ActiveBlock::Pods);
1✔
1655

1656
    assert_eq!(
1✔
1657
      app.refresh_restore_route(),
1✔
1658
      Route {
1659
        id: RouteId::Home,
1660
        active_block: ActiveBlock::Deployments,
1661
      }
1662
    );
1663
  }
1✔
1664

1665
  #[test]
1666
  fn test_refresh_restore_route_uses_more_menu_for_more_resources() {
1✔
1667
    let mut app = App::default();
1✔
1668
    app.context_tabs.set_index(9);
1✔
1669
    app.push_navigation_route(app.context_tabs.get_active_route().clone());
1✔
1670
    app.push_navigation_stack(RouteId::Home, ActiveBlock::Secrets);
1✔
1671

1672
    assert_eq!(
1✔
1673
      app.refresh_restore_route(),
1✔
1674
      Route {
1675
        id: RouteId::Home,
1676
        active_block: ActiveBlock::More,
1677
      }
1678
    );
1679
  }
1✔
1680

1681
  #[test]
1682
  fn test_refresh_restore_route_uses_dynamic_menu_for_dynamic_resources() {
1✔
1683
    let mut app = App::default();
1✔
1684
    app.context_tabs.set_index(10);
1✔
1685
    app.push_navigation_route(app.context_tabs.get_active_route().clone());
1✔
1686
    app.push_navigation_stack(RouteId::Home, ActiveBlock::DynamicResource);
1✔
1687

1688
    assert_eq!(
1✔
1689
      app.refresh_restore_route(),
1✔
1690
      Route {
1691
        id: RouteId::Home,
1692
        active_block: ActiveBlock::DynamicView,
1693
      }
1694
    );
1695
  }
1✔
1696

1697
  #[tokio::test]
1698
  async fn test_dispatch_without_sender_does_not_set_loading() {
1✔
1699
    let mut app = App::default();
1✔
1700

1701
    app.dispatch(IoEvent::GetNamespaces).await;
1✔
1702

1703
    assert!(!app.is_loading());
1✔
1704
  }
1✔
1705

1706
  #[tokio::test]
1707
  async fn test_dispatch_stream_without_sender_does_not_set_loading() {
1✔
1708
    let mut app = App::default();
1✔
1709

1710
    app.dispatch_stream(IoStreamEvent::GetPodLogs(false)).await;
1✔
1711

1712
    assert!(!app.is_loading());
1✔
1713
  }
1✔
1714

1715
  #[tokio::test]
1716
  async fn test_dispatch_cmd_without_sender_does_not_set_loading() {
1✔
1717
    let mut app = App::default();
1✔
1718

1719
    app.dispatch_cmd(IoCmdEvent::GetCliInfo).await;
1✔
1720

1721
    assert!(!app.is_loading());
1✔
1722
  }
1✔
1723

1724
  #[test]
1725
  fn test_set_contexts_tracks_active_context() {
1✔
1726
    use crate::app::contexts::KubeContext;
1727

1728
    let mut app = App::default();
1✔
1729
    let contexts = vec![
1✔
1730
      KubeContext {
1✔
1731
        name: "ctx-a".into(),
1✔
1732
        namespace: Some("ns-a".into()),
1✔
1733
        is_active: false,
1✔
1734
        ..Default::default()
1✔
1735
      },
1✔
1736
      KubeContext {
1✔
1737
        name: "ctx-b".into(),
1✔
1738
        namespace: Some("ns-b".into()),
1✔
1739
        is_active: true,
1✔
1740
        ..Default::default()
1✔
1741
      },
1✔
1742
    ];
1743

1744
    app.set_contexts(contexts);
1✔
1745

1746
    let active = app
1✔
1747
      .data
1✔
1748
      .active_context
1✔
1749
      .as_ref()
1✔
1750
      .expect("should have active context");
1✔
1751
    assert_eq!(active.name, "ctx-b");
1✔
1752
    assert_eq!(active.namespace, Some("ns-b".into()));
1✔
1753
    assert_eq!(app.data.contexts.items.len(), 2);
1✔
1754
  }
1✔
1755

1756
  #[test]
1757
  fn test_set_contexts_no_active() {
1✔
1758
    use crate::app::contexts::KubeContext;
1759

1760
    let mut app = App::default();
1✔
1761
    let contexts = vec![KubeContext {
1✔
1762
      name: "ctx-a".into(),
1✔
1763
      is_active: false,
1✔
1764
      ..Default::default()
1✔
1765
    }];
1✔
1766

1767
    app.set_contexts(contexts);
1✔
1768

1769
    assert!(app.data.active_context.is_none());
1✔
1770
  }
1✔
1771
}
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