File Coverage

bin/lsjobs
Criterion Covered Total %
statement 58 530 10.9
branch 2 244 0.8
condition 1 109 0.9
subroutine 13 39 33.3
pod n/a
total 74 922 8.0


line stmt bran cond sub pod time code
1             #!/usr/bin/env perl
2             #ABSTRACT: List your jobs (or others), and delete them if you wish
3             #PODNAME: lsjobs
4              
5 1     1   6430 use v5.12;
  1         4  
6 1     1   7 use warnings;
  1         2  
  1         78  
7 1     1   889 use utf8;
  1         373  
  1         7  
8 1     1   1260 use Getopt::Long;
  1         21336  
  1         8  
9 1     1   904 use FindBin qw($RealBin);
  1         1772  
  1         137  
10 1     1   767 use Data::Dumper;
  1         10561  
  1         94  
11 1     1   577 use Term::ANSIColor qw(:constants);
  1         13753  
  1         1454  
12 1     1   11 use File::Basename;
  1         7  
  1         94  
13 1     1   1100 use Text::ASCIITable;
  1         10553  
  1         201  
14 1         72011 $Data::Dumper::Sortkeys = 1;
15              
16             # Enable UTF-8 output
17 1         6 binmode(STDOUT, ':utf8');
18 1         3 binmode(STDERR, ':utf8');
19              
20             # Box drawing characters for modern table rendering
21 1         12 my $BOX = {
22             tl => '┌', tr => '┐', bl => '└', br => '┘', # corners
23             h => '─', v => '│', # lines
24             t => '┬', b => '┴', l => '├', r => '┤', # T-junctions
25             c => '┼', # cross
26             };
27              
28 1 50       44 if (-e "$RealBin/../dist.ini") {
  0         0  
29 1 50       4 say STDERR "[dev mode] Using local lib" if ($ENV{"DEBUG"});
30 1     1   841 use lib "$RealBin/../lib";
  1         932  
  1         10  
31             }
32              
33 1     1   975 use NBI::Slurm;
  1         3  
  1         137  
34 1     1   6 use Cwd;
  1         1  
  1         62632  
35              
36              
37              
38              
39 1   50     20 my $current_slurm_jobid = $ENV{SLURM_JOBID} // -1;
40 1         3 my $unix_username = $ENV{USER};
41 1         2 my $user_home_dir = $ENV{HOME};
42              
43 1         1 my $opt_user = $unix_username;
44 1         2 my $opt_status = '.+';
45 1         2 my $opt_running_bool = 0;
46 1         1 my $opt_pending_bool = 0;
47 1         1 my $opt_delete_bool = 0;
48 1         1 my $opt_verbose_bool = 0;
49 1         3 my $opt_queue = '.+';
50 1         2 my $opt_name = '.+';
51 1         1 my $opt_tab = 0;
52 1         1 my $opt_summary = 0;
53 1         2 my @opt_hide = ();
54             GetOptions(
55             'u|user=s' => \$opt_user,
56             'n|name=s' => \$opt_name,
57             's|status=s'=> \$opt_status,
58             'r|running' => \$opt_running_bool,
59             'p|pending' => \$opt_pending_bool,
60             'd|delete' => \$opt_delete_bool,
61             't|tab' => \$opt_tab,
62             'summary' => \$opt_summary,
63             'hide=s' => \@opt_hide,
64             'verbose' => \$opt_verbose_bool,
65 1     1   1036 'version' => sub { say "lsjobs v", $NBI::Slurm::VERSION; exit },
  1         90  
66 0     0   0 'help' => sub { usage() },
67 1         12 );
68              
69 0 0       0 if (not NBI::Slurm::has_squeue()) {
70 0         0 say STDERR RED, "Error: ", RESET, "squeue not found in PATH. Are you in the cluster?";
71 0         0 exit 1;
72             }
73              
74 0         0 my $jobs = getjobs();
75              
76             # Check if we got any data
77 0 0 0     0 if (not defined $jobs or ref($jobs) ne 'HASH') {
78 0         0 say STDERR RED, "Error: ", RESET, "Failed to retrieve job information";
79 0         0 exit 1;
80             }
81 0         0 my @ids = ();
82 0         0 for my $positional (@ARGV) {
83 0 0       0 if ($positional =~ /^(\d+)$/) {
84 0         0 push(@ids, $1);
85             } else {
86 0 0       0 if ($opt_name eq '.+') {
87 0         0 $opt_name = $positional;
88             } else {
89 0         0 say STDERR "Error: unknown positional argument: $positional";
90 0         0 usage();
91             }
92             }
93             }
94              
95 0 0 0     0 if ($opt_user eq 'ALL' or $opt_user eq 'all') {
96 0         0 $opt_user = '.+';
97             }
98 0 0       0 if ($opt_verbose_bool) {
99 0         0 say STDERR "User: $opt_user";
100 0         0 say STDERR "Jobs: ", scalar(keys %{$jobs});
  0         0  
101             }
102              
103 0 0       0 if ($opt_summary) {
104 0         0 print_state_summary_table($jobs, \@ids, $opt_user);
105 0         0 exit 0;
106             }
107              
108 0         0 my $selected_jobs = {};
109 0         0 my $selected_arrays = [['JobID', 'User', 'Queue', 'Name', 'State', 'Time', 'TotalTime', 'NodeList', 'CPUS', 'Memory', 'Reason']];
110              
111 0 0       0 if ($opt_tab) {
112             # Add "#" prefix for TSV mode
113 0         0 $selected_arrays->[0]->[0] = "#" . $selected_arrays->[0]->[0];
114             }
115              
116 0         0 for my $job (sort keys %{$jobs}) {
  0         0  
117             # Check user (full match)
118 0 0       0 if ($jobs->{$job}->{USER} !~ /^$opt_user$/) {
119 0         0 next;
120             }
121             # Check queue (partial match ok)
122 0 0       0 if ($jobs->{$job}->{PARTITION} !~ /$opt_queue/) {
123 0         0 next;
124             }
125              
126             # Check name
127 0 0       0 if ($jobs->{$job}->{NAME} !~ /$opt_name/) {
128 0         0 next;
129             }
130             # Check status (general pattern match)
131 0 0       0 if ($jobs->{$job}->{STATE} !~ /$opt_status/) {
132 0         0 next;
133             }
134             # Check specific status flags (these override general status pattern)
135 0 0 0     0 if ($opt_pending_bool and $jobs->{$job}->{STATE} ne 'PENDING') {
136 0         0 next;
137             }
138 0 0 0     0 if ($opt_running_bool and $jobs->{$job}->{STATE} ne 'RUNNING') {
139 0         0 next;
140             }
141 0 0 0     0 if (scalar @ids > 0 and not grep {$_ eq $job} @ids) {
  0         0  
142 0         0 next;
143             }
144              
145             # Format job ID for display (handle array jobs)
146 0         0 my $display_jobid = $jobs->{$job}->{JOBID};
147 0 0 0     0 if (defined $display_jobid && $display_jobid =~ /_/) {
148             # Array job format: convert "12345_1" to "12345#"
149 0         0 $display_jobid =~ s/_.*/#/;
150             }
151              
152             my $array = [$display_jobid // 'N/A',
153             $jobs->{$job}->{USER} // 'N/A',
154             $jobs->{$job}->{PARTITION} // 'N/A',
155             $jobs->{$job}->{NAME} // 'N/A',
156             $opt_tab ? ($jobs->{$job}->{STATE} // 'N/A') : state_string($jobs->{$job}->{STATE} // 'UNKNOWN'),
157             $jobs->{$job}->{TIME} // '0:00',
158             $jobs->{$job}->{TIME_LIMIT} // 'N/A',
159             $jobs->{$job}->{NODELIST} // 'N/A',
160             $jobs->{$job}->{"CPUS"} // 'N/A',
161             $jobs->{$job}->{"MIN_MEMORY"} // 'N/A',
162 0 0 0     0 $opt_tab ? ($jobs->{$job}->{"REASON"} // 'N/A') : reason_string($jobs->{$job}->{"REASON"} // 'None')
    0 0        
      0        
      0        
      0        
      0        
      0        
      0        
      0        
      0        
      0        
      0        
      0        
163             ];
164 0         0 push(@{$selected_arrays}, $array);
  0         0  
165              
166              
167             }
168             # Apply column hiding if requested
169 0 0 0     0 if (@opt_hide && !$opt_tab) {
170 0         0 $selected_arrays = hide_columns($selected_arrays, \@opt_hide);
171             }
172              
173             # Print summary header (not in tab mode)
174 0 0       0 if (!$opt_tab) {
175 0         0 print_summary($jobs, $selected_arrays);
176             }
177              
178 0 0       0 if ($opt_tab) {
179 0         0 for my $array (@{$selected_arrays}) {
  0         0  
180 0         0 say join("\t", @{$array});
  0         0  
181             }
182             } else {
183             # Render modern table with Unicode box drawing
184 0         0 render_table_modern(@{$selected_arrays});
  0         0  
185 0         0 print RESET, "\n";
186             }
187              
188             ## Print single job
189 0 0 0     0 if ($opt_verbose_bool and scalar @{$selected_arrays} == 3) {
  0         0  
190 0         0 my $job = extractJobByID($jobs, $selected_arrays->[2]->[0]);
191 0         0 for my $key (sort keys %{$job}) {
  0         0  
192             # Filter useless values
193 0 0 0     0 if ($job->{$key} =~ /^$/ or $job->{$key} =~ /^(\(null\)|\*)$/) {
194 0         0 next;
195             }
196 0 0       0 if ($key =~/(S_C_T|USER|ACCOUNT)/) {
197 0         0 next;
198             }
199 0         0 say YELLOW, sprintf("%-20s", $key), RESET, $job->{$key};
200             }
201             }
202              
203 0         0 my @selected_ids = joblist_to_ids(@{$selected_arrays});
  0         0  
204              
205              
206 0 0 0     0 if ($opt_delete_bool and (scalar @selected_ids)) {
    0          
207 0         0 say RED "\nDELETE JOBS:", RESET;
208 0 0       0 if (prompt("Delete " . scalar(@selected_ids) . " jobs?", 'n') =~ /^(y|Y|yes|YES)$/) {
209 0         0 my $command = "scancel " . join(" ", @selected_ids);
210 0         0 my $result = system($command);
211 0 0       0 if ($result == -1) {
    0          
    0          
212 0         0 say RED, "ERROR", RESET, ": Failed to execute scancel: $!";
213             } elsif ($result & 127) {
214 0         0 say RED, "ERROR", RESET, ": scancel died with signal ", ($result & 127);
215             } elsif (my $exit_code = $result >> 8) {
216 0         0 say RED, "ERROR", RESET, ": scancel exited with status $exit_code";
217             } else {
218 0         0 say GREEN, "Success", RESET, ": Deleted ", scalar(@selected_ids), " job(s)";
219             }
220             } else {
221 0         0 say "Deletion cancelled.";
222             }
223              
224             } elsif ($opt_delete_bool) {
225 0         0 say STDERR YELLOW, "Warning: ", RESET, "No jobs selected for deletion";
226             }
227              
228             sub state_string {
229 0   0 0     my $s = shift // 'UNKNOWN';
230 0           my ($icon, $color, $text);
231              
232 0 0         if ($s =~ /^R/i) {
    0          
    0          
    0          
    0          
233 0           $icon = '▶';
234 0           $color = GREEN . BOLD;
235 0           $text = 'RUN';
236             } elsif ($s =~ /^P/i) {
237 0           $icon = '◷';
238 0           $color = YELLOW . BOLD;
239 0           $text = 'PND';
240             } elsif ($s =~ /^C/i) {
241 0           $icon = '✓';
242 0           $color = CYAN . BOLD;
243 0           $text = 'CMP';
244             } elsif ($s =~ /^F/i) {
245 0           $icon = '✗';
246 0           $color = RED . BOLD;
247 0           $text = 'FLD';
248             } elsif ($s =~ /^S/i) {
249 0           $icon = '■';
250 0           $color = RED . BOLD;
251 0           $text = 'STP';
252             } else {
253 0           $icon = '?';
254 0           $color = WHITE;
255 0           $text = substr($s, 0, 3);
256             }
257              
258 0           return $color . $icon . ' ' . $text . RESET;
259             }
260             sub reason_string {
261 0   0 0     my $s = shift // 'None';
262 0           my ($icon, $color);
263              
264 0 0         if ($s =~ /^None/i) {
    0          
    0          
    0          
    0          
    0          
265 0           $icon = '✓';
266 0           $color = CYAN;
267 0           return $color . $icon . ' ' . $s . RESET;
268             } elsif ($s =~ /^Priority/i) {
269 0           $icon = '↑'; # was ⏳ — 2-wide emoji on modern terminals
270 0           $color = YELLOW;
271             } elsif ($s =~ /^Resources/i) {
272 0           $icon = '↯'; # was ⚡ — 2-wide emoji on modern terminals
273 0           $color = YELLOW;
274             } elsif ($s =~ /^Dependency/i) {
275 0           $icon = '→'; # was ⛓ — 2-wide emoji on modern terminals
276 0           $color = BLUE;
277             } elsif ($s =~ /^(Bad|Error|Invalid)/i) {
278 0           $icon = '✗';
279 0           $color = RED . BOLD;
280             } elsif ($s =~ /^QOS/i) {
281 0           $icon = '!'; # was ⚠ — ambiguous-width on modern terminals
282 0           $color = YELLOW;
283             } else {
284 0           $icon = '•';
285 0           $color = WHITE;
286             }
287              
288 0           return $color . $icon . ' ' . $s . RESET;
289             }
290             sub joblist_to_ids {
291             # Receive a list of lists (all same length) and returns a list of jobids
292 0     0     my @rows = @_;
293 0           my @ids = ();
294             # remove first two rows
295            
296 0           for my $row (@rows) {
297             # Skip non numeric values
298            
299 0 0         next if ($row->[0] !~ /^\d+$/);
300              
301 0           push @ids, $row->[0];
302            
303            
304             }
305              
306 0           return @ids;
307            
308             }
309             sub short_job {
310             # Print a line of minimal information about a job
311 0     0     my $line_width = get_terminal_width();
312 0           my $job = shift;
313 0           my $jobid = $job->{JOBID};
314 0           my $name = $job->{NAME};
315 0           my $state = $job->{STATE};
316 0           my $user = $job->{USER};
317 0           my $queue = $job->{PARTITION};
318 0           my $time = $job->{TIME};
319             # Return a string sorther than $line_width
320 0           my $line = sprintf("%-10s %-10s %-10s %-10s %-10s %-10s", $jobid, $name, $state, $user, $queue, $time);
321 0           return $line;
322             }
323              
324             sub print_summary {
325 0     0     my ($jobs, $selected_arrays) = @_;
326 0           my %stats = (running => 0, pending => 0, completed => 0, failed => 0, other => 0);
327              
328             # Count job states
329 0           for my $job (values %$jobs) {
330 0   0       my $state = $job->{STATE} // '';
331 0 0         if ($state =~ /^R/i) {
    0          
    0          
    0          
332 0           $stats{running}++;
333             } elsif ($state =~ /^P/i) {
334 0           $stats{pending}++;
335             } elsif ($state =~ /^C/i) {
336 0           $stats{completed}++;
337             } elsif ($state =~ /^F/i) {
338 0           $stats{failed}++;
339             } else {
340 0           $stats{other}++;
341             }
342             }
343              
344 0           my $total = scalar(keys %$jobs);
345 0           my $showing = scalar(@$selected_arrays) - 1; # -1 for header row
346              
347 0           say '';
348 0           say BOLD . CYAN . '╔════════════════════════════════════════════════════════════════╗' . RESET;
349 0           say BOLD . CYAN . '║' . RESET . ' ' . BOLD . 'SLURM Job Queue Summary' . RESET . ' ' x 39 . BOLD . CYAN . '║' . RESET;
350 0           say BOLD . CYAN . '╚════════════════════════════════════════════════════════════════╝' . RESET;
351 0           say '';
352              
353             # Stats line
354 0           my @stat_parts;
355             push @stat_parts, sprintf('%s %-9s %s%3d%s',
356 0 0         GREEN . BOLD . '▶' . RESET, 'Running:', GREEN . BOLD, $stats{running}, RESET) if $stats{running};
357             push @stat_parts, sprintf('%s %-9s %s%3d%s',
358 0 0         YELLOW . BOLD . '◷' . RESET, 'Pending:', YELLOW . BOLD, $stats{pending}, RESET) if $stats{pending};
359             push @stat_parts, sprintf('%s %-9s %s%3d%s',
360 0 0         CYAN . '✓' . RESET, 'Complete:', CYAN, $stats{completed}, RESET) if $stats{completed};
361             push @stat_parts, sprintf('%s %-9s %s%3d%s',
362 0 0         RED . BOLD . '✗' . RESET, 'Failed:', RED . BOLD, $stats{failed}, RESET) if $stats{failed};
363             push @stat_parts, sprintf('%s %-9s %s%3d%s',
364 0 0         WHITE . '•' . RESET, 'Other:', WHITE, $stats{other}, RESET) if $stats{other};
365              
366 0 0         if (@stat_parts) {
367 0           say ' ' . join(' ', @stat_parts);
368             }
369              
370 0           say '';
371 0           say sprintf(' %s Showing: %s%d%s of %s%d%s total jobs',
372             CYAN . BOLD . '→' . RESET,
373             BOLD . CYAN, $showing, RESET,
374             BOLD, $total, RESET
375             );
376 0           say '';
377             }
378              
379             sub summary_bucket_for_state {
380 0   0 0     my $state = shift // '';
381              
382 0 0         return 'running' if $state =~ /^RUNNING$/i;
383 0 0         return 'pending' if $state =~ /^PENDING$/i;
384 0 0         return 'completed' if $state =~ /^COMPLETED$/i;
385 0 0         return 'errors' if $state =~ /^(?:FAILED|CANCELLED|TIMEOUT|NODE_FAIL|OUT_OF_MEMORY|BOOT_FAIL|DEADLINE|PREEMPTED)$/i;
386 0           return 'other';
387             }
388              
389             sub summary_color {
390 0     0     my $bucket = shift;
391              
392 0 0         return GREEN . BOLD if $bucket eq 'running';
393 0 0         return YELLOW . BOLD if $bucket eq 'pending';
394 0 0         return BLUE if $bucket eq 'completed';
395 0 0         return RED if $bucket eq 'errors';
396 0           return BLACK;
397             }
398              
399             sub summary_user_label {
400 0     0     my $user = shift;
401 0 0 0       return 'All users' if !defined $user || $user eq '.+';
402 0           return $user;
403             }
404              
405             sub job_matches_summary_scope {
406 0     0     my ($jobid, $job, $ids_ref) = @_;
407              
408 0 0         return 0 if $job->{PARTITION} !~ /$opt_queue/;
409 0 0         return 0 if $job->{NAME} !~ /$opt_name/;
410 0 0 0       return 0 if @$ids_ref && !grep { $_ eq $jobid } @$ids_ref;
  0            
411 0           return 1;
412             }
413              
414             sub print_state_summary_table {
415 0     0     my ($jobs, $ids_ref, $user_pattern) = @_;
416              
417 0           my @order = qw(running pending completed errors other);
418 0           my %labels = (
419             running => 'Running jobs',
420             pending => 'Pending jobs',
421             completed => 'Completed',
422             errors => 'Errors',
423             other => 'Other',
424             );
425 0           my %counts = map { $_ => { user => 0, total => 0 } } @order;
  0            
426              
427 0           for my $jobid (sort keys %$jobs) {
428 0           my $job = $jobs->{$jobid};
429 0 0         next unless job_matches_summary_scope($jobid, $job, $ids_ref);
430              
431 0           my $bucket = summary_bucket_for_state($job->{STATE});
432 0           $counts{$bucket}{total}++;
433 0 0 0       if (($job->{USER} // '') =~ /^$user_pattern$/) {
434 0           $counts{$bucket}{user}++;
435             }
436             }
437              
438 0           my $user_label = summary_user_label($user_pattern);
439 0           my $label_w = length('Pending jobs');
440 0           my $user_w = length($user_label);
441 0           my $total_w = length('Total');
442 0           my $user_total = 0;
443 0           my $grand_total = 0;
444              
445 0           for my $bucket (@order) {
446 0           my $user_count = $counts{$bucket}{user};
447 0           my $total_count = $counts{$bucket}{total};
448 0           $user_total += $user_count;
449 0           $grand_total += $total_count;
450 0 0         $user_w = length($user_count) if length($user_count) > $user_w;
451 0 0         $total_w = length($total_count) if length($total_count) > $total_w;
452             }
453 0 0         $label_w = length('Total') if length('Total') > $label_w;
454 0 0         $user_w = length($user_total) if length($user_total) > $user_w;
455 0 0         $total_w = length($grand_total) if length($grand_total) > $total_w;
456              
457 0           say '';
458 0           say sprintf(" %-*s | %*s | %*s", $label_w, '', $user_w, $user_label, $total_w, 'Total');
459 0           say sprintf(" %s-+-%s-+-%s", '-' x $label_w, '-' x $user_w, '-' x $total_w);
460              
461 0           for my $bucket (@order) {
462             my $line = sprintf(
463             " %-*s | %*d | %*d",
464             $label_w, $labels{$bucket},
465             $user_w, $counts{$bucket}{user},
466             $total_w, $counts{$bucket}{total},
467 0           );
468 0           say summary_color($bucket) . $line . RESET;
469             }
470 0           say sprintf(" %s-+-%s-+-%s", '-' x $label_w, '-' x $user_w, '-' x $total_w);
471 0           say BOLD . sprintf(
472             " %-*s | %*d | %*d",
473             $label_w, 'Total',
474             $user_w, $user_total,
475             $total_w, $grand_total,
476             ) . RESET;
477              
478 0           say '';
479             }
480              
481             sub hide_columns {
482 0     0     my ($arrays_ref, $hide_patterns) = @_;
483 0 0         return $arrays_ref unless @$hide_patterns;
484              
485 0           my @arrays = @$arrays_ref;
486 0 0         return \@arrays unless @arrays;
487              
488 0           my $header = $arrays[0];
489 0           my @keep_indices;
490              
491             # Determine which columns to keep
492 0           for my $i (0..$#{$header}) {
  0            
493 0           my $col_name = $header->[$i];
494 0           my $should_hide = 0;
495              
496 0           for my $pattern (@$hide_patterns) {
497             # Case insensitive substring match (minimum 3 chars)
498 0 0 0       if (length($pattern) >= 3 && $col_name =~ /\Q$pattern\E/i) {
499 0           $should_hide = 1;
500 0           last;
501             }
502             }
503              
504 0 0         push @keep_indices, $i unless $should_hide;
505             }
506              
507             # If all columns would be hidden, keep them all
508 0 0         return \@arrays unless @keep_indices;
509              
510             # Filter each row to keep only non-hidden columns
511 0           my @filtered_arrays;
512 0           for my $row (@arrays) {
513 0           my @filtered_row = map { $row->[$_] } @keep_indices;
  0            
514 0           push @filtered_arrays, \@filtered_row;
515             }
516              
517 0           return \@filtered_arrays;
518             }
519              
520             sub ansi_first_char {
521             # Return the first visible glyph of an ANSI-coloured string,
522             # preserving any leading colour escape sequences.
523 0     0     my $s = shift;
524 0           my $prefix = '';
525 0           while ($s =~ s/^(\e\[[0-9;]*m)//) {
526 0           $prefix .= $1;
527             }
528 0           return $prefix . substr($s, 0, 1) . RESET;
529             }
530              
531             sub fit_columns_to_terminal {
532             # Given natural column widths and the header row, return:
533             # ($fitted_widths_ref, $hidden_hashref, $icon_only_hashref)
534             # Strategy (in order):
535             # 1. Shrink flexible cols (name, nodelist, reason) down to their minimums
536             # 2. Hide NodeList entirely (optional column, first to go)
537             # 3. Hide Reason entirely (optional column, second to go)
538             # 4. Collapse State to icon-only glyph
539             # 5. Last-resort: truncate whatever is still widest
540 0     0     my ($widths, $header) = @_;
541 0           my $terminal_width = get_terminal_width();
542              
543 0           my @fitted = @$widths;
544 0           my %hidden = ();
545 0           my %icon_only = ();
546              
547             # Map normalised column name -> index
548 0           my %col_idx;
549 0           for my $i (0..$#$header) {
550 0           my $name = lc($header->[$i]);
551 0           $name =~ s/[^a-z0-9]//g;
552 0           $col_idx{$name} = $i;
553             }
554              
555             # Rendered width: left border + each visible col (content + 2 spaces + right border)
556             my $calc_width = sub {
557 0     0     my $w = 1;
558 0           for my $i (0..$#fitted) {
559 0 0         next if $hidden{$i};
560 0           $w += $fitted[$i] + 3;
561             }
562 0           return $w;
563 0           };
564              
565 0 0         return (\@fitted, \%hidden, \%icon_only) if $calc_width->() <= $terminal_width;
566              
567             # Step 1: Shrink Name/NodeList/Reason down to minimums
568 0           my %min_widths = (name => 10, nodelist => 8, reason => 8);
569 0           my $progress = 1;
570 0   0       while ($calc_width->() > $terminal_width && $progress) {
571 0           $progress = 0;
572 0           my ($best, $best_w) = (undef, 0);
573 0           for my $name (qw(name nodelist reason)) {
574 0           my $i = $col_idx{$name};
575 0 0         next unless defined $i;
576 0 0         next if $hidden{$i};
577 0           my $min = $min_widths{$name};
578 0 0 0       if ($fitted[$i] > $min && $fitted[$i] > $best_w) {
579 0           $best = $name;
580 0           $best_w = $fitted[$i];
581             }
582             }
583 0 0         if (defined $best) {
584 0           my $i = $col_idx{$best};
585 0           my $min = $min_widths{$best};
586 0           my $excess = $calc_width->() - $terminal_width;
587 0           my $cut = $fitted[$i] - $min;
588 0 0         $cut = $excess if $excess < $cut;
589 0           $fitted[$i] -= $cut;
590 0 0         $progress = 1 if $cut > 0;
591             }
592             }
593              
594             # Step 2: Hide NodeList (optional, first to go)
595 0 0         if ($calc_width->() > $terminal_width) {
596 0           my $i = $col_idx{nodelist};
597 0 0         $hidden{$i} = 1 if defined $i;
598             }
599              
600             # Step 3: Hide Reason (optional, second to go)
601 0 0         if ($calc_width->() > $terminal_width) {
602 0           my $i = $col_idx{reason};
603 0 0         $hidden{$i} = 1 if defined $i;
604             }
605              
606             # Step 4: Collapse State to a single icon glyph
607 0 0         if ($calc_width->() > $terminal_width) {
608 0           my $i = $col_idx{state};
609 0 0 0       if (defined $i && !$hidden{$i}) {
610 0           $icon_only{$i} = 1;
611 0           $fitted[$i] = 1;
612             }
613             }
614              
615             # Step 5: Last-resort generic truncation of whatever is still widest
616 0           $progress = 1;
617 0   0       while ($calc_width->() > $terminal_width && $progress) {
618 0           $progress = 0;
619 0           my ($best_i, $best_w) = (-1, 0);
620 0           for my $i (0..$#fitted) {
621 0 0 0       next if $hidden{$i} || $icon_only{$i};
622 0 0 0       if ($fitted[$i] > 4 && $fitted[$i] > $best_w) {
623 0           $best_i = $i;
624 0           $best_w = $fitted[$i];
625             }
626             }
627 0 0         if ($best_i >= 0) {
628 0           my $excess = $calc_width->() - $terminal_width;
629 0           my $cut = $fitted[$best_i] - 4;
630 0 0         $cut = $excess if $excess < $cut;
631 0           $fitted[$best_i] -= $cut;
632 0 0         $progress = 1 if $cut > 0;
633             }
634             }
635              
636 0           return (\@fitted, \%hidden, \%icon_only);
637             }
638              
639             sub render_table_modern {
640             # Receive a list of lists (all same length) and print a modern table with Unicode borders
641 0     0     my @rows = @_;
642 0 0         return unless @rows;
643              
644 0           my $n_cols = scalar(@{$rows[0]});
  0            
645              
646             # Calculate maximum widths for each column
647 0           my @max_widths = ();
648 0           for my $col (0..$n_cols-1) {
649 0           my $max_width = 0;
650 0           for my $row (@rows) {
651 0           my $width = ascii_len($row->[$col]);
652 0 0         $max_width = $width if ($width > $max_width);
653             }
654 0           push(@max_widths, $max_width);
655             }
656              
657             # Fit columns to terminal: truncate, hide optional cols, collapse State to icon
658 0           my ($fitted_widths, $hidden_cols, $icon_only_cols) =
659             fit_columns_to_terminal(\@max_widths, $rows[0]);
660              
661             # Print top border
662 0           print_border_line('top', $fitted_widths, $hidden_cols);
663              
664             # Print header row (first row)
665 0 0         if (@rows > 0) {
666 0           print_table_row($rows[0], $fitted_widths, 'header', $hidden_cols, $icon_only_cols);
667 0           print_border_line('middle', $fitted_widths, $hidden_cols);
668             }
669              
670             # Print data rows
671 0           for my $i (1..$#rows) {
672 0           print_table_row($rows[$i], $fitted_widths, 'data', $hidden_cols, $icon_only_cols);
673             }
674              
675             # Print bottom border
676 0           print_border_line('bottom', $fitted_widths, $hidden_cols);
677             }
678              
679             sub print_border_line {
680 0     0     my ($type, $widths, $hidden) = @_;
681 0   0       $hidden //= {};
682 0           my ($left, $mid, $right, $fill);
683              
684 0 0         if ($type eq 'top') {
    0          
685 0           ($left, $mid, $right, $fill) = ($BOX->{tl}, $BOX->{t}, $BOX->{tr}, $BOX->{h});
686             } elsif ($type eq 'middle') {
687 0           ($left, $mid, $right, $fill) = ($BOX->{l}, $BOX->{c}, $BOX->{r}, $BOX->{h});
688             } else { # bottom
689 0           ($left, $mid, $right, $fill) = ($BOX->{bl}, $BOX->{b}, $BOX->{br}, $BOX->{h});
690             }
691              
692 0           my @visible = grep { !$hidden->{$_} } 0..$#{$widths};
  0            
  0            
693 0           print CYAN . $left . RESET;
694 0           for my $j (0..$#visible) {
695 0           my $i = $visible[$j];
696 0           print CYAN . ($fill x ($widths->[$i] + 2)) . RESET;
697 0 0         print CYAN . ($j == $#visible ? $right : $mid) . RESET;
698             }
699 0           say '';
700             }
701              
702             sub print_table_row {
703 0     0     my ($row, $widths, $style, $hidden, $icon_only) = @_;
704 0   0       $hidden //= {};
705 0   0       $icon_only //= {};
706              
707 0           print CYAN . $BOX->{v} . RESET;
708 0           for my $i (0..$#{$row}) {
  0            
709 0 0         next if $hidden->{$i};
710              
711 0           my $cell = $row->[$i];
712 0           my $width = $widths->[$i];
713              
714             # Icon-only mode: show just the leading glyph (used for State when space is tight)
715 0 0         if ($icon_only->{$i}) {
716 0           my $icon = ansi_first_char($cell);
717 0           print " $icon " . RESET;
718 0           print CYAN . $BOX->{v} . RESET;
719 0           next;
720             }
721              
722 0           my $stripped = $cell;
723 0           $stripped =~ s/\e\[[0-9;]*m//g; # Strip ANSI codes
724              
725 0 0         my $prefix = ($style eq 'header') ? BOLD . WHITE : '';
726              
727 0 0         if (length($stripped) > $width) {
728             # Truncate and mark with a coloured '>' ellipsis
729 0           my $visible = substr($stripped, 0, $width - 1);
730 0           print " $prefix$visible" . YELLOW . BOLD . '>' . RESET . ' ';
731             } else {
732             # Normal padded cell — preserve ANSI codes in the original value
733 0           my $tmpline = sprintf(" %s%-*s ", $prefix, $width, $stripped);
734 0           my $index = index($tmpline, $stripped);
735 0 0         if ($index >= 0) {
736 0           substr($tmpline, $index, length($stripped), $cell);
737             }
738 0           print $tmpline . RESET;
739             }
740 0           print CYAN . $BOX->{v} . RESET;
741             }
742 0           say '';
743             }
744              
745             sub render_table {
746             # Receive a list of lists (all same length) and print a table not larger than $line_width
747            
748             # @_ is an array of array references
749 0     0     my @rows = @_;
750 0           my $n_cols = scalar(@{$rows[0]});
  0            
751 0           my $line_width = get_terminal_width() - $n_cols - 1;
752             # For each column, evaluate the maximum string contained in that column
753 0           my @max_widths = ();
754 0           for my $col (0..$n_cols-1) {
755 0           my $max_width = 0;
756 0           for my $row (@rows) {
757 0           my $width = ascii_len($row->[$col]);
758 0 0         $max_width = $width if ($width > $max_width);
759             }
760 0           push(@max_widths, $max_width);
761             }
762             # Now print the table
763 0           for my $row (@rows) {
764 0           my $line = WHITE . ON_BLACK;
765 0           for my $col (0..$n_cols-1) {
766 0           my $width = $max_widths[$col];
767 0           my $cell = $row->[$col];
768 0           my $stripped = $cell;
769 0           $stripped =~ s/\e\[[0-9;]*m//g;
770 0           my $tmpline = sprintf("|%-${width}s ", $stripped);
771             # In tmpline replace $stripped with $cell, without using regex
772 0           my $index = index($tmpline, $stripped);
773 0           substr($tmpline, $index, length($stripped), $cell);
774 0           $line .= $tmpline;
775             }
776 0           say $line, "|";
777             }
778 0           print RESET;
779             }
780              
781             sub ascii_len {
782 0     0     my $string = shift;
783             # Return legnth excluding ANSI escape sequences
784 0           $string =~ s/\e\[[0-9;]*m//g;
785 0           return length($string);
786             }
787              
788             sub extractJobByID {
789 0     0     my ($jobs, $id) = @_;
790 0           my $job = {};
791 0           for my $jobid (keys %{$jobs}) {
  0            
792 0 0         if ($jobid eq $id) {
793 0           $job = $jobs->{$jobid};
794 0           last;
795             }
796             }
797 0           return $job;
798             }
799             sub getjobs {
800             # Create an anonymous hash, and return it
801 0     0     my $jobs = {};
802             # Use ASCII record separator (chr 30) as delimiter — safe against job names
803             # that contain '|' (e.g. Nextflow workflow names).
804 0           my $sep = chr(30);
805 0           my $fmt = join($sep, qw(%i %u %P %j %T %M %l %N %C %m %r));
806 0           $fmt =~ s/ //g; # remove spaces between format codes
807 0           my $cmd = "squeue --format='$fmt'";
808 0           my @output = `$cmd 2>&1`;
809              
810             # Check if command failed
811 0 0         if ($? != 0) {
812 0           say STDERR RED, "Error: ", RESET, "Failed to execute squeue command";
813 0 0         say STDERR "Output: ", join("\n", @output) if @output;
814 0           return $jobs;
815             }
816              
817 0           my $c = 0;
818 0           my @header = ();
819 0           for my $line (@output) {
820 0           chomp $line;
821 0 0         next if $line =~ /^\s*$/; # Skip empty lines
822              
823 0           my @fields = split(/\x1e/, $line);
824 0           $c++;
825 0 0         if ($c == 1 ) {
826             # Field names
827 0           for my $field (@fields) {
828 0           push(@header, stripchars($field));
829             }
830             } else {
831             # Job info
832 0           my $job = {};
833 0 0         if (scalar(@fields) != scalar(@header)) {
834 0           say STDERR YELLOW, "Warning: ", RESET, "Skipping malformed line (field count mismatch)";
835 0 0         if ($ENV{DEBUG}) {
836 0           say STDERR " Expected: ", scalar(@header), " fields";
837 0           say STDERR " Got: ", scalar(@fields), " fields";
838 0           say STDERR " Line: $line";
839             }
840 0           next; # Skip this line instead of exiting
841             }
842 0           for my $i (0..$#header) {
843 0 0         $job->{"$header[$i]"} = $fields[$i] if (not defined $job->{"$header[$i]"});
844             }
845              
846             # Ensure we have a valid JOBID before adding
847 0 0 0       if (defined $job->{JOBID} && $job->{JOBID} ne '') {
848 0           $jobs->{$job->{JOBID}} = $job;
849             } else {
850 0 0         say STDERR YELLOW, "Warning: ", RESET, "Skipping job with missing JOBID" if $ENV{DEBUG};
851             }
852              
853             }
854              
855             }
856              
857 0           return $jobs;
858             }
859              
860              
861             sub get_terminal_width {
862 0     0     my $terminal_width = `tput cols 2>/dev/null`;
863 0 0         chomp($terminal_width) if defined $terminal_width;
864              
865             # Validate that we got a positive integer
866 0 0 0       if (defined $terminal_width && $terminal_width =~ /^\d+$/ && $terminal_width > 20) {
      0        
867 0           return $terminal_width;
868             }
869              
870             # Fallback to default width
871 0           return 80;
872             }
873              
874             sub stripchars {
875 0     0     my $string = shift;
876             # replace non alphanumeric characters with _
877 0           $string =~ s/[^A-Za-z0-9]/_/g;
878 0           return $string;
879             }
880              
881             sub prompt {
882 0     0     my ($message, $default) = @_;
883 0           my $prompt = "$message [$default]: ";
884 0           print $prompt;
885 0           my $answer = ;
886 0           chomp $answer;
887 0 0         $answer = $default if ($answer eq '');
888 0           return $answer;
889             }
890              
891             sub usage {
892 0     0     say <
893             Usage: lsjobs [options] [jobid ... | pattern ]
894             ----------------------------------------------
895             Options:
896             -u, --user Show only jobs from this user [default: $unix_username]
897             Type 'all' to show all users
898              
899             -n, --name Show only jobs with this name (regex) [default: .+]
900              
901             -s, --status Show only jobs matching this status (regex) [default: .+]
902             -r, --running Show only running jobs
903             -p, --pending Show only pending jobs
904             -t, --tab Output in simple TSV format (pipe to vd for interactive table)
905             --summary Print per-state counts for the selected user vs total jobs
906              
907             --hide Hide column from display (case insensitive substring match,
908             minimum 3 characters). Can be specified multiple times.
909             Examples: --hide time --hide memory --hide nodelist
910              
911             -d, --delete Delete the selected jobs (with confirmation prompt)
912             --verbose Show verbose output
913             --version Show version and exit
914             --help Show this help message
915             END
916 0           exit;
917              
918             }
919              
920             __END__