1use crate::tui::api::{ApiCall, NodeStatus, Severity};
5use crate::tui::effect::Effect;
6use crate::tui::history::{Point, Series};
7use crate::tui::i18n;
8use crate::tui::keymap::Action;
9use crate::tui::model::{
10 file_tag, ClusterView, Confirm, DrainWatch, Goal, Input, InputPurpose, Modal, Model, Pane,
11 Pending, Typed, WINDOWS_MIN,
12};
13
14pub mod view;
15
16#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct Row {
19 pub id: String,
20 pub state: String,
21 pub drain_state: String,
22 pub reachable: bool,
23 pub is_self: bool,
24}
25
26pub struct ChartDef {
28 pub key: &'static str,
29 pub title: &'static str,
30 pub lines: &'static [(Series, &'static str)],
31 pub unit: Unit,
32}
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum Unit {
36 Bytes,
37 Count,
38 PerSecond,
39 Seconds,
40}
41
42pub const MON_CHARTS: &[ChartDef] = &[
44 ChartDef {
45 key: "memory",
46 title: "chart.memory",
47 lines: &[
48 (Series::MemoryUsed, "chart.line.used"),
49 (Series::MemoryLimit, "chart.line.limit"),
50 ],
51 unit: Unit::Bytes,
52 },
53 ChartDef {
54 key: "ready",
55 title: "chart.ready",
56 lines: &[(Series::MemoryMessages, "chart.line.in-memory")],
57 unit: Unit::Count,
58 },
59 ChartDef {
60 key: "disk",
61 title: "chart.disk",
62 lines: &[(Series::DiskMessages, "chart.line.on-disk")],
63 unit: Unit::Count,
64 },
65 ChartDef {
66 key: "requests",
67 title: "chart.requests",
68 lines: &[(Series::ActiveRequests, "chart.line.requests")],
69 unit: Unit::Count,
70 },
71 ChartDef {
72 key: "rejected",
73 title: "chart.rejected",
74 lines: &[(Series::RateLimited, "chart.line.rejected")],
75 unit: Unit::PerSecond,
76 },
77 ChartDef {
78 key: "rtt",
79 title: "chart.rtt",
80 lines: &[(Series::ChannelRtt, "chart.line.rtt")],
81 unit: Unit::Seconds,
82 },
83];
84
85pub fn rows(m: &Model) -> Vec<Row> {
87 if m.uses_status() {
88 if let Some(status) = &m.status.data {
89 let mut rows: Vec<Row> = status
90 .nodes
91 .iter()
92 .map(|n| Row {
93 id: n.node_id.clone(),
94 state: n.state.clone(),
95 drain_state: n.drain_state.clone(),
96 reachable: n.reachable,
97 is_self: false,
98 })
99 .collect();
100 if let Some(nodes) = &m.cluster.nodes.data {
103 let missing: Vec<_> = nodes
104 .items
105 .iter()
106 .filter(|n| !rows.iter().any(|r| r.id == n.node_id))
107 .collect();
108 for n in missing {
109 rows.push(Row {
110 id: n.node_id.clone(),
111 state: n.state.clone(),
112 drain_state: n.drain_state.clone(),
113 reachable: n.reachable || n.state == "drained",
114 is_self: n.is_self,
115 });
116 }
117 }
118 return rows;
119 }
120 }
121 m.cluster
122 .nodes
123 .data
124 .as_ref()
125 .map(|nodes| {
126 nodes
127 .items
128 .iter()
129 .map(|n| Row {
130 id: n.node_id.clone(),
131 state: n.state.clone(),
132 drain_state: n.drain_state.clone(),
133 reachable: n.reachable,
134 is_self: n.is_self,
135 })
136 .collect()
137 })
138 .unwrap_or_default()
139}
140
141pub fn selected_index(m: &Model, rows: &[Row]) -> Option<usize> {
143 if rows.is_empty() {
144 return None;
145 }
146 let found = m
147 .cluster
148 .node
149 .as_ref()
150 .and_then(|id| rows.iter().position(|r| &r.id == id));
151 Some(found.unwrap_or(0))
152}
153
154pub fn selected_row(m: &Model) -> Option<Row> {
155 let rows = rows(m);
156 selected_index(m, &rows).map(|i| rows[i].clone())
157}
158
159pub fn status_of<'a>(m: &'a Model, id: &str) -> Option<&'a NodeStatus> {
160 m.status
161 .data
162 .as_ref()?
163 .nodes
164 .iter()
165 .find(|n| n.node_id == id && n.reachable)
166}
167
168#[derive(Debug, Clone, Copy, PartialEq, Eq)]
170pub enum Gate {
171 NotAdmin,
173 NotRoot,
175 NoNode,
176 Unreachable,
177 NotActive,
178 NotDraining,
179 Busy,
180}
181
182impl Gate {
183 pub fn key(self) -> &'static str {
184 match self {
185 Gate::NotAdmin => "gate.not-admin",
186 Gate::NotRoot => "gate.not-root",
187 Gate::NoNode => "gate.no-node",
188 Gate::Unreachable => "gate.unreachable",
189 Gate::NotActive => "gate.not-active",
190 Gate::NotDraining => "gate.not-draining",
191 Gate::Busy => "gate.busy",
192 }
193 }
194}
195
196pub fn gate(m: &Model, action: Action) -> Option<Gate> {
198 if m.busy.is_some() {
199 return Some(Gate::Busy);
200 }
201 if !m.is_admin() {
202 return Some(Gate::NotAdmin);
203 }
204 if action == Action::StopCluster && !m.is_root() {
205 return Some(Gate::NotRoot);
206 }
207 let node_gate = |needs: fn(&Row) -> Option<Gate>| match selected_row(m) {
208 None => Some(Gate::NoNode),
209 Some(row) if !row.reachable => Some(Gate::Unreachable),
210 Some(row) => needs(&row),
211 };
212 match action {
213 Action::Drain => node_gate(|r| (r.drain_state != "active").then_some(Gate::NotActive)),
214 Action::Return => node_gate(|r| {
215 (!matches!(r.drain_state.as_str(), "draining" | "drained")).then_some(Gate::NotDraining)
216 }),
217 Action::FlushNode | Action::Monitor | Action::Detail => node_gate(|_| None),
218 _ => None,
219 }
220}
221
222pub fn on_action(m: &mut Model, action: Action) -> Vec<Effect> {
224 match m.cluster.view {
225 ClusterView::List => list_action(m, action),
226 ClusterView::Detail => detail_action(m, action),
227 ClusterView::Monitor => monitor_action(m, action),
228 }
229}
230
231fn list_action(m: &mut Model, action: Action) -> Vec<Effect> {
232 match action {
233 Action::Up => step(m, -1),
234 Action::Down => step(m, 1),
235 Action::PageUp => step(m, -10),
236 Action::PageDown => step(m, 10),
237 Action::First => step(m, i32::MIN / 2),
238 Action::Last => step(m, i32::MAX / 2),
239 Action::PaneNext | Action::PanePrev => {
240 m.cluster.pane = match m.cluster.pane {
241 Pane::Conditions => Pane::Nodes,
242 Pane::Nodes => Pane::Conditions,
243 };
244 Vec::new()
245 }
246 Action::Enter => {
247 if m.cluster.pane == Pane::Conditions {
248 return open_condition(m);
249 }
250 crate::tui::menu::open(m)
251 }
252 Action::Detail => {
253 if m.cluster.pane == Pane::Conditions {
254 return open_condition(m);
255 }
256 open(m, ClusterView::Detail, Action::Detail)
257 }
258 Action::Monitor => open(m, ClusterView::Monitor, Action::Monitor),
259 Action::Drain => confirm(m, action, |row| Pending::Drain(row.id.clone())),
260 Action::Return => confirm(m, action, |row| Pending::Return(row.id.clone())),
261 Action::FlushNode => confirm(m, action, |row| Pending::FlushNode(row.id.clone())),
262 Action::FlushAll => ask(m, action, Pending::FlushAll, None),
263 Action::StopCluster => {
264 let name = m
265 .status
266 .data
267 .as_ref()
268 .and_then(|s| s.nodes.iter().find_map(|n| n.cluster_name.clone()))
269 .unwrap_or_else(|| "stop".to_owned());
270 let typed = Typed {
271 expected: name,
272 input: String::new(),
273 };
274 ask(m, action, Pending::Stop, Some(typed))
275 }
276 _ => Vec::new(),
277 }
278}
279
280fn step(m: &mut Model, delta: i32) -> Vec<Effect> {
281 match m.cluster.pane {
282 Pane::Conditions => {
283 let count = m.status.data.as_ref().map_or(0, |s| s.conditions.len());
284 m.cluster.condition = moved(m.cluster.condition, delta, count);
285 }
286 Pane::Nodes => {
287 let rows = rows(m);
288 if let Some(current) = selected_index(m, &rows) {
289 let next = moved(current, delta, rows.len());
290 m.cluster.node = Some(rows[next].id.clone());
291 }
292 }
293 }
294 Vec::new()
295}
296
297pub fn moved(index: usize, delta: i32, count: usize) -> usize {
299 if count == 0 {
300 return 0;
301 }
302 let target = i64::try_from(index).unwrap_or(0) + i64::from(delta);
303 target.clamp(0, i64::try_from(count - 1).unwrap_or(0)) as usize
304}
305
306fn open_condition(m: &mut Model) -> Vec<Effect> {
307 let name = m
308 .status
309 .data
310 .as_ref()
311 .and_then(|s| s.conditions.get(m.cluster.condition))
312 .map(|c| c.name.clone());
313 if let Some(name) = name {
314 m.modal = Some(Modal::Condition(name));
315 }
316 Vec::new()
317}
318
319fn open(m: &mut Model, view: ClusterView, needs: Action) -> Vec<Effect> {
320 if let Some(reason) = gate(m, needs) {
321 return refuse(m, reason);
322 }
323 m.cluster.view = view;
324 m.cluster.scroll = 0;
325 m.cluster.metrics.reset();
326 m.cluster.backup.reset();
327 if view == ClusterView::Monitor {
328 m.cluster.monitor.frozen = None;
329 }
330 Vec::new()
331}
332
333pub fn refuse(m: &mut Model, reason: Gate) -> Vec<Effect> {
335 let text = m.tx().t(reason.key());
336 m.notify(Severity::Warning, text);
337 Vec::new()
338}
339
340fn confirm(m: &mut Model, action: Action, pending: fn(&Row) -> Pending) -> Vec<Effect> {
341 if let Some(reason) = gate(m, action) {
342 return refuse(m, reason);
343 }
344 match selected_row(m) {
345 Some(row) => ask(m, action, pending(&row), None),
346 None => refuse(m, Gate::NoNode),
347 }
348}
349
350fn ask(m: &mut Model, action: Action, pending: Pending, typed: Option<Typed>) -> Vec<Effect> {
351 if let Some(reason) = gate(m, action) {
352 return refuse(m, reason);
353 }
354 m.modal = Some(Modal::Confirm(Confirm {
355 pending,
356 typed,
357 ok_focused: false,
358 }));
359 Vec::new()
360}
361
362fn detail_action(m: &mut Model, action: Action) -> Vec<Effect> {
363 let effects = detail_keys(m, action);
364 let visible = usize::from(m.size.1.saturating_sub(3));
366 let max = view::detail_lines(m).len().saturating_sub(visible);
367 m.cluster.scroll = m.cluster.scroll.min(u16::try_from(max).unwrap_or(u16::MAX));
368 effects
369}
370
371fn detail_keys(m: &mut Model, action: Action) -> Vec<Effect> {
372 match action {
373 Action::Esc | Action::Left => m.cluster.view = ClusterView::List,
374 Action::Up => m.cluster.scroll = m.cluster.scroll.saturating_sub(1),
375 Action::Down => m.cluster.scroll = m.cluster.scroll.saturating_add(1),
376 Action::PageUp => m.cluster.scroll = m.cluster.scroll.saturating_sub(10),
377 Action::PageDown => m.cluster.scroll = m.cluster.scroll.saturating_add(10),
378 Action::First => m.cluster.scroll = 0,
379 Action::Last => m.cluster.scroll = u16::MAX,
380 Action::Monitor => m.cluster.view = ClusterView::Monitor,
381 Action::Drain
382 | Action::Return
383 | Action::FlushNode
384 | Action::FlushAll
385 | Action::StopCluster => return list_action(m, action),
386 _ => {}
387 }
388 Vec::new()
389}
390
391fn monitor_action(m: &mut Model, action: Action) -> Vec<Effect> {
392 let total = MON_CHARTS.len();
393 let monitor = &mut m.cluster.monitor;
394 match action {
395 Action::Esc if monitor.full => monitor.full = false,
397 Action::Esc => m.cluster.view = ClusterView::List,
398 Action::Up => monitor.chart = moved(monitor.chart, -1, total),
399 Action::Down => monitor.chart = moved(monitor.chart, 1, total),
400 Action::First => monitor.chart = 0,
401 Action::Last => monitor.chart = total - 1,
402 Action::ChartFull => monitor.full = !monitor.full,
403 Action::WindowWider => {
404 let allowed = allowed_windows(m.history.retention_ms());
405 m.cluster.monitor.window = (m.cluster.monitor.window + 1).min(allowed - 1);
406 }
407 Action::WindowNarrower => {
408 m.cluster.monitor.window = m.cluster.monitor.window.saturating_sub(1);
409 }
410 Action::PauseChart => {
411 m.cluster.monitor.frozen = match m.cluster.monitor.frozen {
412 Some(_) => None,
413 None => Some(m.now),
414 };
415 }
416 Action::Scale => m.cluster.monitor.fixed_scale = !m.cluster.monitor.fixed_scale,
417 Action::Export => {
418 if let Some(node) = m
419 .cluster
420 .node
421 .clone()
422 .or_else(|| selected_row(m).map(|r| r.id))
423 {
424 let chart = m.cluster.monitor.chart;
425 let key = MON_CHARTS[chart].key;
426 let short = file_tag(&node);
427 m.modal = Some(Modal::Input(Input {
428 purpose: InputPurpose::ExportCsv {
429 node,
430 series: chart,
431 },
432 value: format!("taba-{short}-{key}.csv"),
433 }));
434 }
435 }
436 _ => {}
437 }
438 Vec::new()
439}
440
441pub fn allowed_windows(retention_ms: u64) -> usize {
443 WINDOWS_MIN
444 .iter()
445 .filter(|min| **min * 60_000 <= retention_ms)
446 .count()
447 .max(1)
448}
449
450pub fn window_ms(m: &Model) -> u64 {
452 let index = m
453 .cluster
454 .monitor
455 .window
456 .min(allowed_windows(m.history.retention_ms()) - 1);
457 WINDOWS_MIN[index] * 60_000
458}
459
460pub fn line_points(m: &Model, node: &str, series: Series) -> Vec<Point> {
462 let at = m.cluster.monitor.frozen.unwrap_or(m.now);
463 m.history
464 .series(node, series)
465 .map(|r| r.window(at, window_ms(m)))
466 .unwrap_or_default()
467}
468
469pub fn export_csv(m: &Model, node: &str, chart: usize) -> String {
471 let def = &MON_CHARTS[chart.min(MON_CHARTS.len() - 1)];
472 let tx = m.tx();
473 let mut out = String::from("time");
474 for (_, name) in def.lines {
475 out.push(',');
476 out.push_str(&tx.t(name).replace(',', " "));
477 }
478 out.push('\n');
479 let lines: Vec<Vec<Point>> = def
480 .lines
481 .iter()
482 .map(|(series, _)| line_points(m, node, *series))
483 .collect();
484 let offset = m.wall_ms - i64::try_from(m.now).unwrap_or(0);
485 if let Some(first) = lines.first() {
486 for point in first {
487 let wall = offset + i64::try_from(point.at).unwrap_or(0);
488 let c = i18n::civil_from_ms(wall);
489 out.push_str(&format!(
490 "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
491 c.year, c.month, c.day, c.hour, c.minute, c.second
492 ));
493 for line in &lines {
494 out.push(',');
495 if let Some(p) = line.iter().find(|p| p.at == point.at) {
496 out.push_str(&p.value.to_string());
497 }
498 }
499 out.push('\n');
500 }
501 }
502 out
503}
504
505pub fn watch(m: &mut Model, node: &str, goal: Goal) {
507 m.cluster.watch = Some(DrainWatch {
508 node: node.to_owned(),
509 goal,
510 });
511 m.cluster.drain.reset();
512}
513
514pub fn call_of(pending: &Pending) -> Option<ApiCall> {
516 pending.call()
517}
518
519#[cfg(test)]
520mod tests;