1use 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
20const 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 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
104pub 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 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
149pub 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
227fn 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
239fn 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
267pub 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
292pub 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
302pub 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 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
454fn 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
588pub 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
599fn 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 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
646fn 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 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
663fn 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
677fn 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
699fn 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
716fn 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
785fn 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 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 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
907fn 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
937fn 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
967fn 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
1003fn 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
1038fn 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 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 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;