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
//! The display rows of the job table: job-array grouping.
//!
//! The job list itself stays a flat `Vec<Job>` (sorting, duplicate
//! removal, notifications and the scheduler know nothing about
//! grouping). [`build_rows`] derives the *display* rows from it: jobs
//! that are not array tasks become [`JobRow::Single`] rows, and two or
//! more tasks sharing an array base id (e.g. "12345_1", "12345_7",
//! "12345_[8-99]") collapse into one [`JobRow::Group`] header row.
//! Expanded groups additionally list their tasks as indented
//! [`JobRow::Task`] rows right below the header. The rows are a pure
//! function of the job list, the grouping option and the set of
//! expanded base ids, so they can be recomputed at any time without
//! going stale.

use std::collections::{HashMap, HashSet};

use crate::job::{array_base_id, parse_array_id, pending_range_count, ArrayTask, Job, JobStatus};

/// One display row of the job table. The indices point into the flat
/// job list the rows were built from.
#[derive(Debug, Clone, PartialEq)]
pub enum JobRow {
    /// A job that is not part of a displayed array group.
    Single { job_index: usize },
    /// The header row of a job-array group (>= 2 tasks with the same
    /// base id). The task indices are in job-list order.
    Group {
        base_id: String,
        task_indices: Vec<usize>,
        expanded: bool,
    },
    /// One task of an expanded group, rendered indented below the
    /// header. `last` marks the final task (for the tree glyph).
    Task { job_index: usize, last: bool },
}

/// Builds the display rows for the given job list.
///
/// With `group_arrays` disabled every job becomes a `Single` row. With
/// it enabled, jobs whose array base id occurs at least twice are
/// grouped: the group header is placed at the position of the first
/// task in job-list order (so groups stay contiguous under every sort
/// category), and base ids contained in `expanded` get their task rows
/// emitted below the header.
pub fn build_rows(jobs: &[Job], group_arrays: bool, expanded: &HashSet<String>) -> Vec<JobRow> {
    if !group_arrays {
        return (0..jobs.len())
            .map(|job_index| JobRow::Single { job_index })
            .collect();
    }
    // collect the task indices per base id, in job-list order
    let mut tasks_by_base: HashMap<&str, Vec<usize>> = HashMap::new();
    for (index, job) in jobs.iter().enumerate() {
        if let Some(base) = array_base_id(&job.id) {
            tasks_by_base.entry(base).or_default().push(index);
        }
    }
    let mut emitted: HashSet<&str> = HashSet::new();
    let mut rows = Vec::with_capacity(jobs.len());
    for (job_index, job) in jobs.iter().enumerate() {
        let base = array_base_id(&job.id);
        let group = base.and_then(|base| {
            let task_indices = tasks_by_base.get(base)?;
            // a lone array task is displayed like a plain job
            (task_indices.len() >= 2).then_some((base, task_indices))
        });
        let Some((base, task_indices)) = group else {
            rows.push(JobRow::Single { job_index });
            continue;
        };
        if !emitted.insert(base) {
            // the group header was already emitted at the first task
            continue;
        }
        let is_expanded = expanded.contains(base);
        rows.push(JobRow::Group {
            base_id: base.to_string(),
            task_indices: task_indices.clone(),
            expanded: is_expanded,
        });
        if is_expanded {
            let last_pos = task_indices.len() - 1;
            rows.extend(
                task_indices
                    .iter()
                    .enumerate()
                    .map(|(pos, &index)| JobRow::Task {
                        job_index: index,
                        last: pos == last_pos,
                    }),
            );
        }
    }
    rows
}

/// Narrows display rows to the jobs matching a filter predicate.
///
/// Single rows are kept iff their job matches. A group row is kept iff
/// at least one of its tasks matches, and its task indices are narrowed
/// to the matching tasks, so the aggregate status counts reflect only
/// the matching tasks. The task rows of an expanded group are rebuilt
/// from the narrowed indices (with the tree glyphs recomputed).
pub fn filter_rows<F>(rows: Vec<JobRow>, jobs: &[Job], matches: F) -> Vec<JobRow>
where
    F: Fn(&Job) -> bool,
{
    let mut filtered = Vec::with_capacity(rows.len());
    for row in rows {
        match row {
            JobRow::Single { job_index } => {
                if matches(&jobs[job_index]) {
                    filtered.push(JobRow::Single { job_index });
                }
            }
            JobRow::Group {
                base_id,
                task_indices,
                expanded,
            } => {
                let matching: Vec<usize> = task_indices
                    .into_iter()
                    .filter(|&index| matches(&jobs[index]))
                    .collect();
                if matching.is_empty() {
                    continue;
                }
                let task_rows = expanded.then(|| {
                    let last_pos = matching.len() - 1;
                    matching
                        .iter()
                        .enumerate()
                        .map(|(pos, &job_index)| JobRow::Task {
                            job_index,
                            last: pos == last_pos,
                        })
                        .collect::<Vec<JobRow>>()
                });
                filtered.push(JobRow::Group {
                    base_id,
                    task_indices: matching,
                    expanded,
                });
                if let Some(task_rows) = task_rows {
                    filtered.extend(task_rows);
                }
            }
            // the tasks of an expanded group are rebuilt from the
            // narrowed group above, so the original rows are dropped
            JobRow::Task { .. } => {}
        }
    }
    filtered
}

/// The compact aggregate status of a group's tasks, e.g. "3R 10PD 37CD".
///
/// The counts are listed in a fixed order (R, PD, CG, CD, F, TO, CA, ?)
/// and zero counts are omitted. A pending-range placeholder task like
/// "12345_[8-99]" counts as the number of tasks its range stands for.
pub fn status_counts(tasks: &[&Job]) -> String {
    const ORDER: [JobStatus; 8] = [
        JobStatus::Running,
        JobStatus::Pending,
        JobStatus::Completing,
        JobStatus::Completed,
        JobStatus::Failed,
        JobStatus::Timeout,
        JobStatus::Cancelled,
        JobStatus::Unknown,
    ];
    let mut counts = [0u64; ORDER.len()];
    for job in tasks {
        let weight = match parse_array_id(&job.id) {
            Some((_, ArrayTask::Range(range))) => pending_range_count(&range),
            _ => 1,
        };
        if let Some(position) = ORDER.iter().position(|status| status == &job.status) {
            counts[position] += weight;
        }
    }
    ORDER
        .iter()
        .zip(counts)
        .filter(|(_, count)| *count > 0)
        .map(|(status, count)| format!("{}{}", count, status.abbrev()))
        .collect::<Vec<_>>()
        .join(" ")
}

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

#[cfg(test)]
mod tests {
    use super::*;

    fn job(id: &str, status: JobStatus) -> Job {
        Job::new(
            id,
            &format!("job_{}", id),
            status,
            "00:10:00",
            "main",
            1,
            "/work",
            "cmd",
            None,
        )
    }

    /// A mixed list: two singles and two arrays (base 100 and 200).
    fn mixed_jobs() -> Vec<Job> {
        vec![
            job("42", JobStatus::Running),
            job("100_1", JobStatus::Running),
            job("100_2", JobStatus::Pending),
            job("7", JobStatus::Completed),
            job("200_0", JobStatus::Running),
            job("200_[5-9]", JobStatus::Pending),
        ]
    }

    #[test]
    fn grouping_disabled_yields_only_singles() {
        let jobs = mixed_jobs();
        let rows = build_rows(&jobs, false, &HashSet::new());
        assert_eq!(rows.len(), jobs.len());
        assert!(rows.iter().all(|row| matches!(row, JobRow::Single { .. })));
    }

    #[test]
    fn mixed_singles_and_two_arrays_are_grouped() {
        let jobs = mixed_jobs();
        let rows = build_rows(&jobs, true, &HashSet::new());
        assert_eq!(
            rows,
            vec![
                JobRow::Single { job_index: 0 },
                JobRow::Group {
                    base_id: "100".to_string(),
                    task_indices: vec![1, 2],
                    expanded: false,
                },
                JobRow::Single { job_index: 3 },
                JobRow::Group {
                    base_id: "200".to_string(),
                    task_indices: vec![4, 5],
                    expanded: false,
                },
            ]
        );
    }

    #[test]
    fn lone_array_task_stays_a_single_row() {
        // only one task of base 100: no group is formed
        let jobs = vec![
            job("42", JobStatus::Running),
            job("100_1", JobStatus::Running),
        ];
        let rows = build_rows(&jobs, true, &HashSet::new());
        assert_eq!(
            rows,
            vec![
                JobRow::Single { job_index: 0 },
                JobRow::Single { job_index: 1 },
            ]
        );
    }

    #[test]
    fn expanded_group_lists_its_tasks_below_the_header() {
        let jobs = mixed_jobs();
        let expanded: HashSet<String> = ["100".to_string()].into();
        let rows = build_rows(&jobs, true, &expanded);
        assert_eq!(
            rows,
            vec![
                JobRow::Single { job_index: 0 },
                JobRow::Group {
                    base_id: "100".to_string(),
                    task_indices: vec![1, 2],
                    expanded: true,
                },
                JobRow::Task {
                    job_index: 1,
                    last: false,
                },
                JobRow::Task {
                    job_index: 2,
                    last: true,
                },
                JobRow::Single { job_index: 3 },
                JobRow::Group {
                    base_id: "200".to_string(),
                    task_indices: vec![4, 5],
                    expanded: false,
                },
            ]
        );
    }

    #[test]
    fn scattered_tasks_form_a_contiguous_group_at_the_first_task() {
        // e.g. after sorting by status the tasks of one array may not
        // be adjacent in the flat list; the group must still be one row
        let jobs = vec![
            job("100_1", JobStatus::Running),
            job("42", JobStatus::Running),
            job("100_2", JobStatus::Pending),
        ];
        let rows = build_rows(&jobs, true, &HashSet::new());
        assert_eq!(
            rows,
            vec![
                JobRow::Group {
                    base_id: "100".to_string(),
                    task_indices: vec![0, 2],
                    expanded: false,
                },
                JobRow::Single { job_index: 1 },
            ]
        );
    }

    // ----------------------------------------------------------------
    // filter_rows
    // ----------------------------------------------------------------

    #[test]
    fn filter_keeps_matching_singles_and_narrows_groups() {
        // mixed_jobs: single "42" (R), group 100 (100_1 R, 100_2 PD),
        // single "7" (CD), group 200 (200_0 R, 200_[5-9] PD)
        let jobs = mixed_jobs();
        let rows = build_rows(&jobs, true, &HashSet::new());
        let rows = filter_rows(rows, &jobs, |job| job.status == JobStatus::Pending);
        // only the pending tasks survive: the singles are dropped and
        // both groups are narrowed to their pending task
        assert_eq!(
            rows,
            vec![
                JobRow::Group {
                    base_id: "100".to_string(),
                    task_indices: vec![2],
                    expanded: false,
                },
                JobRow::Group {
                    base_id: "200".to_string(),
                    task_indices: vec![5],
                    expanded: false,
                },
            ]
        );
        // the aggregate counts reflect only the matching tasks
        let tasks: Vec<&Job> = vec![&jobs[2]];
        assert_eq!(status_counts(&tasks), "1PD");
    }

    #[test]
    fn filter_drops_groups_without_matching_tasks() {
        let jobs = mixed_jobs();
        let rows = build_rows(&jobs, true, &HashSet::new());
        let rows = filter_rows(rows, &jobs, |job| job.status == JobStatus::Completed);
        // only the completed single job "7" matches anything
        assert_eq!(rows, vec![JobRow::Single { job_index: 3 }]);

        // no match at all: the result is empty
        let rows = build_rows(&jobs, true, &HashSet::new());
        let rows = filter_rows(rows, &jobs, |_| false);
        assert!(rows.is_empty());
    }

    #[test]
    fn filter_rebuilds_expanded_task_rows_with_tree_glyphs() {
        // three tasks of one group, the middle one filtered out
        let jobs = vec![
            job("100_1", JobStatus::Running),
            job("100_2", JobStatus::Pending),
            job("100_3", JobStatus::Running),
        ];
        let expanded: HashSet<String> = ["100".to_string()].into();
        let rows = build_rows(&jobs, true, &expanded);
        let rows = filter_rows(rows, &jobs, |job| job.status == JobStatus::Running);
        // the surviving last task carries the closing glyph flag
        assert_eq!(
            rows,
            vec![
                JobRow::Group {
                    base_id: "100".to_string(),
                    task_indices: vec![0, 2],
                    expanded: true,
                },
                JobRow::Task {
                    job_index: 0,
                    last: false,
                },
                JobRow::Task {
                    job_index: 2,
                    last: true,
                },
            ]
        );
    }

    #[test]
    fn status_counts_aggregates_in_fixed_order() {
        let jobs = [
            job("100_1", JobStatus::Pending),
            job("100_2", JobStatus::Running),
            job("100_3", JobStatus::Completed),
            job("100_4", JobStatus::Running),
            job("100_5", JobStatus::Failed),
        ];
        let tasks: Vec<&Job> = jobs.iter().collect();
        assert_eq!(status_counts(&tasks), "2R 1PD 1CD 1F");
    }

    #[test]
    fn status_counts_expands_pending_range_placeholders() {
        // the placeholder "100_[8-99]" stands for 92 pending tasks
        let jobs = [
            job("100_1", JobStatus::Running),
            job("100_[8-99]", JobStatus::Pending),
        ];
        let tasks: Vec<&Job> = jobs.iter().collect();
        assert_eq!(status_counts(&tasks), "1R 92PD");
    }
}