1use 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
23pub const MAX_PAGES: u32 = 25;
25
26pub 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
53pub fn numbers<'a>(m: &'a Model, name: &str) -> Option<&'a TopicNumbers> {
55 m.topics.stats.data.as_ref()?.by_name.get(name)
56}
57
58pub 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
76pub 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 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 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
177fn 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
292type Problem = (&'static str, &'static str);
294
295pub 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
303fn 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
316fn 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
330fn 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
446pub 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
469pub 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
484pub 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
516pub 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;