Skip to main content

taba_cli/tui/model/
mod.rs

1//! The state of the whole interface (doc 34, section 4): everything the screens show, with no input or output.
2//! `update` changes it and `view` only reads it, so both are tested without a terminal or a network.
3
4use 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
26/// The messages are not a tab: they are one of the views of a topic (`Topics › name › Messages`).
27pub 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/// What the person set at the start: language, look, profile and history.
45#[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    /// The profiles file as it was read: the Profiles tab and the choice of a profile start from it.
54    pub profiles: ConfigFile,
55    /// Ask which profile to use before connecting (none was given and there is more than one).
56    pub choose_profile: bool,
57}
58
59/// A node id for tables and messages: its start and its end, since the ids of nodes made together share the
60/// start (they are GUID v7, ordered by time).
61pub 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
71/// The part of a node id that is safe and distinct in a file name: its last eight letters and digits.
72pub 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/// A failed call, reduced to what the screens show.
78#[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    /// `root` or the permission that was missing (`403 auth.forbidden`).
86    pub required: Option<String>,
87}
88
89impl Fail {
90    /// The client could not reach the server (no HTTP status).
91    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
115/// The longest wait between two tries of a read that keeps failing.
116const MAX_BACKOFF_MS: u64 = 30_000;
117
118/// A thing read on a schedule: the last good value, the error of the last try, and when to try again.
119/// A failure keeps the value (it becomes *stale*) and backs off (×2 up to 30 s; `Retry-After` is obeyed).
120#[derive(Debug, Clone, PartialEq)]
121pub struct Feed<T> {
122    pub data: Option<T>,
123    /// When the last good value arrived (ms since the app opened).
124    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    /// Old data kept after a failure.
173    pub fn is_stale(&self) -> bool {
174        self.data.is_some() && self.error.is_some()
175    }
176
177    /// Seconds until the next try after a failure.
178    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    /// Asks for a read at the next tick (`r`).
184    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/// An action of a node or the cluster that asks for confirmation first.
194#[derive(Debug, Clone, PartialEq, Eq)]
195pub enum Pending {
196    Drain(String),
197    Return(String),
198    FlushNode(String),
199    FlushAll,
200    Stop,
201    /// The id and the name of the token.
202    RevokeToken(String, String),
203    RemoveProfile(String),
204    /// The name of the topic.
205    DeleteTopic(String),
206    /// Throws away every message of the topic, on every node.
207    Prune(String),
208}
209
210impl Pending {
211    /// The API call the action makes; removing a profile only touches the file, so it has none.
212    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/// A popup that asks to be typed to proceed (stopping the cluster).
228#[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    /// The default is always **Cancel** (TUI-FRM-07).
239    pub ok_focused: bool,
240}
241
242/// The result of an action, shown in a popup.
243#[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    /// The part of a token name the Tokens tab filters by.
257    TokenFilter,
258    /// The part of a topic name the Topics tab filters by.
259    TopicFilter,
260    /// The file the charts of a queue are written to (every line of the three charts).
261    ExportTopicCsv,
262}
263
264#[derive(Debug, Clone, PartialEq, Eq)]
265pub struct Input {
266    pub purpose: InputPurpose,
267    pub value: String,
268}
269
270/// What `Enter` opens on an item of a list: the title names the item, and each entry is an action of the screen.
271#[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/// One popup at a time (TUI-FRM-07); `Esc` closes it.
279#[derive(Debug, Clone, PartialEq)]
280pub enum Modal {
281    /// The actions of the selected item (`Enter` on a list).
282    Menu(Menu),
283    /// The summary of the selected topic: its numbers, limits and tags.
284    TopicSummary,
285    /// The selected message of the Messages view, open: its body and what can still be done to it.
286    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    /// What a condition means and what to do (`Enter` on a condition).
301    Condition(String),
302    /// Which profile to connect with.
303    Profiles {
304        selected: usize,
305    },
306    /// A form: a token or a profile.
307    Editor(Editor),
308    /// The secret of a token that was just created.
309    Secret(Secret),
310}
311
312/// The secret of a new token, shown once (TUI-TOK-03). It lives only in this popup: never in a notice, a log or an error.
313#[derive(Debug, Clone, PartialEq, Eq)]
314pub struct Secret {
315    pub id: String,
316    pub name: String,
317    pub token: String,
318    /// When it was shown (ms since the app opened); after 30 s it is masked.
319    pub shown_at: u64,
320    pub copied: bool,
321    pub saved: bool,
322    /// The person tried to close it without copying or saving it.
323    pub warned: bool,
324    /// The token this one replaces (a clone), and whether it is to be revoked on closing (default: no).
325    pub replaces: Option<(String, String)>,
326    pub revoke_old: bool,
327}
328
329/// A short message in the footer.
330#[derive(Debug, Clone, PartialEq, Eq)]
331pub struct Notice {
332    pub severity: Severity,
333    pub text: String,
334    pub until: u64,
335}
336
337/// What the drain watch is waiting for.
338#[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
363/// The windows of a chart, in minutes (TUI-MON-03).
364pub const WINDOWS_MIN: [u64; 5] = [1, 5, 15, 30, 60];
365
366#[derive(Debug, Clone, PartialEq)]
367pub struct Monitor {
368    /// The chart being shown, an index of `screens::cluster::MON_CHARTS`.
369    pub chart: usize,
370    pub window: usize,
371    /// The instant the chart was frozen at (`Space`).
372    pub frozen: Option<u64>,
373    /// `true`: the scale starts at zero and ends at the largest value ever seen; `false`: it follows the window.
374    pub fixed_scale: bool,
375    /// The chart fills the whole screen (`Enter`) instead of sitting next to the list of charts.
376    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    /// The selected node; kept by id so a reordering does not move the selection.
397    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/// How the tokens are ordered.
426#[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/// The Tokens tab (doc 34, section 6.5).
455#[derive(Debug, Clone, PartialEq)]
456pub struct TokensScreen {
457    /// Every token the server has, read page by page.
458    pub list: Feed<Vec<Token>>,
459    /// The tokens of the pages read so far while a read still goes on.
460    pub reading: Vec<Token>,
461    pub pages: u32,
462    /// The selected token, by id.
463    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/// How the topics are ordered.
482#[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/// The numbers of the topics, by name, and whether the read was incomplete.
511#[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/// The views of an open topic.
519#[derive(Debug, Clone, Copy, PartialEq, Eq)]
520pub enum TopicTab {
521    Charts,
522    Messages,
523}
524
525/// A message this session received (TUI-MSG-04): the API does not list a queue, so these rows are what was read.
526#[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    /// The body as text (lossy for bytes that are not UTF-8).
534    pub body: String,
535    /// The first bytes of the body as they came, for the formatted and the base64 views.
536    pub raw: Vec<u8>,
537    pub receipt: String,
538    /// When it was received (ms since the app opened).
539    pub at: u64,
540    /// When it turns visible again (ms since the app opened), while it is held.
541    pub held_until: Option<u64>,
542    /// Acknowledged: it is gone from the queue.
543    pub acked: bool,
544}
545
546/// The most received messages kept.
547pub const MAX_RECEIVED: usize = 1000;
548
549/// How the body of an open message is shown: as sent (the default), formatted, or as base64.
550#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
551pub enum BodyView {
552    #[default]
553    Sent,
554    Formatted,
555    Base64,
556}
557
558/// What the Messages view of a topic holds.
559#[derive(Debug, Clone, PartialEq, Eq, Default)]
560pub struct Messages {
561    /// Newest first.
562    pub rows: Vec<Received>,
563    pub selected: usize,
564    /// How the body of an open message is shown.
565    pub view: BodyView,
566    /// What the last publish, receive or prune said (severity, text).
567    pub last: Option<(Severity, String)>,
568}
569
570/// The charts of one queue (TUI-MSG-11, TUI-MON-*): what was sampled since they were opened.
571#[derive(Debug, Clone, PartialEq)]
572pub struct TopicMonitor {
573    pub name: String,
574    pub tab: TopicTab,
575    /// A distributor has no queue: no numbers, no charts, nothing to receive.
576    pub distributor: bool,
577    pub dead_letter: bool,
578    pub messages: Messages,
579    /// The window, the freeze and the scale, as in the charts of a node.
580    pub monitor: Monitor,
581    /// The reading on a schedule; its data is unused, the samples go to `series`.
582    pub feed: Feed<()>,
583    /// One ring per line: `visible`, `invisible`, `published`, `consumed`, `acked`, `oldest` and `g:<group>`.
584    pub series: std::collections::HashMap<String, crate::tui::history::Ring>,
585    /// The groups charted, in the order they first showed up (at most three), so their colors stay.
586    pub groups: Vec<String>,
587}
588
589impl TopicMonitor {
590    /// A monitor with nothing sampled yet.
591    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/// The Topics tab (doc 34, section 6.4).
607#[derive(Debug, Clone, PartialEq)]
608pub struct TopicsScreen {
609    /// The strip of charts under the list: what was sampled of the topics that were selected lately (the most
610    /// recent last), so going back to one does not start its charts over.
611    pub previews: Vec<TopicMonitor>,
612    /// Show the dead-letter queues too; off by default (they follow their source queue).
613    pub show_dead: bool,
614    /// The charts of the queue being looked at (`Enter`), or none while the list is shown.
615    pub monitor: Option<TopicMonitor>,
616    /// Every topic the server lists, read page by page.
617    pub list: Feed<Vec<Topic>>,
618    pub reading: Vec<Topic>,
619    pub pages: u32,
620    /// Their numbers (depth, rates, age, condition), read the same way; a failure leaves the table without them.
621    pub stats: Feed<TopicStats>,
622    pub stats_reading: TopicStats,
623    pub stats_pages: u32,
624    /// The selected topic, by name.
625    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/// The Profiles tab (doc 34, section 6.7): the profiles file and the selected profile.
650#[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    /// Milliseconds since the app opened, from the last tick.
664    pub now: u64,
665    /// The wall clock at the last tick (ms since the epoch, UTC).
666    pub wall_ms: i64,
667    pub paused: bool,
668    /// `false` while the terminal does not have the focus.
669    pub focused: bool,
670    pub quit: bool,
671    pub modal: Option<Modal>,
672    pub notice: Option<Notice>,
673    /// An action that is running (its reply has not come).
674    pub busy: Option<ApiCall>,
675    pub me: Feed<Me>,
676    pub status: Feed<ClusterStatus>,
677    /// `GET /cluster/status` answered `401`/`403` for this token: the screen falls back to `GET /nodes`.
678    pub status_denied: Option<Fail>,
679    pub cluster: ClusterScreen,
680    pub tokens: TokensScreen,
681    pub topics: TopicsScreen,
682    pub profiles: ProfilesScreen,
683    /// Waiting for the person to choose a profile: nothing is read until then.
684    pub awaiting_profile: bool,
685    pub history: History,
686    pub next_id: u64,
687    /// Bumped when the screens are reset; a reply of an older generation is dropped.
688    pub generation: u64,
689}
690
691impl Model {
692    pub fn new(options: Options) -> Model {
693        // With one profile (or none) there is nothing to choose.
694        let choose = options.choose_profile && options.profiles.profiles.len() > 1;
695        // With none, the form for the first one opens at once.
696        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    /// Starts over with another profile: what was read from the old server is dropped, and so is any reply still
743    /// on its way (its generation is old).
744    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    /// `true` when this token may read the cluster's detail and manage the tokens (root, no authentication, or the
772    /// `admin` permission).
773    pub fn is_admin(&self) -> bool {
774        self.me.data.as_ref().is_some_and(Me::is_admin)
775    }
776
777    /// `true` for the root token (or no authentication): stopping the cluster and the `admin` tokens are its alone.
778    pub fn is_root(&self) -> bool {
779        self.me.data.as_ref().is_some_and(Me::is_root)
780    }
781
782    /// The cluster-wide status is read with this token.
783    pub fn uses_status(&self) -> bool {
784        self.is_admin() && self.status_denied.is_none()
785    }
786
787    /// Any feed that ended in a connection failure: the server cannot be reached.
788    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    /// The worst condition for the status line: its severity and the word (a condition name, or a state key).
798    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/// The catalog in a language: the views and the update ask it for their texts.
839#[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;