Skip to main content

taba_cli/tui/screens/cluster/
mod.rs

1//! The Cluster tab (doc 34, sections 6.2 and 6.3): the conditions, the nodes, the detail of a node, its
2//! charts and the actions of node and cluster. This file is the state and the keys; `view.rs` draws it.
3
4use 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/// A node of the table: the same row from `GET /cluster/status` (root) or `GET /nodes` (any token).
17#[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
26/// What a chart draws: a title and the lines (one or two) with their legend keys.
27pub 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
42/// The series of a node that can be charted (TUI-MON-01).
43pub 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
85/// The nodes of the table, in the order the server gave them.
86pub 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            // A drained node leaves the member table, so the status no longer lists it; `GET /nodes` still does,
101            // and the table keeps it so it can be returned.
102            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
141/// The index of the selected node in `rows`; the first one if the selection is gone.
142pub 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/// Why an action cannot run now; `None` means it can.
169#[derive(Debug, Clone, Copy, PartialEq, Eq)]
170pub enum Gate {
171    /// Needs the `admin` permission (or the root token).
172    NotAdmin,
173    /// Needs the root token itself (stopping the cluster).
174    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
196/// Whether an action of the Cluster tab can run now, and why not (TUI-CLU-05, TUI-FRM-10).
197pub 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
222/// The keys of the Cluster tab. `Enter` opens the detail; the rest are the actions and the movement.
223pub 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
297/// `index` moved by `delta` inside `0..count`, without going around.
298pub 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
333/// A key that cannot work now says why, in the footer (TUI-FRM-10).
334pub 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    // `End` and the wheel may ask for more than there is: keep the offset where the text really ends.
365    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        // Esc leaves the full screen first, then the charts.
396        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
441/// How many windows of `WINDOWS_MIN` fit in the retention (never above it, TUI-MON-03); at least one.
442pub 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
450/// The window in milliseconds, clipped to what is retained.
451pub 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
460/// The points of a line of a chart: the window, up to the instant it was frozen at.
461pub 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
469/// The visible series as CSV (TUI-MON-07): the time in UTC and one column per line.
470pub 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
505/// Starts watching a node's drain until it reaches `goal`.
506pub 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
514/// The call an action's confirmation runs.
515pub fn call_of(pending: &Pending) -> Option<ApiCall> {
516    pending.call()
517}
518
519#[cfg(test)]
520mod tests;