Skip to main content

taba_cli/tui/update/
mod.rs

1//! `update(model, msg) -> effects` (doc 34, section 4): the whole behavior of the interface as a function of one
2//! input at a time. It never blocks and never touches the terminal or the network; the calls it wants made
3//! come out as `Effect`s.
4
5use ratatui::crossterm::event::{KeyCode, KeyEvent};
6use taba_client::{Error, Reply};
7
8use crate::tui::api::{self, ApiCall, Severity};
9use crate::tui::effect::Effect;
10use crate::tui::i18n::{self, LANGS};
11use crate::tui::keymap::{self, Action, Scope};
12use crate::tui::model::{
13    short_id, ClusterView, Confirm, Fail, Goal, InputPurpose, Modal, Model, Pending, Report, Tab,
14    TopicStats, TopicTab, TABS,
15};
16use crate::tui::msg::Msg;
17use crate::tui::prom::Metrics;
18use crate::tui::screens::{cluster, profiles, tokens, topics};
19
20/// How often each thing is read.
21const ME_EVERY_MS: u64 = 300_000;
22const STATUS_EVERY_MS: u64 = 2_000;
23const NODES_EVERY_MS: u64 = 5_000;
24const NODES_BACKGROUND_MS: u64 = 10_000;
25const METRICS_EVERY_MS: u64 = 5_000;
26const BACKUP_EVERY_MS: u64 = 10_000;
27const DRAIN_EVERY_MS: u64 = 2_000;
28const TOKENS_EVERY_MS: u64 = 10_000;
29const TOPICS_EVERY_MS: u64 = 5_000;
30
31pub fn update(m: &mut Model, msg: Msg) -> Vec<Effect> {
32    match msg {
33        Msg::Key(event) => on_key(m, event),
34        Msg::Scroll(lines) => on_scroll(m, lines),
35        Msg::Resize(width, height) => {
36            m.size = (width, height);
37            Vec::new()
38        }
39        Msg::Focus(focused) => {
40            m.focused = focused;
41            // Coming back, the screen catches up at once.
42            if focused {
43                refresh(m);
44            }
45            Vec::new()
46        }
47        Msg::Tick { now, wall_ms } => on_tick(m, now, wall_ms),
48        Msg::Action(action) => dispatch(m, action),
49        Msg::Reply {
50            id,
51            generation,
52            call,
53            result,
54        } => on_reply(m, id, generation, call, result),
55        Msg::FileWritten { path, result } => {
56            let tx = m.tx();
57            match result {
58                Ok(()) => {
59                    tokens::secret_written(m, &path);
60                    let text = tx.tw("notice.file-written", &[("path", &path)]);
61                    m.notify(Severity::Ok, text);
62                }
63                Err(e) => {
64                    let text = tx.tw("notice.file-failed", &[("path", &path), ("error", &e)]);
65                    m.notify(Severity::Critical, text);
66                }
67            }
68            Vec::new()
69        }
70        Msg::Paste(text) => {
71            match &mut m.modal {
72                Some(Modal::Editor(editor)) => editor.insert(&text),
73                Some(Modal::Input(input)) => {
74                    input.value.extend(text.chars().filter(|c| !c.is_control()));
75                }
76                _ => {}
77            }
78            Vec::new()
79        }
80        Msg::ProfilesSaved {
81            file,
82            select,
83            result,
84        } => profiles::saved(m, file, select, result),
85        Msg::ProfileChecked(result) => {
86            profiles::checked(m, &result);
87            Vec::new()
88        }
89        Msg::Connected {
90            profile,
91            url,
92            result,
93        } => on_connected(m, profile, url, result),
94        Msg::LanguageSaved(result) => {
95            if let Err(e) = result {
96                let text = m.tx().tw("notice.language-not-saved", &[("error", &e)]);
97                m.notify(Severity::Warning, text);
98            }
99            Vec::new()
100        }
101    }
102}
103
104/// The scope whose keys apply on the screen being shown.
105pub fn scope_of(m: &Model) -> Scope {
106    match (m.tab, m.cluster.view) {
107        (Tab::Cluster, ClusterView::Monitor) => Scope::Monitor,
108        (Tab::Cluster, _) => Scope::Cluster,
109        (Tab::Tokens, _) => Scope::Tokens,
110        (Tab::Topics, _) => match &m.topics.monitor {
111            Some(mon) if mon.tab == TopicTab::Messages => Scope::Messages,
112            Some(_) => Scope::Monitor,
113            None => Scope::Topics,
114        },
115        (Tab::Profiles, _) => Scope::Profiles,
116    }
117}
118
119fn on_key(m: &mut Model, event: KeyEvent) -> Vec<Effect> {
120    // `crossterm` on Windows reports the release of a key too: only the press counts.
121    if event.kind == ratatui::crossterm::event::KeyEventKind::Release {
122        return Vec::new();
123    }
124    if m.modal.is_some() {
125        return on_modal_key(m, event);
126    }
127    match keymap::resolve(scope_of(m), &event) {
128        Some(action) => dispatch(m, action),
129        None => Vec::new(),
130    }
131}
132
133fn on_scroll(m: &mut Model, lines: i16) -> Vec<Effect> {
134    if let Some(Modal::Help { scroll }) = &mut m.modal {
135        *scroll = scroll.saturating_add_signed(lines);
136        return Vec::new();
137    }
138    if m.modal.is_some() {
139        return Vec::new();
140    }
141    let action = if lines < 0 { Action::Up } else { Action::Down };
142    let mut effects = Vec::new();
143    for _ in 0..lines.unsigned_abs().min(10) {
144        effects.extend(dispatch(m, action));
145    }
146    effects
147}
148
149/// Runs an action of the keymap, from a key or from the command palette.
150pub fn dispatch(m: &mut Model, action: Action) -> Vec<Effect> {
151    match action {
152        Action::Quit => vec![Effect::Quit],
153        Action::GoTab(n) => {
154            go_to(m, usize::from(n));
155            Vec::new()
156        }
157        Action::TabNext => {
158            go_to(m, (m.tab.index() + 1) % TABS.len());
159            Vec::new()
160        }
161        Action::TabPrev => {
162            go_to(m, (m.tab.index() + TABS.len() - 1) % TABS.len());
163            Vec::new()
164        }
165        Action::Help => {
166            m.modal = Some(Modal::Help { scroll: 0 });
167            Vec::new()
168        }
169        Action::Palette => {
170            m.modal = Some(Modal::Palette {
171                query: String::new(),
172                selected: 0,
173            });
174            Vec::new()
175        }
176        Action::Language => {
177            let selected = LANGS.iter().position(|l| *l == m.lang).unwrap_or(0);
178            m.modal = Some(Modal::Language { selected });
179            Vec::new()
180        }
181        Action::Refresh => {
182            refresh(m);
183            Vec::new()
184        }
185        Action::Pause => {
186            m.paused = !m.paused;
187            Vec::new()
188        }
189        Action::Submit => Vec::new(),
190        Action::SwitchProfile => {
191            open_profiles(m);
192            Vec::new()
193        }
194        other => match m.tab {
195            Tab::Cluster => cluster::on_action(m, other),
196            Tab::Tokens => tokens::on_action(m, other),
197            Tab::Topics => topics::on_action(m, other),
198            Tab::Profiles => profiles::on_action(m, other),
199        },
200    }
201}
202
203fn go_to(m: &mut Model, index: usize) {
204    if let Some(tab) = TABS.get(index) {
205        m.tab = *tab;
206    }
207}
208
209fn refresh(m: &mut Model) {
210    m.me.refresh_now();
211    m.status.refresh_now();
212    m.cluster.nodes.refresh_now();
213    m.cluster.metrics.refresh_now();
214    m.cluster.backup.refresh_now();
215    m.cluster.drain.refresh_now();
216    m.tokens.list.refresh_now();
217    m.topics.list.refresh_now();
218    m.topics.stats.refresh_now();
219    if let Some(mon) = &mut m.topics.monitor {
220        mon.feed.refresh_now();
221    }
222    for preview in &mut m.topics.previews {
223        preview.feed.refresh_now();
224    }
225}
226
227/// Opens the choice of a profile, with the default (or the first) one under the cursor.
228fn open_profiles(m: &mut Model) {
229    if m.profiles.file.profiles.is_empty() {
230        let text = m.tx().t("gate.no-profile");
231        m.notify(Severity::Warning, text);
232        return;
233    }
234    m.modal = Some(Modal::Profiles {
235        selected: profiles::preferred_index(&m.profiles.file),
236    });
237}
238
239/// The connection with a profile was made (or could not be).
240fn on_connected(
241    m: &mut Model,
242    profile: String,
243    url: String,
244    result: Result<(), String>,
245) -> Vec<Effect> {
246    let tx = m.tx();
247    match result {
248        Ok(()) => {
249            m.reconnect(profile.clone(), url.clone());
250            if matches!(m.modal, Some(Modal::Profiles { .. })) {
251                m.modal = None;
252            }
253            let text = tx.tw("notice.connected", &[("profile", &profile), ("url", &url)]);
254            m.notify(Severity::Ok, text);
255        }
256        Err(e) => {
257            let text = tx.tw(
258                "notice.connect-failed",
259                &[("profile", &profile), ("error", &e)],
260            );
261            m.notify(Severity::Critical, text);
262        }
263    }
264    Vec::new()
265}
266
267// ---------- popups ----------
268
269/// The commands of the palette: every action that has a help text, plus a jump to each tab.
270pub fn palette_entries(lang: i18n::Lang) -> Vec<(String, Action)> {
271    let mut entries = Vec::new();
272    for (i, tab) in TABS.iter().enumerate() {
273        let name = i18n::tr(lang, tab.name_key());
274        entries.push((
275            i18n::tr_with(lang, "palette.go-to", &[("tab", &name)]),
276            Action::GoTab(i as u8),
277        ));
278    }
279    for binding in keymap::BINDINGS {
280        let Some(help) = binding.help else { continue };
281        if matches!(
282            binding.action,
283            Action::GoTab(_) | Action::Enter | Action::Esc | Action::Submit
284        ) {
285            continue;
286        }
287        entries.push((i18n::tr(lang, help), binding.action));
288    }
289    entries
290}
291
292/// A subsequence match, ignoring case: `drn` finds `Drain the node`.
293pub fn fuzzy(query: &str, text: &str) -> bool {
294    let mut chars = text.chars().flat_map(char::to_lowercase);
295    query
296        .chars()
297        .flat_map(char::to_lowercase)
298        .filter(|c| !c.is_whitespace())
299        .all(|q| chars.any(|c| c == q))
300}
301
302/// The palette entries that match a query, in either the current language or English (TUI-HLP-02). Those that
303/// contain the text come before those that only have its letters in order.
304pub fn palette_matches(lang: i18n::Lang, query: &str) -> Vec<(String, Action)> {
305    let english = palette_entries(i18n::Lang::EnUs);
306    let wanted = query.trim().to_lowercase();
307    let contains = |text: &str| text.to_lowercase().contains(&wanted);
308    let mut found: Vec<(bool, (String, Action))> = palette_entries(lang)
309        .into_iter()
310        .zip(english)
311        .filter(|((own, _), (en, _))| fuzzy(query, own) || fuzzy(query, en))
312        .map(|((own, action), (en, _))| (!(contains(&own) || contains(&en)), (own, action)))
313        .collect();
314    // A stable sort keeps the table's order inside each group.
315    found.sort_by_key(|(worse, _)| *worse);
316    found.into_iter().map(|(_, entry)| entry).collect()
317}
318
319fn on_modal_key(m: &mut Model, event: KeyEvent) -> Vec<Effect> {
320    let Some(modal) = m.modal.clone() else {
321        return Vec::new();
322    };
323    let ctrl = crate::tui::keymap::ctrl_of(&event);
324    if ctrl && matches!(event.code, KeyCode::Char('c')) {
325        return vec![Effect::Quit];
326    }
327    match modal {
328        Modal::Help { scroll } => {
329            let scroll = match event.code {
330                KeyCode::Esc | KeyCode::Char('q' | 'h' | '?') | KeyCode::F(1) => {
331                    m.modal = None;
332                    return Vec::new();
333                }
334                KeyCode::Up | KeyCode::Char('k') => scroll.saturating_sub(1),
335                KeyCode::Down | KeyCode::Char('j') => scroll.saturating_add(1),
336                KeyCode::PageUp => scroll.saturating_sub(10),
337                KeyCode::PageDown => scroll.saturating_add(10),
338                KeyCode::Home => 0,
339                KeyCode::End => u16::MAX,
340                _ => scroll,
341            };
342            m.modal = Some(Modal::Help { scroll });
343            Vec::new()
344        }
345        Modal::Palette {
346            mut query,
347            selected,
348        } => palette_key(m, event, &mut query, selected),
349        Modal::Language { selected } => match event.code {
350            KeyCode::Esc | KeyCode::Char('q') => {
351                m.modal = None;
352                Vec::new()
353            }
354            KeyCode::Up | KeyCode::Char('k') => {
355                m.modal = Some(Modal::Language {
356                    selected: cluster::moved(selected, -1, LANGS.len()),
357                });
358                Vec::new()
359            }
360            KeyCode::Down | KeyCode::Char('j') => {
361                m.modal = Some(Modal::Language {
362                    selected: cluster::moved(selected, 1, LANGS.len()),
363                });
364                Vec::new()
365            }
366            KeyCode::Enter => {
367                m.modal = None;
368                let lang = LANGS[selected.min(LANGS.len() - 1)];
369                m.lang = lang;
370                vec![Effect::SaveLanguage(lang)]
371            }
372            _ => Vec::new(),
373        },
374        Modal::Menu(menu) => crate::tui::menu::key(m, event, menu),
375        Modal::Message => topics::messages::detail_key(m, event),
376        Modal::Confirm(confirm) => confirm_key(m, event, confirm),
377        Modal::Profiles { selected } => profiles_key(m, event, selected),
378        Modal::Editor(mut editor) => {
379            let outcome = editor.handle(&event);
380            if editor.purpose.is_profile() {
381                profiles::editor_key(m, editor, outcome)
382            } else if editor.purpose.is_message() {
383                topics::messages::editor_key(m, editor, outcome)
384            } else if editor.purpose.is_topic() {
385                topics::editor_key(m, editor, outcome)
386            } else {
387                tokens::editor_key(m, editor, outcome)
388            }
389        }
390        Modal::Secret(secret) => tokens::secret_key(m, secret, event.code),
391        Modal::Report(_) | Modal::Condition(_) | Modal::TopicSummary => {
392            if matches!(
393                event.code,
394                KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q')
395            ) {
396                m.modal = None;
397            }
398            Vec::new()
399        }
400        Modal::Input(mut input) => match event.code {
401            KeyCode::Esc => {
402                m.modal = None;
403                Vec::new()
404            }
405            KeyCode::Enter => {
406                m.modal = None;
407                let value = input.value.trim().to_owned();
408                match &input.purpose {
409                    InputPurpose::ExportCsv { node, series } => {
410                        if value.is_empty() {
411                            return Vec::new();
412                        }
413                        let contents = cluster::export_csv(m, node, *series);
414                        vec![Effect::WriteFile {
415                            path: value,
416                            contents,
417                        }]
418                    }
419                    InputPurpose::TokenFilter => {
420                        m.tokens.filter = value;
421                        Vec::new()
422                    }
423                    InputPurpose::TopicFilter => {
424                        m.topics.filter = value;
425                        Vec::new()
426                    }
427                    InputPurpose::ExportTopicCsv => {
428                        if value.is_empty() {
429                            return Vec::new();
430                        }
431                        let contents = topics::monitor::export_csv(m);
432                        vec![Effect::WriteFile {
433                            path: value,
434                            contents,
435                        }]
436                    }
437                }
438            }
439            KeyCode::Backspace => {
440                input.value.pop();
441                m.modal = Some(Modal::Input(input));
442                Vec::new()
443            }
444            KeyCode::Char(c) if !ctrl => {
445                input.value.push(c);
446                m.modal = Some(Modal::Input(input));
447                Vec::new()
448            }
449            _ => Vec::new(),
450        },
451    }
452}
453
454/// The choice of a profile: the arrows move, `Enter` connects. At the start `Esc` takes the default one; later it only
455/// closes the popup.
456fn profiles_key(m: &mut Model, event: KeyEvent, selected: usize) -> Vec<Effect> {
457    let count = m.profiles.file.profiles.len();
458    let set = |m: &mut Model, selected: usize| m.modal = Some(Modal::Profiles { selected });
459    match event.code {
460        KeyCode::Up | KeyCode::Char('k') => set(m, cluster::moved(selected, -1, count)),
461        KeyCode::Down | KeyCode::Char('j') => set(m, cluster::moved(selected, 1, count)),
462        KeyCode::Home => set(m, 0),
463        KeyCode::End => set(m, count.saturating_sub(1)),
464        KeyCode::Enter => {
465            if let Some((name, _)) = m.profiles.file.profiles.get(selected) {
466                return vec![Effect::Connect(name.clone())];
467            }
468        }
469        KeyCode::Esc => {
470            if !m.awaiting_profile {
471                m.modal = None;
472            } else if let Some((name, _)) = m
473                .profiles
474                .file
475                .profiles
476                .get(profiles::preferred_index(&m.profiles.file))
477            {
478                return vec![Effect::Connect(name.clone())];
479            }
480        }
481        _ => {}
482    }
483    Vec::new()
484}
485
486fn palette_key(m: &mut Model, event: KeyEvent, query: &mut String, selected: usize) -> Vec<Effect> {
487    let count = palette_matches(m.lang, query).len();
488    match event.code {
489        KeyCode::Esc => {
490            m.modal = None;
491        }
492        KeyCode::Up => {
493            m.modal = Some(Modal::Palette {
494                query: query.clone(),
495                selected: cluster::moved(selected, -1, count),
496            });
497        }
498        KeyCode::Down => {
499            m.modal = Some(Modal::Palette {
500                query: query.clone(),
501                selected: cluster::moved(selected, 1, count),
502            });
503        }
504        KeyCode::Backspace => {
505            query.pop();
506            m.modal = Some(Modal::Palette {
507                query: query.clone(),
508                selected: 0,
509            });
510        }
511        KeyCode::Char(c) if !crate::tui::keymap::ctrl_of(&event) => {
512            query.push(c);
513            m.modal = Some(Modal::Palette {
514                query: query.clone(),
515                selected: 0,
516            });
517        }
518        KeyCode::Enter => {
519            let chosen = palette_matches(m.lang, query)
520                .get(selected)
521                .map(|(_, a)| *a);
522            m.modal = None;
523            if let Some(action) = chosen {
524                if let Some(binding) = keymap::BINDINGS.iter().find(|b| b.action == action) {
525                    match binding.scope {
526                        Scope::Cluster | Scope::Monitor => m.tab = Tab::Cluster,
527                        Scope::Tokens => m.tab = Tab::Tokens,
528                        Scope::Topics | Scope::Messages => m.tab = Tab::Topics,
529                        Scope::Profiles => m.tab = Tab::Profiles,
530                        Scope::Global => {}
531                    }
532                }
533                return dispatch(m, action);
534            }
535        }
536        _ => {}
537    }
538    Vec::new()
539}
540
541fn confirm_key(m: &mut Model, event: KeyEvent, mut confirm: Confirm) -> Vec<Effect> {
542    match event.code {
543        KeyCode::Esc => {
544            m.modal = None;
545            return Vec::new();
546        }
547        KeyCode::Enter => {
548            let allowed = match &confirm.typed {
549                Some(typed) => typed.input == typed.expected,
550                None => confirm.ok_focused,
551            };
552            if allowed {
553                m.modal = None;
554                return match (confirm.pending.call(), &confirm.pending) {
555                    (Some(call), _) => run(m, call),
556                    (None, Pending::RemoveProfile(name)) => profiles::remove(m, name),
557                    (None, _) => Vec::new(),
558                };
559            }
560            if confirm.typed.is_none() {
561                m.modal = None;
562            }
563            return Vec::new();
564        }
565        KeyCode::Left | KeyCode::Right | KeyCode::Tab | KeyCode::BackTab
566            if confirm.typed.is_none() =>
567        {
568            confirm.ok_focused = !confirm.ok_focused;
569        }
570        KeyCode::Backspace => {
571            if let Some(typed) = &mut confirm.typed {
572                typed.input.pop();
573            }
574        }
575        KeyCode::Char(c) if !crate::tui::keymap::ctrl_of(&event) => {
576            if let Some(typed) = &mut confirm.typed {
577                typed.input.push(c);
578            } else if matches!(c, 'h' | 'l') {
579                confirm.ok_focused = !confirm.ok_focused;
580            }
581        }
582        _ => {}
583    }
584    m.modal = Some(Modal::Confirm(confirm));
585    Vec::new()
586}
587
588/// Sends an action's call; the screen is `busy` until the reply comes.
589pub fn run(m: &mut Model, call: ApiCall) -> Vec<Effect> {
590    let id = m.id();
591    m.busy = Some(call.clone());
592    vec![Effect::Call {
593        id,
594        generation: m.generation,
595        call,
596    }]
597}
598
599// ---------- the clock: what to read, and when ----------
600
601fn on_tick(m: &mut Model, now: u64, wall_ms: i64) -> Vec<Effect> {
602    m.now = now;
603    m.wall_ms = wall_ms;
604    if m.notice.as_ref().is_some_and(|n| now >= n.until) {
605        m.notice = None;
606    }
607    let mut effects = Vec::new();
608    // Nothing is read until the person has chosen a profile.
609    if m.awaiting_profile {
610        return effects;
611    }
612    let background = !m.paused && m.focused && m.modal.is_none();
613    if background || m.me.data.is_none() {
614        poll_me(m, &mut effects);
615    }
616    if background && m.me.data.is_some() {
617        poll_overview(m, &mut effects);
618        poll_node(m, &mut effects);
619        poll_tokens(m, &mut effects);
620        poll_topics(m, &mut effects);
621        poll_topic_monitor(m, &mut effects);
622        poll_topic_preview(m, &mut effects);
623    }
624    poll_drain(m, &mut effects);
625    effects
626}
627
628fn issue(m: &mut Model, call: ApiCall) -> (u64, Effect) {
629    let id = m.id();
630    let effect = Effect::Call {
631        id,
632        generation: m.generation,
633        call,
634    };
635    (id, effect)
636}
637
638fn poll_me(m: &mut Model, effects: &mut Vec<Effect>) {
639    if m.me.due(m.now) {
640        let (id, effect) = issue(m, ApiCall::Me);
641        m.me.in_flight = Some(id);
642        effects.push(effect);
643    }
644}
645
646/// The status of the cluster (root) or the list of nodes (any token): it feeds the status line and the
647/// history. On the Cluster tab it is read often; on the others, only as often as the history samples.
648fn poll_overview(m: &mut Model, effects: &mut Vec<Effect>) {
649    let on_cluster = m.tab == Tab::Cluster;
650    if m.uses_status() && m.status.due(m.now) {
651        let (id, effect) = issue(m, ApiCall::ClusterStatus);
652        m.status.in_flight = Some(id);
653        effects.push(effect);
654    }
655    // The list of nodes also feeds the table of a root token: it remembers the nodes that left by drain.
656    if (!m.uses_status() || on_cluster) && m.cluster.nodes.due(m.now) {
657        let (id, effect) = issue(m, ApiCall::Nodes);
658        m.cluster.nodes.in_flight = Some(id);
659        effects.push(effect);
660    }
661}
662
663/// The tokens, read page by page while their tab is open.
664fn poll_tokens(m: &mut Model, effects: &mut Vec<Effect>) {
665    if m.tab != Tab::Tokens || tokens::availability(m) != tokens::Availability::Ready {
666        return;
667    }
668    if m.tokens.list.due(m.now) {
669        m.tokens.reading.clear();
670        m.tokens.pages = 1;
671        let (id, effect) = issue(m, ApiCall::Tokens(None));
672        m.tokens.list.in_flight = Some(id);
673        effects.push(effect);
674    }
675}
676
677/// The topics and their numbers, read page by page while their tab is open. The two reads go on side by side:
678/// the table appears with the definitions and the numbers fill in when they arrive.
679fn poll_topics(m: &mut Model, effects: &mut Vec<Effect>) {
680    if m.tab != Tab::Topics {
681        return;
682    }
683    if m.topics.list.due(m.now) {
684        m.topics.reading.clear();
685        m.topics.pages = 1;
686        let (id, effect) = issue(m, ApiCall::Topics(None));
687        m.topics.list.in_flight = Some(id);
688        effects.push(effect);
689    }
690    if m.topics.stats.due(m.now) {
691        m.topics.stats_reading = TopicStats::default();
692        m.topics.stats_pages = 1;
693        let (id, effect) = issue(m, ApiCall::TopicStats(None));
694        m.topics.stats.in_flight = Some(id);
695        effects.push(effect);
696    }
697}
698
699/// One reading of the queue whose charts are open, every sampling interval, whatever tab is shown.
700fn poll_topic_monitor(m: &mut Model, effects: &mut Vec<Effect>) {
701    let now = m.now;
702    let Some(mon) = &m.topics.monitor else {
703        return;
704    };
705    if mon.distributor || !mon.feed.due(now) {
706        return;
707    }
708    let name = mon.name.clone();
709    let (id, effect) = issue(m, ApiCall::TopicDetail(name));
710    if let Some(mon) = &mut m.topics.monitor {
711        mon.feed.in_flight = Some(id);
712    }
713    effects.push(effect);
714}
715
716/// One reading of the selected topic for the strip of charts under the list, while the list is what is shown.
717fn poll_topic_preview(m: &mut Model, effects: &mut Vec<Effect>) {
718    if m.tab != Tab::Topics || m.topics.monitor.is_some() {
719        return;
720    }
721    topics::preview::prepare(m);
722    let now = m.now;
723    let Some(name) = topics::preview::current(m)
724        .filter(|p| p.feed.due(now))
725        .map(|p| p.name.clone())
726    else {
727        return;
728    };
729    let (id, effect) = issue(m, ApiCall::TopicDetail(name.clone()));
730    if let Some(preview) = m.topics.previews.iter_mut().find(|p| p.name == name) {
731        preview.feed.in_flight = Some(id);
732    }
733    effects.push(effect);
734}
735
736fn poll_node(m: &mut Model, effects: &mut Vec<Effect>) {
737    if m.tab != Tab::Cluster || m.cluster.view != ClusterView::Detail || !m.is_admin() {
738        return;
739    }
740    let Some(row) = cluster::selected_row(m) else {
741        return;
742    };
743    if !row.reachable {
744        return;
745    }
746    if m.cluster.metrics.due(m.now) {
747        let (id, effect) = issue(m, ApiCall::NodeMetrics(row.id.clone()));
748        m.cluster.metrics.in_flight = Some(id);
749        effects.push(effect);
750    }
751    if m.cluster.backup.due(m.now) {
752        let (id, effect) = issue(m, ApiCall::NodeBackup(row.id));
753        m.cluster.backup.in_flight = Some(id);
754        effects.push(effect);
755    }
756}
757
758fn poll_drain(m: &mut Model, effects: &mut Vec<Effect>) {
759    let Some(watch) = m.cluster.watch.clone() else {
760        return;
761    };
762    if m.cluster.drain.due(m.now) {
763        let (id, effect) = issue(m, ApiCall::DrainStatus(watch.node));
764        m.cluster.drain.in_flight = Some(id);
765        effects.push(effect);
766    }
767}
768
769fn status_every(m: &Model) -> u64 {
770    if m.tab == Tab::Cluster {
771        STATUS_EVERY_MS
772    } else {
773        m.history.interval_ms()
774    }
775}
776
777fn nodes_every(m: &Model) -> u64 {
778    if m.tab == Tab::Cluster {
779        NODES_EVERY_MS
780    } else {
781        NODES_BACKGROUND_MS
782    }
783}
784
785// ---------- the replies ----------
786
787fn invalid(what: &str, detail: String) -> Fail {
788    Fail {
789        status: 200,
790        code: "client.invalid-response".to_owned(),
791        message: format!("{what}: {detail}"),
792        request_id: None,
793        retry_after: None,
794        required: None,
795    }
796}
797
798fn on_reply(
799    m: &mut Model,
800    id: u64,
801    generation: u64,
802    call: ApiCall,
803    result: Result<Reply, Error>,
804) -> Vec<Effect> {
805    if generation != m.generation {
806        return Vec::new();
807    }
808    let now = m.now;
809    let outcome: Result<String, Fail> = result.map(|r| r.text()).map_err(|e| Fail::from(&e));
810    if call.is_action() {
811        return on_action_reply(m, call, outcome);
812    }
813    match call {
814        ApiCall::Me => {
815            if m.me.in_flight != Some(id) {
816                return Vec::new();
817            }
818            match outcome.and_then(|t| api::parse_me(&t).map_err(|e| invalid("auth/me", e))) {
819                Ok(me) => m.me.ok(now, me, ME_EVERY_MS),
820                Err(fail) => m.me.fail(now, fail, 2_000),
821            }
822        }
823        ApiCall::ClusterStatus => {
824            if m.status.in_flight != Some(id) {
825                return Vec::new();
826            }
827            let every = status_every(m);
828            match outcome.and_then(|t| {
829                api::parse_cluster_status(&t).map_err(|e| invalid("cluster/status", e))
830            }) {
831                Ok(status) => {
832                    m.history.sample(now, &status);
833                    m.status.ok(now, status, every);
834                    m.status_denied = None;
835                }
836                Err(fail) if fail.is_denied() => {
837                    // Not the root token: fall back to the list of nodes (TUI-CLU-07).
838                    m.status.in_flight = None;
839                    m.status_denied = Some(fail);
840                }
841                Err(fail) => m.status.fail(now, fail, every),
842            }
843        }
844        ApiCall::Nodes => {
845            if m.cluster.nodes.in_flight != Some(id) {
846                return Vec::new();
847            }
848            let every = nodes_every(m);
849            match outcome.and_then(|t| api::parse_nodes(&t).map_err(|e| invalid("nodes", e))) {
850                Ok(nodes) => m.cluster.nodes.ok(now, nodes, every),
851                Err(fail) => m.cluster.nodes.fail(now, fail, every),
852            }
853        }
854        ApiCall::NodeMetrics(_) => {
855            if m.cluster.metrics.in_flight != Some(id) {
856                return Vec::new();
857            }
858            match outcome {
859                Ok(text) => m
860                    .cluster
861                    .metrics
862                    .ok(now, Metrics::parse(&text), METRICS_EVERY_MS),
863                Err(fail) => m.cluster.metrics.fail(now, fail, METRICS_EVERY_MS),
864            }
865        }
866        ApiCall::NodeBackup(_) => {
867            if m.cluster.backup.in_flight != Some(id) {
868                return Vec::new();
869            }
870            match outcome {
871                Ok(text) => match api::parse_backup(&text) {
872                    Ok(backup) => m.cluster.backup.ok(now, Some(backup), BACKUP_EVERY_MS),
873                    Err(e) => m
874                        .cluster
875                        .backup
876                        .fail(now, invalid("backup", e), BACKUP_EVERY_MS),
877                },
878                // No S3 backup configured is not an error: the section is simply left out.
879                Err(fail) if fail.code == "backup.not-configured" => {
880                    m.cluster.backup.ok(now, None, BACKUP_EVERY_MS);
881                }
882                Err(fail) => m.cluster.backup.fail(now, fail, BACKUP_EVERY_MS),
883            }
884        }
885        ApiCall::Tokens(_) => return on_tokens_reply(m, id, outcome),
886        ApiCall::Topics(_) => return on_topics_reply(m, id, outcome),
887        ApiCall::TopicStats(_) => return on_topic_stats_reply(m, id, outcome),
888        ApiCall::TopicDetail(name) => return on_topic_detail_reply(m, id, &name, outcome),
889        ApiCall::DrainStatus(_) => {
890            if m.cluster.drain.in_flight != Some(id) {
891                return Vec::new();
892            }
893            match outcome.and_then(|t| api::parse_drain(&t).map_err(|e| invalid("drain", e))) {
894                Ok(drain) => {
895                    let state = drain.state.clone();
896                    m.cluster.drain.ok(now, drain, DRAIN_EVERY_MS);
897                    finish_watch(m, &state);
898                }
899                Err(fail) => m.cluster.drain.fail(now, fail, DRAIN_EVERY_MS),
900            }
901        }
902        _ => {}
903    }
904    Vec::new()
905}
906
907/// A page of tokens: the next one is asked for until the server has no more.
908fn on_tokens_reply(m: &mut Model, id: u64, outcome: Result<String, Fail>) -> Vec<Effect> {
909    if m.tokens.list.in_flight != Some(id) {
910        return Vec::new();
911    }
912    let now = m.now;
913    match outcome.and_then(|t| api::parse_tokens(&t).map_err(|e| invalid("tokens", e))) {
914        Ok(page) => {
915            m.tokens.reading.extend(page.items);
916            match page.next_cursor {
917                Some(cursor) if m.tokens.pages < tokens::MAX_PAGES => {
918                    m.tokens.pages += 1;
919                    let (id, effect) = issue(m, ApiCall::Tokens(Some(cursor)));
920                    m.tokens.list.in_flight = Some(id);
921                    return vec![effect];
922                }
923                _ => {
924                    let all = std::mem::take(&mut m.tokens.reading);
925                    m.tokens.list.ok(now, all, TOKENS_EVERY_MS);
926                }
927            }
928        }
929        Err(fail) => {
930            m.tokens.reading.clear();
931            m.tokens.list.fail(now, fail, TOKENS_EVERY_MS);
932        }
933    }
934    Vec::new()
935}
936
937/// A page of topics: the next one is asked for until the server has no more.
938fn on_topics_reply(m: &mut Model, id: u64, outcome: Result<String, Fail>) -> Vec<Effect> {
939    if m.topics.list.in_flight != Some(id) {
940        return Vec::new();
941    }
942    let now = m.now;
943    match outcome.and_then(|t| api::parse_topics(&t).map_err(|e| invalid("topics", e))) {
944        Ok(page) => {
945            m.topics.reading.extend(page.items);
946            match page.next_cursor {
947                Some(cursor) if m.topics.pages < topics::MAX_PAGES => {
948                    m.topics.pages += 1;
949                    let (id, effect) = issue(m, ApiCall::Topics(Some(cursor)));
950                    m.topics.list.in_flight = Some(id);
951                    return vec![effect];
952                }
953                _ => {
954                    let all = std::mem::take(&mut m.topics.reading);
955                    m.topics.list.ok(now, all, TOPICS_EVERY_MS);
956                }
957            }
958        }
959        Err(fail) => {
960            m.topics.reading.clear();
961            m.topics.list.fail(now, fail, TOPICS_EVERY_MS);
962        }
963    }
964    Vec::new()
965}
966
967/// A page of the numbers of the topics. A failure only leaves the table without numbers (and says so).
968fn on_topic_stats_reply(m: &mut Model, id: u64, outcome: Result<String, Fail>) -> Vec<Effect> {
969    if m.topics.stats.in_flight != Some(id) {
970        return Vec::new();
971    }
972    let now = m.now;
973    match outcome.and_then(|t| api::parse_topic_stats(&t).map_err(|e| invalid("stats/topics", e))) {
974        Ok(page) => {
975            m.topics.stats_reading.by_name.extend(page.items);
976            m.topics.stats_reading.partial |= page.partial;
977            for missing in page.missing {
978                if !m.topics.stats_reading.missing.contains(&missing) {
979                    m.topics.stats_reading.missing.push(missing);
980                }
981            }
982            match page.next_cursor {
983                Some(cursor) if m.topics.stats_pages < topics::MAX_PAGES => {
984                    m.topics.stats_pages += 1;
985                    let (id, effect) = issue(m, ApiCall::TopicStats(Some(cursor)));
986                    m.topics.stats.in_flight = Some(id);
987                    return vec![effect];
988                }
989                _ => {
990                    let all = std::mem::take(&mut m.topics.stats_reading);
991                    m.topics.stats.ok(now, all, TOPICS_EVERY_MS);
992                }
993            }
994        }
995        Err(fail) => {
996            m.topics.stats_reading = TopicStats::default();
997            m.topics.stats.fail(now, fail, TOPICS_EVERY_MS);
998        }
999    }
1000    Vec::new()
1001}
1002
1003/// A reading for the charts of a queue: it becomes one point per line.
1004fn on_topic_detail_reply(
1005    m: &mut Model,
1006    id: u64,
1007    name: &str,
1008    outcome: Result<String, Fail>,
1009) -> Vec<Effect> {
1010    let (now, every, cap) = (m.now, m.history.interval_ms(), m.history.capacity());
1011    let open = m
1012        .topics
1013        .monitor
1014        .as_ref()
1015        .is_some_and(|mon| mon.name == name && mon.feed.in_flight == Some(id));
1016    let target = if open {
1017        m.topics.monitor.as_mut()
1018    } else {
1019        m.topics
1020            .previews
1021            .iter_mut()
1022            .find(|p| p.name == name && p.feed.in_flight == Some(id))
1023    };
1024    let Some(mon) = target else {
1025        return Vec::new();
1026    };
1027    match outcome.and_then(|t| api::parse_topic_sample(&t).map_err(|e| invalid("stats/topics", e)))
1028    {
1029        Ok(sample) => {
1030            topics::monitor::record(mon, cap, now, &sample);
1031            mon.feed.ok(now, (), every);
1032        }
1033        Err(fail) => mon.feed.fail(now, fail, every),
1034    }
1035    Vec::new()
1036}
1037
1038/// The drain reached what was being waited for: say so and refresh the cluster.
1039fn finish_watch(m: &mut Model, state: &str) {
1040    let Some(watch) = m.cluster.watch.clone() else {
1041        return;
1042    };
1043    let reached = match watch.goal {
1044        Goal::Drained => state == "drained",
1045        Goal::Active => state == "active",
1046    };
1047    if !reached {
1048        return;
1049    }
1050    let key = match watch.goal {
1051        Goal::Drained => "notice.drained",
1052        Goal::Active => "notice.returned",
1053    };
1054    let short = short_id(&watch.node);
1055    let text = m.tx().tw(key, &[("node", &short)]);
1056    m.notify(Severity::Ok, text);
1057    m.cluster.watch = None;
1058    m.status.refresh_now();
1059    m.cluster.nodes.refresh_now();
1060}
1061
1062fn on_action_reply(m: &mut Model, call: ApiCall, outcome: Result<String, Fail>) -> Vec<Effect> {
1063    if m.busy.as_ref() == Some(&call) {
1064        m.busy = None;
1065    }
1066    let text = match outcome {
1067        Ok(text) => text,
1068        Err(fail) => {
1069            // A form that was refused keeps the reason in itself, and stays open.
1070            if matches!(
1071                call,
1072                ApiCall::TokenCreate(_)
1073                    | ApiCall::TokenUpdate(..)
1074                    | ApiCall::TopicCreate(_)
1075                    | ApiCall::TopicUpdate(..)
1076                    | ApiCall::Publish(_)
1077                    | ApiCall::Receive(..)
1078                    | ApiCall::Visibility(..)
1079            ) && tokens::refused(m, &fail)
1080            {
1081                return Vec::new();
1082            }
1083            // An acknowledgement that is refused is a line, not a popup: several go together.
1084            if let ApiCall::Ack(_, id, _) | ApiCall::Visibility(_, id, _, _) = &call {
1085                topics::messages::refused(m, id, &fail.message);
1086                return Vec::new();
1087            }
1088            m.modal = Some(Modal::Report(Report::Failure { call, fail }));
1089            return Vec::new();
1090        }
1091    };
1092    let tx = m.tx();
1093    m.status.refresh_now();
1094    m.cluster.nodes.refresh_now();
1095    match &call {
1096        ApiCall::DrainStart(node) | ApiCall::DrainCancel(node) => {
1097            let (key, goal) = if matches!(call, ApiCall::DrainStart(_)) {
1098                ("notice.drain-started", Goal::Drained)
1099            } else {
1100                ("notice.return-started", Goal::Active)
1101            };
1102            let short = short_id(node);
1103            m.notify(Severity::Ok, tx.tw(key, &[("node", &short)]));
1104            cluster::watch(m, node, goal);
1105        }
1106        ApiCall::FlushNode(_) | ApiCall::FlushCluster => match api::parse_flush(&text) {
1107            Ok(report) => m.modal = Some(Modal::Report(Report::Flush(report))),
1108            Err(e) => {
1109                m.notify(
1110                    Severity::Warning,
1111                    tx.tw("notice.bad-reply", &[("error", &e)]),
1112                );
1113            }
1114        },
1115        ApiCall::StopCluster => match api::parse_stop(&text) {
1116            Ok(report) => m.modal = Some(Modal::Report(Report::Stop(report))),
1117            Err(e) => {
1118                m.notify(
1119                    Severity::Warning,
1120                    tx.tw("notice.bad-reply", &[("error", &e)]),
1121                );
1122            }
1123        },
1124        ApiCall::TokenCreate(_) | ApiCall::TokenUpdate(..) => {
1125            return tokens::saved(m, &call, &text)
1126        }
1127        ApiCall::TokenRevoke(_) => tokens::revoked(m),
1128        ApiCall::TopicCreate(_) | ApiCall::TopicUpdate(..) => {
1129            return topics::saved(m, &call, &text)
1130        }
1131        ApiCall::TopicDelete(name) => topics::deleted(m, name),
1132        ApiCall::Publish(_) => topics::messages::published(m, &text),
1133        ApiCall::Receive(topic, hold, _) => topics::messages::received(m, topic, *hold, &text),
1134        ApiCall::Ack(_, id, _) => topics::messages::acked(m, id),
1135        ApiCall::Visibility(_, id, _, secs) => topics::messages::visibility_changed(m, id, *secs),
1136        ApiCall::Prune(_) => topics::messages::pruned(m, &text),
1137        _ => {}
1138    }
1139    Vec::new()
1140}
1141
1142#[cfg(test)]
1143mod tests;