stama 1.1.1

A terminal user interface for monitoring and managing slurm jobs.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
use crossterm::event::{KeyEvent, MouseEvent};
use ratatui::{
    layout::{Flex, Layout, Rect},
    Frame,
};

use crate::app::Action;
use crate::menus::{
    confirmation::Confirmation,
    help::{HelpContext, HelpMenu},
    job_actions::JobActionsMenu,
    job_overview::JobOverview,
    log_viewer::LogViewer,
    message::Message,
    node_select::NodeSelectMenu,
    user_options_menu::UserOptionsMenu,
};
use crate::mouse_input::MouseInput;
use crate::{joblist::JobList, user_options::UserOptions};

use self::salloc::salloc_menu::SallocMenu;

pub mod confirmation;
pub mod help;
pub mod job_actions;
pub mod job_overview;
pub mod log_viewer;
pub mod message;
pub mod node_select;
pub mod salloc;
pub mod user_options_menu;

#[derive(Debug, Clone)]
pub enum OpenMenu {
    UserOptions,
    Help(HelpContext),
    Salloc,
    JobActions,
    /// The fullscreen live log view of the selected job
    LogView,
    /// The node selection popup for ssh-ing into a multi-node job
    NodeSelect {
        job_id: String,
        nodes: Vec<String>,
    },
    Message(message::Message),
}

// ===================================================================
//  MENU TRAIT
// ===================================================================

/// The common interface of all popup menus.
///
/// A menu is either open or closed: an open menu is rendered and
/// receives keyboard and mouse input, a closed one is skipped by the
/// `MenuContainer`. The container only calls `render`, `input` and
/// `mouse_input` while the menu is open, so implementations need no
/// "am I open?" guards of their own.
pub trait Menu {
    /// Whether the menu is currently open
    fn is_open(&self) -> bool;

    /// Render the menu. Only called while the menu is open.
    fn render(&mut self, f: &mut Frame, area: &Rect);

    /// Handle a key event. Only called while the menu is open.
    /// Returns true if the event was consumed (menus below and the
    /// job overview then do not see it).
    fn input(&mut self, action: &mut Action, key_event: KeyEvent) -> bool;

    /// Handle a mouse event. Only called while the menu is open.
    /// Menus mark the event as handled via `MouseInput` so that the
    /// menus below them ignore it.
    fn mouse_input(&mut self, action: &mut Action, mouse_input: &mut MouseInput);
}

// ===================================================================
//  SHARED HELPERS
// ===================================================================

/// The size of one popup dimension
#[derive(Debug, Clone, Copy)]
pub enum PopupSize {
    /// A fraction of the frame dimension (0.0..=1.0)
    Fraction(f32),
    /// A fixed number of terminal cells (clipped to the frame)
    Fixed(u16),
}

impl PopupSize {
    /// Resolve the size to a cell count, never exceeding `total`
    fn resolve(self, total: u16) -> u16 {
        match self {
            PopupSize::Fraction(fraction) => (fraction * total as f32) as u16,
            PopupSize::Fixed(cells) => cells.min(total),
        }
    }
}

/// Compute a centered popup rect inside the given frame area.
/// The returned rect never extends beyond the frame area, so it is
/// safe to render into even on very narrow terminals. The caller
/// renders `Clear` plus its own block into the rect.
pub fn centered_popup(frame_area: Rect, width: PopupSize, height: PopupSize) -> Rect {
    let width = width.resolve(frame_area.width);
    let height = height.resolve(frame_area.height);
    let vertical = Layout::vertical([height]).flex(Flex::Center);
    let horizontal = Layout::horizontal([width]).flex(Flex::Center);
    let [rect] = vertical.areas(frame_area);
    let [rect] = horizontal.areas(rect);
    rect
}

/// Wrap-around index arithmetic shared by the list menus: stepping
/// past the last entry wraps to the first and vice versa. A jump
/// beyond either end also wraps (a click below the last row selects
/// the first entry, matching the previous per-menu implementations).
///
/// `len` is the number of selectable rows; a menu with a synthetic
/// trailing row (like the salloc "Create new" row) passes `len + 1`.
pub fn wrap_index(current: isize, delta: isize, len: usize) -> usize {
    if len == 0 {
        return 0;
    }
    let max = len as isize - 1;
    let target = current + delta;
    if target > max {
        0
    } else if target < 0 {
        max as usize
    } else {
        target as usize
    }
}

/// The Menu Container that contains all menus and dispatches
/// rendering, keyboard and mouse events to them
pub struct MenuContainer {
    /// The Job Overview (Main Task Manager Window)
    pub job_overview: JobOverview,
    /// A menu that shows the available action for the
    /// selected job
    pub job_actions_menu: JobActionsMenu,
    /// A popup to pick one node of a multi-node job to ssh to
    pub node_select_menu: NodeSelectMenu,
    /// The fullscreen live log view of the selected job
    pub log_viewer: LogViewer,
    /// A menu for allocating jobs (salloc)
    pub salloc_menu: SallocMenu,
    /// A menu that shows the configurable user options
    pub user_options_menu: UserOptionsMenu,
    /// A popup window that shows help for keybindings
    pub help_menu: HelpMenu,
    /// A popup window that displays a message
    pub message: Message,
    /// A popup window that asks for confirmation
    pub confirmation: Confirmation,
}

// ===================================================================
//  CONSTRUCTOR
// ===================================================================

impl MenuContainer {
    /// Construct a new menu container
    pub fn new(user_options: &UserOptions, joblist: &JobList) -> Self {
        Self {
            job_overview: JobOverview::new(
                user_options.refresh_rate,
                &joblist.squeue_command,
                user_options.job_columns.clone(),
            ),
            job_actions_menu: JobActionsMenu::new(),
            node_select_menu: NodeSelectMenu::new(),
            log_viewer: LogViewer::new(),
            salloc_menu: SallocMenu::new(),
            help_menu: HelpMenu::new(),
            message: Message::new_disabled(),
            confirmation: Confirmation::new_disabled(),
            user_options_menu: UserOptionsMenu::load(),
        }
    }
}

// ===================================================================
// METHODS
// ===================================================================

impl MenuContainer {
    /// The popup menus in front-to-back order (the most modal first).
    /// Rendering, keyboard input and mouse input all derive from this
    /// single ordering, so keys and clicks always go to the same menu.
    fn popups_front_to_back(&mut self) -> [&mut dyn Menu; 8] {
        [
            &mut self.confirmation,
            &mut self.message,
            &mut self.help_menu,
            // the fullscreen log view covers everything except the
            // dialogs and the help popup above it
            &mut self.log_viewer,
            &mut self.user_options_menu,
            &mut self.salloc_menu,
            // the node selection opens on top of the job actions menu
            // (which closes itself when it emits the ssh action)
            &mut self.node_select_menu,
            &mut self.job_actions_menu,
        ]
    }

    /// Opens a selected menu
    pub fn activate_menu(&mut self, open_menu: OpenMenu, joblist: &JobList) {
        match open_menu {
            OpenMenu::JobActions => {
                self.open_job_action(joblist);
            }
            OpenMenu::LogView => {
                self.open_log_view(joblist);
            }
            OpenMenu::Salloc => {
                self.salloc_menu.activate();
            }
            OpenMenu::UserOptions => {
                self.user_options_menu.activate();
            }
            OpenMenu::NodeSelect { job_id, nodes } => {
                self.node_select_menu.activate(&job_id, nodes);
            }
            OpenMenu::Message(message) => {
                self.message = message;
            }
            OpenMenu::Help(context) => {
                self.help_menu.open(context);
            }
        }
    }

    /// Opens the job actions menu
    /// This menu shows all the possible actions for the selected job.
    /// For a selected job-array group row, "Kill" targets the whole
    /// array and the other actions apply to the group's first task.
    fn open_job_action(&mut self, joblist: &JobList) {
        if let Some((base_id, task_count, job)) = joblist.selected_group() {
            self.job_actions_menu
                .activate_group(&base_id, task_count, job);
            return;
        }
        match joblist.get_job() {
            Some(job) => {
                self.job_actions_menu.activate(job);
            }
            None => {
                self.message = Message::new("No job selected");
                self.message.kind = message::MessageKind::Error;
            }
        }
    }

    /// Opens the fullscreen live log view for the selected job (for a
    /// selected job-array group row: for the group's first task). If no
    /// job is selected or the job has no log path (e.g. completed jobs
    /// from sacct), an error message is shown instead.
    fn open_log_view(&mut self, joblist: &JobList) {
        let job = match joblist.get_job() {
            Some(job) => job,
            None => {
                self.error_message("No job selected");
                return;
            }
        };
        match job.get_stdout() {
            Some(path) => self.log_viewer.activate(&path),
            None => self.error_message("No log file found"),
        }
    }

    fn error_message(&mut self, text: &str) {
        self.message = Message::new(text);
        self.message.kind = message::MessageKind::Error;
    }
}

// ===================================================================
//  RENDER
// ===================================================================

impl MenuContainer {
    /// Render all menus
    pub fn render(&mut self, f: &mut Frame, area: &Rect, joblist: &JobList) {
        // the job overview is the always-visible base screen
        self.job_overview.render(f, area, joblist);
        // render the popups from back to front so that the
        // frontmost menu is drawn last (on top)
        for menu in self.popups_front_to_back().into_iter().rev() {
            if menu.is_open() {
                menu.render(f, area);
            }
        }
    }
}

// ===================================================================
//  INPUT
// ===================================================================

impl MenuContainer {
    /// Handle keyboard input for all menus
    pub fn input(&mut self, action: &mut Action, key_event: KeyEvent) {
        // pass the key event to the open popups from front to back;
        // the first menu that consumes it wins
        for menu in self.popups_front_to_back() {
            if menu.is_open() && menu.input(action, key_event) {
                return;
            }
        }
        // fall through to the base screen
        self.job_overview.input(action, key_event);
    }

    /// Handle mouse input for all menus
    pub fn mouse_input(
        &mut self,
        action: &mut Action,
        mouse_input: &mut MouseInput,
        mouse_event: MouseEvent,
    ) {
        // first update the mouse input with the event
        mouse_input.handled = false;
        mouse_input.event = Some(mouse_event);

        // pass the mouse event to the open popups from front to back
        // (the same order as keyboard input); a menu that handles the
        // event marks it as handled so the menus below ignore it
        for menu in self.popups_front_to_back() {
            if menu.is_open() {
                menu.mouse_input(action, mouse_input);
            }
        }
        self.job_overview.mouse_input(action, mouse_input);
    }
}

// ===================================================================
//  TESTS
// ===================================================================

#[cfg(test)]
mod tests {
    use super::*;
    use crossterm::event::{KeyCode, KeyModifiers, MouseButton, MouseEventKind};

    // ----------------------------------------------------------------
    //  wrap_index
    // ----------------------------------------------------------------

    #[test]
    fn test_wrap_index_steps_within_bounds() {
        assert_eq!(wrap_index(0, 1, 5), 1);
        assert_eq!(wrap_index(3, -1, 5), 2);
        assert_eq!(wrap_index(2, 0, 5), 2);
    }

    #[test]
    fn test_wrap_index_wraps_at_the_ends() {
        // stepping past the last entry wraps to the first
        assert_eq!(wrap_index(4, 1, 5), 0);
        // stepping before the first entry wraps to the last
        assert_eq!(wrap_index(0, -1, 5), 4);
    }

    #[test]
    fn test_wrap_index_jump_beyond_the_ends_wraps() {
        // an absolute jump beyond the end selects the first entry
        // (e.g. a click below the last row)
        assert_eq!(wrap_index(17, 0, 5), 0);
        assert_eq!(wrap_index(-3, 0, 5), 4);
    }

    #[test]
    fn test_wrap_index_empty_list() {
        assert_eq!(wrap_index(0, 1, 0), 0);
        assert_eq!(wrap_index(0, -1, 0), 0);
    }

    #[test]
    fn test_wrap_index_with_synthetic_trailing_row() {
        // a menu with a synthetic trailing row passes len + 1:
        // index == len is a valid selection
        assert_eq!(wrap_index(2, 1, 3 + 1), 3);
        assert_eq!(wrap_index(3, 1, 3 + 1), 0);
        assert_eq!(wrap_index(0, -1, 3 + 1), 3);
    }

    // ----------------------------------------------------------------
    //  centered_popup
    // ----------------------------------------------------------------

    #[test]
    fn test_centered_popup_is_centered() {
        let frame = Rect::new(0, 0, 100, 50);
        let rect = centered_popup(frame, PopupSize::Fixed(40), PopupSize::Fixed(10));
        assert_eq!(rect, Rect::new(30, 20, 40, 10));
    }

    #[test]
    fn test_centered_popup_fraction() {
        let frame = Rect::new(0, 0, 100, 50);
        let rect = centered_popup(frame, PopupSize::Fraction(0.8), PopupSize::Fraction(0.8));
        assert_eq!(rect.width, 80);
        assert_eq!(rect.height, 40);
    }

    #[test]
    fn test_centered_popup_clips_to_narrow_frame() {
        // a fixed size larger than the frame must be clipped so that
        // rendering into the rect cannot panic on narrow terminals
        let frame = Rect::new(0, 0, 10, 5);
        let rect = centered_popup(frame, PopupSize::Fixed(40), PopupSize::Fixed(9));
        assert!(rect.width <= frame.width);
        assert!(rect.height <= frame.height);
        assert_eq!(rect.intersection(frame), rect);
    }

    // ----------------------------------------------------------------
    //  MenuContainer input routing
    // ----------------------------------------------------------------

    fn container() -> MenuContainer {
        MenuContainer::new(&UserOptions::default(), &JobList::new())
    }

    fn key(code: KeyCode) -> KeyEvent {
        KeyEvent::new(code, KeyModifiers::NONE)
    }

    fn left_click(column: u16, row: u16) -> MouseEvent {
        MouseEvent {
            kind: MouseEventKind::Down(MouseButton::Left),
            column,
            row,
            modifiers: KeyModifiers::NONE,
        }
    }

    /// Regression test for the key/mouse ordering inconsistency: with
    /// both the confirmation and the message popup open, a key event
    /// must go to the confirmation (the most modal menu) and leave the
    /// message untouched.
    #[test]
    fn test_key_input_goes_to_confirmation_before_message() {
        let mut container = container();
        container.confirmation = Confirmation::new("Quit?", Action::ConfirmedQuit);
        container.message = Message::new("some message");

        let mut action = Action::None;
        container.input(&mut action, key(KeyCode::Esc));

        // Esc denies the confirmation; the message stays open
        assert!(!container.confirmation.is_open());
        assert!(container.message.is_open());
    }

    /// Regression test for the key/mouse ordering inconsistency: a
    /// mouse event must go to the confirmation first as well (it used
    /// to go to the message while key events went to the confirmation).
    #[test]
    fn test_mouse_input_goes_to_confirmation_before_message() {
        let mut container = container();
        container.confirmation = Confirmation::new("Quit?", Action::ConfirmedQuit);
        container.message = Message::new("some message");

        let mut action = Action::None;
        let mut mouse_input = MouseInput::new();
        // the popups were never rendered, so their rects are empty and
        // the click lands outside of them: it closes the confirmation
        container.mouse_input(&mut action, &mut mouse_input, left_click(0, 0));

        assert!(!container.confirmation.is_open());
        assert!(container.message.is_open());
    }

    /// An open node selection popup consumes key input: navigation
    /// keys move its selection instead of falling through to the job
    /// overview, and Enter emits the ssh action for the chosen node.
    #[test]
    fn test_open_node_select_consumes_key_input() {
        let mut container = container();
        container.activate_menu(
            OpenMenu::NodeSelect {
                job_id: "4242".to_string(),
                nodes: vec!["gpu1".to_string(), "gpu3".to_string()],
            },
            &JobList::new(),
        );
        assert!(container.node_select_menu.is_open());

        let mut action = Action::None;
        container.input(&mut action, key(KeyCode::Char('j')));

        // the popup consumed the key: the selection moved and no
        // action leaked through to the base screen
        assert_eq!(container.node_select_menu.index, 1);
        assert!(matches!(action, Action::None));

        container.input(&mut action, key(KeyCode::Enter));
        match action {
            Action::SshToNode(node) => assert_eq!(node, "gpu3"),
            other => panic!("expected Action::SshToNode, got {:?}", other),
        }
        assert!(!container.node_select_menu.is_open());
    }

    /// Opening the job actions menu on a job-array group row targets
    /// the whole array: the kill action carries the base id and the
    /// labels/title name the array and its task count.
    #[test]
    fn test_job_actions_on_group_row_target_the_array() {
        use crate::job::{Job, JobStatus};
        use crate::menus::job_actions::JobActions;

        let mut container = container();
        let mut joblist = JobList::new();
        for id in ["100_1", "100_2"] {
            joblist.jobs.push(Job::new(
                id,
                "array_job",
                JobStatus::Running,
                "00:00:00",
                "main",
                1,
                "/work",
                "cmd",
                None,
            ));
        }
        // row 0 is the collapsed group header
        assert!(joblist.selected_group().is_some());

        container.activate_menu(OpenMenu::JobActions, &joblist);

        assert!(container.job_actions_menu.is_open());
        assert_eq!(
            container.job_actions_menu.job_name,
            "job array 100 (2 tasks)"
        );
        assert_eq!(
            container.job_actions_menu.labels[0],
            "1. Kill job array (2 tasks)"
        );
        match &container.job_actions_menu.actions[0] {
            JobActions::KillArray {
                base_id,
                task_count,
            } => {
                assert_eq!(base_id, "100");
                assert_eq!(*task_count, 2);
            }
            other => panic!("expected KillArray, got {:?}", other),
        }
        // the other actions apply to the group's first task
        match &container.job_actions_menu.actions[1] {
            JobActions::OpenLog(job) => assert_eq!(job.id, "100_1"),
            other => panic!("expected OpenLog, got {:?}", other),
        }

        // opening the menu for a plain job afterwards restores the
        // standard labels and title
        let mut joblist = JobList::new();
        joblist.jobs.push(Job::new_default());
        container.activate_menu(OpenMenu::JobActions, &joblist);
        assert_eq!(container.job_actions_menu.labels[0], "1. Kill job");
        assert_eq!(container.job_actions_menu.job_name, "jobname");
        assert!(matches!(
            container.job_actions_menu.actions[0],
            JobActions::Kill(_)
        ));
    }

    /// Opening the log view resolves the log path of the selected job
    /// (with %j/%x placeholders expanded) and the fullscreen viewer
    /// consumes key input until it is closed.
    #[test]
    fn test_open_log_view_for_selected_job() {
        use crate::job::{Job, JobStatus};

        let mut container = container();
        let mut joblist = JobList::new();
        let mut job = Job::new_default();
        job.id = "4242".to_string();
        job.output = Some("/logs/run-%j.out".to_string());
        joblist.jobs.push(job);

        container.activate_menu(OpenMenu::LogView, &joblist);

        assert!(container.log_viewer.is_open());
        assert_eq!(container.log_viewer.path, "/logs/run-4242.out");
        // the viewer requests the initial read for its path
        let request = container.log_viewer.follow_request().unwrap();
        assert_eq!(request.path, "/logs/run-4242.out");
        assert_eq!(request.offset, None);

        // keys go to the viewer, not to the job overview ('q' would
        // otherwise quit the app)
        let mut action = Action::None;
        container.input(&mut action, key(KeyCode::Char('q')));
        assert!(matches!(action, Action::None));
        assert!(!container.log_viewer.is_open());

        // a job without a log path opens an error message instead
        let mut sacct_job = Job::new_default();
        sacct_job.status = JobStatus::Completed;
        sacct_job.output = None;
        let mut joblist = JobList::new();
        joblist.jobs.push(sacct_job);
        container.activate_menu(OpenMenu::LogView, &joblist);
        assert!(!container.log_viewer.is_open());
        assert!(container.message.is_open());
        assert!(container.message.text.contains("No log file found"));

        // an empty job list opens an error message as well
        container.message = Message::new_disabled();
        container.activate_menu(OpenMenu::LogView, &JobList::new());
        assert!(!container.log_viewer.is_open());
        assert!(container.message.text.contains("No job selected"));
    }

    /// The 'L' key of the job overview opens the log view through the
    /// same action path the app dispatches.
    #[test]
    fn test_job_overview_key_l_emits_log_view_action() {
        let mut container = container();
        let mut action = Action::None;
        container.input(&mut action, key(KeyCode::Char('L')));
        assert!(matches!(action, Action::OpenMenu(OpenMenu::LogView)));
    }

    /// Confirming the dialog with 'y' emits the stored action
    #[test]
    fn test_confirmation_key_confirm_emits_action() {
        let mut container = container();
        container.confirmation = Confirmation::new("Quit?", Action::ConfirmedQuit);

        let mut action = Action::None;
        container.input(&mut action, key(KeyCode::Char('y')));

        assert!(matches!(action, Action::ConfirmedQuit));
        assert!(!container.confirmation.is_open());
    }
}