1use taba_client::json::Fields;
5use taba_client::Error;
6
7use crate::profile::ConfigFile;
8use crate::tui::api::{
9 ApiCall, Backup, ClusterStatus, Drain, FlushReport, Me, Missing, Nodes, Severity, StopReport,
10 Token, Topic, TopicNumbers,
11};
12use crate::tui::editor::Editor;
13use crate::tui::history::History;
14use crate::tui::i18n::{self, Lang};
15use crate::tui::prom::Metrics;
16use crate::tui::theme::Theme;
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum Tab {
20 Cluster,
21 Topics,
22 Tokens,
23 Profiles,
24}
25
26pub const TABS: [Tab; 4] = [Tab::Cluster, Tab::Topics, Tab::Tokens, Tab::Profiles];
28
29impl Tab {
30 pub fn index(self) -> usize {
31 TABS.iter().position(|t| *t == self).unwrap_or(0)
32 }
33
34 pub fn name_key(self) -> &'static str {
35 match self {
36 Tab::Cluster => "tab.cluster",
37 Tab::Topics => "tab.topics",
38 Tab::Tokens => "tab.tokens",
39 Tab::Profiles => "tab.profiles",
40 }
41 }
42}
43
44#[derive(Debug, Clone)]
46pub struct Options {
47 pub lang: Lang,
48 pub theme: Theme,
49 pub profile: String,
50 pub url: String,
51 pub retention_secs: u64,
52 pub interval_secs: u64,
53 pub profiles: ConfigFile,
55 pub choose_profile: bool,
57}
58
59pub fn short_id(id: &str) -> String {
62 let chars: Vec<char> = id.chars().collect();
63 if chars.len() <= 12 {
64 return id.to_owned();
65 }
66 let head: String = chars[..4].iter().collect();
67 let tail: String = chars[chars.len() - 6..].iter().collect();
68 format!("{head}…{tail}")
69}
70
71pub fn file_tag(id: &str) -> String {
73 let kept: Vec<char> = id.chars().filter(char::is_ascii_alphanumeric).collect();
74 kept[kept.len().saturating_sub(8)..].iter().collect()
75}
76
77#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct Fail {
80 pub status: u16,
81 pub code: String,
82 pub message: String,
83 pub request_id: Option<String>,
84 pub retry_after: Option<u64>,
85 pub required: Option<String>,
87}
88
89impl Fail {
90 pub fn is_offline(&self) -> bool {
92 self.status == 0
93 }
94
95 pub fn is_denied(&self) -> bool {
96 self.status == 401 || self.status == 403
97 }
98}
99
100impl From<&Error> for Fail {
101 fn from(e: &Error) -> Fail {
102 let body = String::from_utf8_lossy(&e.body);
103 let required = Fields::parse(&body).ok().and_then(|f| f.string("required"));
104 Fail {
105 status: e.status,
106 code: e.code.clone(),
107 message: e.message.clone(),
108 request_id: e.request_id.clone(),
109 retry_after: e.retry_after,
110 required,
111 }
112 }
113}
114
115const MAX_BACKOFF_MS: u64 = 30_000;
117
118#[derive(Debug, Clone, PartialEq)]
121pub struct Feed<T> {
122 pub data: Option<T>,
123 pub fetched_at: Option<u64>,
125 pub error: Option<Fail>,
126 pub in_flight: Option<u64>,
127 pub next_at: u64,
128 pub failures: u32,
129}
130
131impl<T> Default for Feed<T> {
132 fn default() -> Feed<T> {
133 Feed {
134 data: None,
135 fetched_at: None,
136 error: None,
137 in_flight: None,
138 next_at: 0,
139 failures: 0,
140 }
141 }
142}
143
144impl<T> Feed<T> {
145 pub fn due(&self, now: u64) -> bool {
146 self.in_flight.is_none() && now >= self.next_at
147 }
148
149 pub fn ok(&mut self, now: u64, data: T, every_ms: u64) {
150 self.data = Some(data);
151 self.fetched_at = Some(now);
152 self.error = None;
153 self.in_flight = None;
154 self.failures = 0;
155 self.next_at = now + every_ms;
156 }
157
158 pub fn fail(&mut self, now: u64, fail: Fail, every_ms: u64) {
159 self.failures = self.failures.saturating_add(1);
160 let backoff = every_ms
161 .saturating_mul(1 << self.failures.min(5))
162 .min(MAX_BACKOFF_MS);
163 self.next_at = now
164 + match fail.retry_after {
165 Some(secs) => secs.saturating_mul(1000),
166 None => backoff,
167 };
168 self.error = Some(fail);
169 self.in_flight = None;
170 }
171
172 pub fn is_stale(&self) -> bool {
174 self.data.is_some() && self.error.is_some()
175 }
176
177 pub fn retry_in(&self, now: u64) -> Option<u64> {
179 self.error.as_ref()?;
180 Some(self.next_at.saturating_sub(now).div_ceil(1000))
181 }
182
183 pub fn refresh_now(&mut self) {
185 self.next_at = 0;
186 }
187
188 pub fn reset(&mut self) {
189 *self = Feed::default();
190 }
191}
192
193#[derive(Debug, Clone, PartialEq, Eq)]
195pub enum Pending {
196 Drain(String),
197 Return(String),
198 FlushNode(String),
199 FlushAll,
200 Stop,
201 RevokeToken(String, String),
203 RemoveProfile(String),
204 DeleteTopic(String),
206 Prune(String),
208}
209
210impl Pending {
211 pub fn call(&self) -> Option<ApiCall> {
213 Some(match self {
214 Pending::Drain(id) => ApiCall::DrainStart(id.clone()),
215 Pending::Return(id) => ApiCall::DrainCancel(id.clone()),
216 Pending::FlushNode(id) => ApiCall::FlushNode(id.clone()),
217 Pending::FlushAll => ApiCall::FlushCluster,
218 Pending::Stop => ApiCall::StopCluster,
219 Pending::RevokeToken(id, _) => ApiCall::TokenRevoke(id.clone()),
220 Pending::DeleteTopic(name) => ApiCall::TopicDelete(name.clone()),
221 Pending::Prune(name) => ApiCall::Prune(name.clone()),
222 Pending::RemoveProfile(_) => return None,
223 })
224 }
225}
226
227#[derive(Debug, Clone, PartialEq, Eq)]
229pub struct Typed {
230 pub expected: String,
231 pub input: String,
232}
233
234#[derive(Debug, Clone, PartialEq, Eq)]
235pub struct Confirm {
236 pub pending: Pending,
237 pub typed: Option<Typed>,
238 pub ok_focused: bool,
240}
241
242#[derive(Debug, Clone, PartialEq)]
244pub enum Report {
245 Flush(FlushReport),
246 Stop(StopReport),
247 Failure { call: ApiCall, fail: Fail },
248}
249
250#[derive(Debug, Clone, PartialEq, Eq)]
251pub enum InputPurpose {
252 ExportCsv {
253 node: String,
254 series: usize,
255 },
256 TokenFilter,
258 TopicFilter,
260 ExportTopicCsv,
262}
263
264#[derive(Debug, Clone, PartialEq, Eq)]
265pub struct Input {
266 pub purpose: InputPurpose,
267 pub value: String,
268}
269
270#[derive(Debug, Clone, PartialEq, Eq)]
272pub struct Menu {
273 pub title: String,
274 pub items: Vec<crate::tui::keymap::Action>,
275 pub selected: usize,
276}
277
278#[derive(Debug, Clone, PartialEq)]
280pub enum Modal {
281 Menu(Menu),
283 TopicSummary,
285 Message,
287 Help {
288 scroll: u16,
289 },
290 Palette {
291 query: String,
292 selected: usize,
293 },
294 Language {
295 selected: usize,
296 },
297 Confirm(Confirm),
298 Report(Report),
299 Input(Input),
300 Condition(String),
302 Profiles {
304 selected: usize,
305 },
306 Editor(Editor),
308 Secret(Secret),
310}
311
312#[derive(Debug, Clone, PartialEq, Eq)]
314pub struct Secret {
315 pub id: String,
316 pub name: String,
317 pub token: String,
318 pub shown_at: u64,
320 pub copied: bool,
321 pub saved: bool,
322 pub warned: bool,
324 pub replaces: Option<(String, String)>,
326 pub revoke_old: bool,
327}
328
329#[derive(Debug, Clone, PartialEq, Eq)]
331pub struct Notice {
332 pub severity: Severity,
333 pub text: String,
334 pub until: u64,
335}
336
337#[derive(Debug, Clone, Copy, PartialEq, Eq)]
339pub enum Goal {
340 Drained,
341 Active,
342}
343
344#[derive(Debug, Clone, PartialEq, Eq)]
345pub struct DrainWatch {
346 pub node: String,
347 pub goal: Goal,
348}
349
350#[derive(Debug, Clone, Copy, PartialEq, Eq)]
351pub enum ClusterView {
352 List,
353 Detail,
354 Monitor,
355}
356
357#[derive(Debug, Clone, Copy, PartialEq, Eq)]
358pub enum Pane {
359 Conditions,
360 Nodes,
361}
362
363pub const WINDOWS_MIN: [u64; 5] = [1, 5, 15, 30, 60];
365
366#[derive(Debug, Clone, PartialEq)]
367pub struct Monitor {
368 pub chart: usize,
370 pub window: usize,
371 pub frozen: Option<u64>,
373 pub fixed_scale: bool,
375 pub full: bool,
377}
378
379impl Default for Monitor {
380 fn default() -> Monitor {
381 Monitor {
382 chart: 0,
383 window: 1,
384 frozen: None,
385 fixed_scale: false,
386 full: false,
387 }
388 }
389}
390
391#[derive(Debug, Clone, PartialEq)]
392pub struct ClusterScreen {
393 pub view: ClusterView,
394 pub pane: Pane,
395 pub condition: usize,
396 pub node: Option<String>,
398 pub scroll: u16,
399 pub monitor: Monitor,
400 pub nodes: Feed<Nodes>,
401 pub metrics: Feed<Metrics>,
402 pub backup: Feed<Option<Backup>>,
403 pub drain: Feed<Drain>,
404 pub watch: Option<DrainWatch>,
405}
406
407impl Default for ClusterScreen {
408 fn default() -> ClusterScreen {
409 ClusterScreen {
410 view: ClusterView::List,
411 pane: Pane::Nodes,
412 condition: 0,
413 node: None,
414 scroll: 0,
415 monitor: Monitor::default(),
416 nodes: Feed::default(),
417 metrics: Feed::default(),
418 backup: Feed::default(),
419 drain: Feed::default(),
420 watch: None,
421 }
422 }
423}
424
425#[derive(Debug, Clone, Copy, PartialEq, Eq)]
427pub enum TokenSort {
428 NewestFirst,
429 OldestFirst,
430 Name,
431 Id,
432}
433
434impl TokenSort {
435 pub fn next(self) -> TokenSort {
436 match self {
437 TokenSort::NewestFirst => TokenSort::OldestFirst,
438 TokenSort::OldestFirst => TokenSort::Name,
439 TokenSort::Name => TokenSort::Id,
440 TokenSort::Id => TokenSort::NewestFirst,
441 }
442 }
443
444 pub fn key(self) -> &'static str {
445 match self {
446 TokenSort::NewestFirst => "sort.newest",
447 TokenSort::OldestFirst => "sort.oldest",
448 TokenSort::Name => "sort.name",
449 TokenSort::Id => "sort.id",
450 }
451 }
452}
453
454#[derive(Debug, Clone, PartialEq)]
456pub struct TokensScreen {
457 pub list: Feed<Vec<Token>>,
459 pub reading: Vec<Token>,
461 pub pages: u32,
462 pub selected: Option<String>,
464 pub filter: String,
465 pub sort: TokenSort,
466}
467
468impl Default for TokensScreen {
469 fn default() -> TokensScreen {
470 TokensScreen {
471 list: Feed::default(),
472 reading: Vec::new(),
473 pages: 0,
474 selected: None,
475 filter: String::new(),
476 sort: TokenSort::NewestFirst,
477 }
478 }
479}
480
481#[derive(Debug, Clone, Copy, PartialEq, Eq)]
483pub enum TopicSort {
484 Name,
485 Newest,
486 Oldest,
487 Depth,
488}
489
490impl TopicSort {
491 pub fn next(self) -> TopicSort {
492 match self {
493 TopicSort::Name => TopicSort::Newest,
494 TopicSort::Newest => TopicSort::Oldest,
495 TopicSort::Oldest => TopicSort::Depth,
496 TopicSort::Depth => TopicSort::Name,
497 }
498 }
499
500 pub fn key(self) -> &'static str {
501 match self {
502 TopicSort::Name => "sort.name",
503 TopicSort::Newest => "sort.newest",
504 TopicSort::Oldest => "sort.oldest",
505 TopicSort::Depth => "sort.depth",
506 }
507 }
508}
509
510#[derive(Debug, Clone, PartialEq, Default)]
512pub struct TopicStats {
513 pub by_name: std::collections::HashMap<String, TopicNumbers>,
514 pub partial: bool,
515 pub missing: Vec<Missing>,
516}
517
518#[derive(Debug, Clone, Copy, PartialEq, Eq)]
520pub enum TopicTab {
521 Charts,
522 Messages,
523}
524
525#[derive(Debug, Clone, PartialEq, Eq)]
527pub struct Received {
528 pub id: String,
529 pub group: Option<String>,
530 pub deliveries: u64,
531 pub size: usize,
532 pub content_type: String,
533 pub body: String,
535 pub raw: Vec<u8>,
537 pub receipt: String,
538 pub at: u64,
540 pub held_until: Option<u64>,
542 pub acked: bool,
544}
545
546pub const MAX_RECEIVED: usize = 1000;
548
549#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
551pub enum BodyView {
552 #[default]
553 Sent,
554 Formatted,
555 Base64,
556}
557
558#[derive(Debug, Clone, PartialEq, Eq, Default)]
560pub struct Messages {
561 pub rows: Vec<Received>,
563 pub selected: usize,
564 pub view: BodyView,
566 pub last: Option<(Severity, String)>,
568}
569
570#[derive(Debug, Clone, PartialEq)]
572pub struct TopicMonitor {
573 pub name: String,
574 pub tab: TopicTab,
575 pub distributor: bool,
577 pub dead_letter: bool,
578 pub messages: Messages,
579 pub monitor: Monitor,
581 pub feed: Feed<()>,
583 pub series: std::collections::HashMap<String, crate::tui::history::Ring>,
585 pub groups: Vec<String>,
587}
588
589impl TopicMonitor {
590 pub fn new(name: String, tab: TopicTab, distributor: bool, dead_letter: bool) -> TopicMonitor {
592 TopicMonitor {
593 name,
594 tab,
595 distributor,
596 dead_letter,
597 messages: Messages::default(),
598 monitor: Monitor::default(),
599 feed: Feed::default(),
600 series: std::collections::HashMap::new(),
601 groups: Vec::new(),
602 }
603 }
604}
605
606#[derive(Debug, Clone, PartialEq)]
608pub struct TopicsScreen {
609 pub previews: Vec<TopicMonitor>,
612 pub show_dead: bool,
614 pub monitor: Option<TopicMonitor>,
616 pub list: Feed<Vec<Topic>>,
618 pub reading: Vec<Topic>,
619 pub pages: u32,
620 pub stats: Feed<TopicStats>,
622 pub stats_reading: TopicStats,
623 pub stats_pages: u32,
624 pub selected: Option<String>,
626 pub filter: String,
627 pub sort: TopicSort,
628}
629
630impl Default for TopicsScreen {
631 fn default() -> TopicsScreen {
632 TopicsScreen {
633 show_dead: false,
634 previews: Vec::new(),
635 monitor: None,
636 list: Feed::default(),
637 reading: Vec::new(),
638 pages: 0,
639 stats: Feed::default(),
640 stats_reading: TopicStats::default(),
641 stats_pages: 0,
642 selected: None,
643 filter: String::new(),
644 sort: TopicSort::Name,
645 }
646 }
647}
648
649#[derive(Debug, Clone, PartialEq)]
651pub struct ProfilesScreen {
652 pub file: ConfigFile,
653 pub selected: Option<String>,
654}
655
656pub struct Model {
657 pub lang: Lang,
658 pub theme: Theme,
659 pub profile: String,
660 pub url: String,
661 pub tab: Tab,
662 pub size: (u16, u16),
663 pub now: u64,
665 pub wall_ms: i64,
667 pub paused: bool,
668 pub focused: bool,
670 pub quit: bool,
671 pub modal: Option<Modal>,
672 pub notice: Option<Notice>,
673 pub busy: Option<ApiCall>,
675 pub me: Feed<Me>,
676 pub status: Feed<ClusterStatus>,
677 pub status_denied: Option<Fail>,
679 pub cluster: ClusterScreen,
680 pub tokens: TokensScreen,
681 pub topics: TopicsScreen,
682 pub profiles: ProfilesScreen,
683 pub awaiting_profile: bool,
685 pub history: History,
686 pub next_id: u64,
687 pub generation: u64,
689}
690
691impl Model {
692 pub fn new(options: Options) -> Model {
693 let choose = options.choose_profile && options.profiles.profiles.len() > 1;
695 let create = options.choose_profile && options.profiles.profiles.is_empty();
697 let first = choose.then(|| {
698 let file = &options.profiles;
699 let selected = file
700 .default
701 .as_ref()
702 .and_then(|name| file.profiles.iter().position(|(n, _)| n == name))
703 .unwrap_or(0);
704 Modal::Profiles { selected }
705 });
706 let mut model = Model {
707 lang: options.lang,
708 theme: options.theme,
709 profile: options.profile,
710 url: options.url,
711 tab: Tab::Cluster,
712 size: (80, 24),
713 now: 0,
714 wall_ms: 0,
715 paused: false,
716 focused: true,
717 quit: false,
718 modal: first,
719 notice: None,
720 busy: None,
721 me: Feed::default(),
722 status: Feed::default(),
723 status_denied: None,
724 cluster: ClusterScreen::default(),
725 tokens: TokensScreen::default(),
726 topics: TopicsScreen::default(),
727 profiles: ProfilesScreen {
728 selected: options.profiles.default.clone(),
729 file: options.profiles,
730 },
731 awaiting_profile: choose || create,
732 history: History::new(options.retention_secs, options.interval_secs),
733 next_id: 1,
734 generation: 0,
735 };
736 if create {
737 crate::tui::screens::profiles::open_form(&mut model, None);
738 }
739 model
740 }
741
742 pub fn reconnect(&mut self, profile: String, url: String) {
745 self.profile = profile;
746 self.url = url;
747 self.generation += 1;
748 self.awaiting_profile = false;
749 self.busy = None;
750 self.me = Feed::default();
751 self.status = Feed::default();
752 self.status_denied = None;
753 self.cluster = ClusterScreen::default();
754 self.tokens = TokensScreen::default();
755 self.topics = TopicsScreen::default();
756 let retention = self.history.retention_ms() / 1000;
757 let interval = self.history.interval_ms() / 1000;
758 self.history = History::new(retention, interval);
759 }
760
761 pub fn id(&mut self) -> u64 {
762 let id = self.next_id;
763 self.next_id += 1;
764 id
765 }
766
767 pub fn tx(&self) -> Tx {
768 Tx(self.lang)
769 }
770
771 pub fn is_admin(&self) -> bool {
774 self.me.data.as_ref().is_some_and(Me::is_admin)
775 }
776
777 pub fn is_root(&self) -> bool {
779 self.me.data.as_ref().is_some_and(Me::is_root)
780 }
781
782 pub fn uses_status(&self) -> bool {
784 self.is_admin() && self.status_denied.is_none()
785 }
786
787 pub fn is_offline(&self) -> bool {
789 let feeds = [
790 self.me.error.as_ref(),
791 self.status.error.as_ref(),
792 self.cluster.nodes.error.as_ref(),
793 ];
794 feeds.into_iter().flatten().any(Fail::is_offline)
795 }
796
797 pub fn health(&self) -> (Severity, String) {
799 if self.is_offline() {
800 return (Severity::Critical, "offline".to_owned());
801 }
802 if let Some(status) = &self.status.data {
803 let severity = status
804 .conditions
805 .iter()
806 .map(|c| c.severity)
807 .max()
808 .unwrap_or(Severity::Ok);
809 let word = if status.worst.is_empty() {
810 "ok"
811 } else {
812 &status.worst
813 };
814 return (severity, word.to_owned());
815 }
816 if let Some(nodes) = &self.cluster.nodes.data {
817 let bad = nodes
818 .items
819 .iter()
820 .any(|n| !n.reachable || !matches!(n.state.as_str(), "active" | ""));
821 if bad || nodes.partial {
822 return (Severity::Warning, "degraded".to_owned());
823 }
824 return (Severity::Ok, "ok".to_owned());
825 }
826 (Severity::Ok, "unknown".to_owned())
827 }
828
829 pub fn notify(&mut self, severity: Severity, text: String) {
830 self.notice = Some(Notice {
831 severity,
832 text,
833 until: self.now + 5000,
834 });
835 }
836}
837
838#[derive(Debug, Clone, Copy)]
840pub struct Tx(pub Lang);
841
842impl Tx {
843 pub fn t(self, key: &str) -> String {
844 i18n::tr(self.0, key)
845 }
846
847 pub fn tw(self, key: &str, args: &[(&str, &dyn std::fmt::Display)]) -> String {
848 i18n::tr_with(self.0, key, args)
849 }
850
851 pub fn tn(self, key: &str, n: u64) -> String {
852 i18n::tr_n(self.0, key, n)
853 }
854}
855
856#[cfg(test)]
857mod tests;