Skip to main content

taba_cli/tui/screens/topics/
mod.rs

1//! The Topics tab (doc 34, section 6.4): the topics of the server with their numbers, creating, editing and
2//! deleting them. This file is the state and the keys; `view.rs` draws it. The checks of the form are the cheap
3//! ones (a name, a number, a `key=value`); everything else is the server's to refuse, and its reason goes in the
4//! form (TUI-TPF-02).
5
6use taba_client::{Nullable, TopicRequest, TopicUpdate};
7
8use crate::tui::api::{ApiCall, Severity, Topic, TopicNumbers};
9use crate::tui::editor::{Editor, Field, Outcome, Purpose};
10use crate::tui::effect::Effect;
11use crate::tui::keymap::Action;
12use crate::tui::model::{
13    Confirm, Input, InputPurpose, Modal, Model, Pending, TopicSort, TopicTab, Typed,
14};
15use crate::tui::screens::cluster::moved;
16use crate::tui::update::run;
17
18pub mod messages;
19pub mod monitor;
20pub mod preview;
21pub mod view;
22
23/// How many pages of topics one read follows before it gives up (200 topics each).
24pub const MAX_PAGES: u32 = 25;
25
26/// The topics shown: those whose name has the filter in it, in the chosen order.
27pub fn visible(m: &Model) -> Vec<Topic> {
28    let wanted = m.topics.filter.to_lowercase();
29    let mut topics: Vec<Topic> = m
30        .topics
31        .list
32        .data
33        .iter()
34        .flatten()
35        .filter(|t| m.topics.show_dead || !t.dead_letter)
36        .filter(|t| wanted.is_empty() || t.name.to_lowercase().contains(&wanted))
37        .cloned()
38        .collect();
39    match m.topics.sort {
40        TopicSort::Name => topics.sort_by_key(|t| t.name.to_lowercase()),
41        TopicSort::Newest => topics.sort_by(|a, b| b.created_at.cmp(&a.created_at)),
42        TopicSort::Oldest => topics.sort_by(|a, b| a.created_at.cmp(&b.created_at)),
43        TopicSort::Depth => topics.sort_by(|a, b| {
44            let depth = |t: &Topic| numbers(m, &t.name).and_then(|n| n.depth).unwrap_or(0);
45            depth(b)
46                .cmp(&depth(a))
47                .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
48        }),
49    }
50    topics
51}
52
53/// The numbers of a topic, when the statistics have been read and have it.
54pub fn numbers<'a>(m: &'a Model, name: &str) -> Option<&'a TopicNumbers> {
55    m.topics.stats.data.as_ref()?.by_name.get(name)
56}
57
58/// The index of the selected topic in `topics`; the first one if the selection is gone.
59pub fn selected_index(m: &Model, topics: &[Topic]) -> Option<usize> {
60    if topics.is_empty() {
61        return None;
62    }
63    let found = m
64        .topics
65        .selected
66        .as_ref()
67        .and_then(|name| topics.iter().position(|t| &t.name == name));
68    Some(found.unwrap_or(0))
69}
70
71pub fn selected(m: &Model) -> Option<Topic> {
72    let topics = visible(m);
73    selected_index(m, &topics).map(|i| topics[i].clone())
74}
75
76/// Why an action cannot run now, as a catalog key; `None` means it can (TUI-FRM-10).
77pub fn gate(m: &Model, action: Action) -> Option<&'static str> {
78    let Some(me) = &m.me.data else {
79        return Some("gate.me-unknown");
80    };
81    if m.busy.is_some() {
82        return Some("gate.busy");
83    }
84    match action {
85        Action::TopicNew if !me.can("create") => Some("gate.need-create"),
86        Action::TopicEdit | Action::TopicDelete => {
87            let Some(topic) = selected(m) else {
88                return Some("gate.no-topic");
89            };
90            if topic.dead_letter {
91                Some("gate.topic-read-only")
92            } else if !me.can("manage") {
93                Some("gate.need-manage")
94            } else {
95                None
96            }
97        }
98        _ => None,
99    }
100}
101
102fn refuse(m: &mut Model, key: &str) -> Vec<Effect> {
103    let text = m.tx().t(key);
104    m.notify(Severity::Warning, text);
105    Vec::new()
106}
107
108pub fn on_action(m: &mut Model, action: Action) -> Vec<Effect> {
109    // With the charts of a queue open, the keys are theirs.
110    if m.topics.monitor.is_some() {
111        return monitor::on_action(m, action);
112    }
113    match action {
114        Action::Enter => crate::tui::menu::open(m),
115        Action::TopicSummary => match selected(m) {
116            Some(_) => {
117                m.modal = Some(Modal::TopicSummary);
118                Vec::new()
119            }
120            None => refuse(m, "gate.no-topic"),
121        },
122        Action::TopicCharts => monitor::open(m, TopicTab::Charts),
123        Action::TopicMessages => monitor::open(m, TopicTab::Messages),
124        // From the menu of the list: open the messages of the topic and then do the thing in them.
125        Action::MsgPublish | Action::MsgReceive | Action::MsgPrune => {
126            let mut effects = monitor::open(m, TopicTab::Messages);
127            if m.topics.monitor.is_some() {
128                effects.extend(messages::on_action(m, action));
129            }
130            effects
131        }
132        Action::Up => step(m, -1),
133        Action::Down => step(m, 1),
134        Action::PageUp => step(m, -10),
135        Action::PageDown => step(m, 10),
136        Action::First => step(m, i32::MIN / 2),
137        Action::Last => step(m, i32::MAX / 2),
138        Action::Sort => {
139            m.topics.sort = m.topics.sort.next();
140            Vec::new()
141        }
142        Action::TopicDead => {
143            m.topics.show_dead = !m.topics.show_dead;
144            Vec::new()
145        }
146        Action::Filter => {
147            m.modal = Some(Modal::Input(Input {
148                purpose: InputPurpose::TopicFilter,
149                value: m.topics.filter.clone(),
150            }));
151            Vec::new()
152        }
153        Action::TopicNew | Action::TopicEdit | Action::TopicDelete => {
154            if let Some(key) = gate(m, action) {
155                return refuse(m, key);
156            }
157            match action {
158                Action::TopicNew => open_create(m),
159                Action::TopicEdit => open_edit(m),
160                _ => open_delete(m),
161            }
162            Vec::new()
163        }
164        _ => Vec::new(),
165    }
166}
167
168fn step(m: &mut Model, delta: i32) -> Vec<Effect> {
169    let topics = visible(m);
170    if let Some(current) = selected_index(m, &topics) {
171        let next = moved(current, delta, topics.len());
172        m.topics.selected = Some(topics[next].name.clone());
173    }
174    Vec::new()
175}
176
177/// The text of a number field: nothing when the topic has none of its own.
178fn number_text(value: Option<u64>) -> String {
179    value.map(|n| n.to_string()).unwrap_or_default()
180}
181
182fn pairs_text(tags: &[(String, String)]) -> String {
183    tags.iter()
184        .map(|(k, v)| format!("{k}={v}"))
185        .collect::<Vec<_>>()
186        .join(" ")
187}
188
189fn open_create(m: &mut Model) {
190    let fields = vec![
191        Field::text("name", "editor.field.name", ""),
192        Field::check("distributor", "editor.field.distributor", false),
193        Field::check("fifo", "editor.field.fifo", false),
194        Field::check("volatile", "editor.field.volatile", false),
195        Field::text("idle", "editor.field.idle", ""),
196        Field::text("max-bytes", "editor.field.max-bytes", ""),
197        Field::text("ttl", "editor.field.ttl", ""),
198        Field::text("max-deliveries", "editor.field.max-deliveries", ""),
199        Field::text("tags", "editor.field.tags", ""),
200        Field::text("prefixes", "editor.field.topic-prefixes", ""),
201    ];
202    m.modal = Some(Modal::Editor(Editor::new(Purpose::TopicCreate, fields)));
203}
204
205fn open_edit(m: &mut Model) {
206    let Some(topic) = selected(m) else { return };
207    let distributor = topic.is_distributor();
208    let fixed = "editor.locked.topic";
209    let queue_only = |field: Field| {
210        if distributor {
211            field.locked("editor.locked.not-queue")
212        } else {
213            field
214        }
215    };
216    let was: Vec<(&'static str, String)> = vec![
217        ("idle", number_text(topic.idle_timeout)),
218        ("max-bytes", number_text(topic.max_message_bytes)),
219        ("ttl", number_text(topic.ttl)),
220        ("max-deliveries", number_text(topic.max_deliveries)),
221        ("tags", pairs_text(&topic.tags)),
222        ("prefixes", topic.prefixes.join(" ")),
223    ];
224    let value = |id: &str| {
225        was.iter()
226            .find(|(f, _)| *f == id)
227            .map(|(_, v)| v.clone())
228            .unwrap_or_default()
229    };
230    let idle = if distributor {
231        Field::text("idle", "editor.field.idle", &value("idle")).locked("editor.locked.not-queue")
232    } else if topic.volatile {
233        Field::text("idle", "editor.field.idle", &value("idle"))
234    } else {
235        Field::text("idle", "editor.field.idle", &value("idle"))
236            .locked("editor.locked.not-volatile")
237    };
238    let prefixes = {
239        let field = Field::text(
240            "prefixes",
241            "editor.field.topic-prefixes",
242            &value("prefixes"),
243        );
244        if distributor {
245            field
246        } else {
247            field.locked("editor.locked.not-distributor")
248        }
249    };
250    let fields = vec![
251        Field::text("name", "editor.field.name", &topic.name).locked(fixed),
252        Field::check("distributor", "editor.field.distributor", distributor).locked(fixed),
253        Field::check("fifo", "editor.field.fifo", topic.fifo).locked(fixed),
254        Field::check("volatile", "editor.field.volatile", topic.volatile).locked(fixed),
255        idle,
256        queue_only(Field::text(
257            "max-bytes",
258            "editor.field.max-bytes",
259            &value("max-bytes"),
260        )),
261        queue_only(Field::text("ttl", "editor.field.ttl", &value("ttl"))),
262        queue_only(Field::text(
263            "max-deliveries",
264            "editor.field.max-deliveries",
265            &value("max-deliveries"),
266        )),
267        Field::text("tags", "editor.field.tags", &value("tags")),
268        prefixes,
269    ];
270    m.modal = Some(Modal::Editor(Editor::new(
271        Purpose::TopicEdit {
272            name: topic.name,
273            was,
274        },
275        fields,
276    )));
277}
278
279fn open_delete(m: &mut Model) {
280    if let Some(topic) = selected(m) {
281        m.modal = Some(Modal::Confirm(Confirm {
282            typed: Some(Typed {
283                expected: topic.name.clone(),
284                input: String::new(),
285            }),
286            pending: Pending::DeleteTopic(topic.name),
287            ok_focused: false,
288        }));
289    }
290}
291
292/// A problem with a field of the form: the field and the catalog key of what to tell.
293type Problem = (&'static str, &'static str);
294
295/// A topic name is made of letters, digits, `_` and `-` (doc 09).
296pub fn valid_name(name: &str) -> bool {
297    !name.is_empty()
298        && name
299            .chars()
300            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-'))
301}
302
303/// `Ok(None)` for an empty field, the number otherwise.
304fn parse_number(text: &str) -> Result<Option<u64>, ()> {
305    let text = text.trim();
306    if text.is_empty() {
307        return Ok(None);
308    }
309    text.parse().map(Some).map_err(|_| ())
310}
311
312fn words(text: &str) -> impl Iterator<Item = &str> {
313    text.split([',', ' ']).filter(|w| !w.is_empty())
314}
315
316/// `key=value` pairs separated by spaces or commas.
317fn parse_tags(text: &str) -> Result<Vec<(String, String)>, ()> {
318    words(text)
319        .map(|word| match word.split_once('=') {
320            Some((k, v)) if !k.is_empty() => Ok((k.to_owned(), v.to_owned())),
321            _ => Err(()),
322        })
323        .collect()
324}
325
326fn parse_prefixes(text: &str) -> Vec<String> {
327    words(text).map(str::to_owned).collect()
328}
329
330/// `default` is the same as nothing: the value of the node.
331fn normal(text: &str) -> &str {
332    let text = text.trim();
333    if text.eq_ignore_ascii_case("default") {
334        ""
335    } else {
336        text
337    }
338}
339
340fn build_create(editor: &Editor) -> Result<ApiCall, Problem> {
341    let name = editor.text("name");
342    if name.is_empty() {
343        return Err(("name", "editor.error.name"));
344    }
345    if !valid_name(name) {
346        return Err(("name", "editor.error.topic-name"));
347    }
348    let number =
349        |id: &'static str| parse_number(editor.text(id)).map_err(|()| (id, "editor.error.number"));
350    let tags = parse_tags(editor.text("tags")).map_err(|()| ("tags", "editor.error.tags"))?;
351    let prefixes = parse_prefixes(editor.text("prefixes"));
352    let (idle, max_bytes, ttl, deliveries) = (
353        number("idle")?,
354        number("max-bytes")?,
355        number("ttl")?,
356        number("max-deliveries")?,
357    );
358    let distributor = editor.checked("distributor");
359    let (fifo, volatile) = (editor.checked("fifo"), editor.checked("volatile"));
360    let mut request = TopicRequest {
361        name: name.to_owned(),
362        topic_type: if distributor { "distributor" } else { "queue" }.to_owned(),
363        tags,
364        prefixes: prefixes.clone(),
365        ..TopicRequest::default()
366    };
367    if distributor {
368        if prefixes.is_empty() {
369            return Err(("prefixes", "editor.error.prefixes-needed"));
370        }
371        if fifo
372            || volatile
373            || idle.is_some()
374            || max_bytes.is_some()
375            || ttl.is_some()
376            || deliveries.is_some()
377        {
378            return Err(("distributor", "editor.error.distributor-fields"));
379        }
380    } else {
381        if !prefixes.is_empty() {
382            return Err(("prefixes", "editor.error.queue-prefixes"));
383        }
384        if idle.is_some() && !volatile {
385            return Err(("idle", "editor.error.idle-needs-volatile"));
386        }
387        request.volatile = volatile.then_some(true);
388        request.fifo = fifo.then_some(true);
389        request.idle_timeout = idle;
390        request.max_message_bytes = max_bytes;
391        request.ttl = ttl;
392        request.max_deliveries = deliveries;
393    }
394    Ok(ApiCall::TopicCreate(request.to_json()))
395}
396
397fn build_update(
398    editor: &Editor,
399    name: &str,
400    was: &[(&'static str, String)],
401) -> Result<ApiCall, Problem> {
402    let before = |id: &str| {
403        was.iter()
404            .find(|(f, _)| *f == id)
405            .map_or("", |(_, v)| v.as_str())
406    };
407    let changed = |id: &str| normal(editor.text(id)) != normal(before(id));
408    let limit = |id: &'static str| -> Result<Nullable<u64>, Problem> {
409        if !changed(id) {
410            return Ok(Nullable::Keep);
411        }
412        match parse_number(normal(editor.text(id))) {
413            Ok(Some(n)) => Ok(Nullable::Set(n)),
414            Ok(None) => Ok(Nullable::Default),
415            Err(()) => Err((id, "editor.error.number")),
416        }
417    };
418    let mut update = TopicUpdate::default();
419    if changed("tags") {
420        update.tags =
421            Some(parse_tags(editor.text("tags")).map_err(|()| ("tags", "editor.error.tags"))?);
422    }
423    if changed("prefixes") {
424        let prefixes = parse_prefixes(editor.text("prefixes"));
425        if prefixes.is_empty() {
426            return Err(("prefixes", "editor.error.prefixes-needed"));
427        }
428        update.prefixes = Some(prefixes);
429    }
430    if changed("idle") {
431        match parse_number(editor.text("idle")) {
432            Ok(Some(n)) => update.idle_timeout = Some(n),
433            _ => return Err(("idle", "editor.error.number")),
434        }
435    }
436    update.max_message_bytes = limit("max-bytes")?;
437    update.ttl = limit("ttl")?;
438    update.max_deliveries = limit("max-deliveries")?;
439    let body = update.to_json();
440    if body == "{}" {
441        return Err(("tags", "editor.error.nothing"));
442    }
443    Ok(ApiCall::TopicUpdate(name.to_owned(), body))
444}
445
446/// The form was filled in and the person asked to save it.
447pub fn submit(m: &mut Model, mut editor: Editor) -> Vec<Effect> {
448    let built = match &editor.purpose {
449        Purpose::TopicCreate => build_create(&editor),
450        Purpose::TopicEdit { name, was } => build_update(&editor, name, was),
451        _ => return Vec::new(),
452    };
453    match built {
454        Err((field, key)) => {
455            editor.error = Some(m.tx().t(key));
456            editor.focus_on(field);
457            m.modal = Some(Modal::Editor(editor));
458            Vec::new()
459        }
460        Ok(call) => {
461            editor.busy = true;
462            editor.error = None;
463            m.modal = Some(Modal::Editor(editor));
464            run(m, call)
465        }
466    }
467}
468
469/// The keys of the form of a topic.
470pub fn editor_key(m: &mut Model, editor: Editor, outcome: Outcome) -> Vec<Effect> {
471    match outcome {
472        Outcome::Cancel => {
473            m.modal = None;
474            Vec::new()
475        }
476        Outcome::Submit => submit(m, editor),
477        Outcome::Test | Outcome::None => {
478            m.modal = Some(Modal::Editor(editor));
479            Vec::new()
480        }
481    }
482}
483
484/// The server answered the form: the topic was created or changed.
485pub fn saved(m: &mut Model, call: &ApiCall, text: &str) -> Vec<Effect> {
486    let tx = m.tx();
487    if matches!(m.modal, Some(Modal::Editor(_))) {
488        m.modal = None;
489    }
490    m.topics.list.refresh_now();
491    m.topics.stats.refresh_now();
492    let created = matches!(call, ApiCall::TopicCreate(_));
493    let key = if created {
494        "notice.topic-created"
495    } else {
496        "notice.topic-updated"
497    };
498    match crate::tui::api::parse_topic(text) {
499        Ok(topic) => {
500            let notice = tx.tw(key, &[("name", &topic.name)]);
501            if created {
502                m.topics.selected = Some(topic.name);
503            }
504            m.notify(Severity::Ok, notice);
505        }
506        Err(e) => {
507            m.notify(
508                Severity::Warning,
509                tx.tw("notice.bad-reply", &[("error", &e)]),
510            );
511        }
512    }
513    Vec::new()
514}
515
516/// A topic was deleted: it leaves the table at once (with its dead-letter queue), before the next read.
517pub fn deleted(m: &mut Model, name: &str) {
518    let text = m.tx().tw("notice.topic-deleted", &[("name", &name)]);
519    m.notify(Severity::Ok, text);
520    if let Some(list) = &mut m.topics.list.data {
521        list.retain(|t| t.name != name && t.source.as_deref() != Some(name));
522    }
523    m.topics.list.refresh_now();
524    m.topics.stats.refresh_now();
525}
526
527#[cfg(test)]
528mod tests;