diff --git a/web/pages/mxq/mxq.in b/web/pages/mxq/mxq.in
index c9bd872..176cb06 100755
--- a/web/pages/mxq/mxq.in
+++ b/web/pages/mxq/mxq.in
@@ -33,6 +33,16 @@ td.number { text-align: right; }
tr.bg0 { background: #F0F0F0; }
tr.bg1 { background: #E4E4E4; }
+form.filter { margin: 8px 0; padding: 8px 10px; background: #f4f4f4; border: 1px solid #bbb; border-radius: 5px; display: inline-block; }
+form.filter input[type="text"], form.filter input[type="number"] { padding: 2px 4px; }
+
+table.groups th a { color: #0645ad; text-decoration: none; }
+table.groups th a:hover { text-decoration: underline; }
+
+table.server.sortable thead th { cursor: pointer; user-select: none; white-space: nowrap; }
+table.server.sortable thead th:hover { background: #d8d8d8; }
+table.server span.arrow { color: #444; }
+
EOF
}
@@ -635,9 +645,60 @@ EOF
return $out;
}
+# Build a whitelisted "ORDER BY col dir" clause. %$allowed maps a request key
+# to a trusted SQL expression, so an attacker-supplied sort key can never reach
+# the query verbatim.
+sub order_clause {
+ my ($sort,$dir,$allowed,$default_col,$default_dir)=@_;
+ my $col = ($sort && exists $allowed->{$sort}) ? $allowed->{$sort} : $allowed->{$default_col};
+ my $d = (defined $dir && lc($dir) eq 'asc') ? 'ASC'
+ : (defined $dir && lc($dir) eq 'desc') ? 'DESC'
+ : $default_dir;
+ return "ORDER BY $col $d";
+}
+
+# Render a
of where whitelisted columns link back to this page with
+# updated sort parameters (server-side sorting, no JavaScript required).
+sub sortable_header {
+ my ($keys,$labels,$allowed,$cur_sort,$cur_dir,$sort_param,$dir_param,$base)=@_;
+ my @th;
+ for my $i (0..$#$keys) {
+ my $key=$keys->[$i];
+ my $label=$labels->[$i];
+ if (exists $allowed->{$key}) {
+ my $is_cur = defined $cur_sort && $cur_sort eq $key;
+ my $newdir = ($is_cur && lc($cur_dir//'') eq 'asc') ? 'desc' : 'asc';
+ my $arrow = $is_cur ? (lc($cur_dir//'') eq 'asc' ? ' ▲' : ' ▼') : '';
+ my $href = my_url('groups', { %$base, $sort_param=>$key, $dir_param=>$newdir });
+ push @th, $q->a({href=>$href}, $label).$arrow;
+ } else {
+ push @th, $label;
+ }
+ }
+ return $q->Tr($q->th(\@th));
+}
+
+sub groups_filter_form {
+ my ($user_name,$group_name,$days)=@_;
+ my $action=selfurl('/groups');
+ my $u=escapeHTML(defined $user_name ? $user_name : '');
+ my $g=escapeHTML(defined $group_name ? $group_name : '');
+ my $d=escapeHTML($days);
+ return <<"__EOF__";
+
+__EOF__
+}
+
sub group_table {
- my ($sql_clause,$sql_binds,$days)=@_;
+ my ($sql_clause,$sql_binds,$days,$opt)=@_;
+ $opt ||= {};
+ my $base = $opt->{base} || {};
$sql_clause||='true';
@@ -678,11 +739,23 @@ sub group_table {
unknown
);
- my $sth=$dbh->prepare('SELECT '.join(',',@cols).' FROM mxq_group WHERE '.$sql_clause.' AND (group_jobs_running>0 OR group_jobs_inq>0) ORDER BY group_id DESC');
+ my %active_allowed=map {$_=>$_} qw(
+ group_id group_name job_threads job_memory job_time job_tmpdir_size job_gpu
+ user_name group_status group_jobs group_jobs_inq group_jobs_running
+ group_jobs_finished group_jobs_failed group_jobs_cancelled group_jobs_unknown
+ );
+ my @active_keys=qw(
+ group_id group_name job_threads job_memory job_time job_tmpdir_size job_gpu
+ user_name group_status group_jobs group_jobs_inq group_jobs_running
+ group_jobs_finished group_jobs_failed group_jobs_cancelled group_jobs_unknown
+ );
+ my $active_order=order_clause($opt->{asort},$opt->{adir},\%active_allowed,'group_id','DESC');
+
+ my $sth=$dbh->prepare('SELECT '.join(',',@cols).' FROM mxq_group WHERE '.$sql_clause.' AND (group_jobs_running>0 OR group_jobs_inq>0) '.$active_order);
$sth->execute(@$sql_binds);
$out.=$NL.'';
- $out.=$NL.$q->Tr($q->th(\@head));
+ $out.=$NL.sortable_header(\@active_keys,\@head,\%active_allowed,$opt->{asort},$opt->{adir},'asort','adir',$base);
while (my $row=$sth->fetchrow_arrayref()) {
my ($group_id,$group_name,$job_threads,
$job_memory,$job_time,$job_tmpdir_size,$job_gpu,
@@ -743,11 +816,26 @@ sub group_table {
unkn
);
- $sth=$dbh->prepare('SELECT '.join(',',@cols).' FROM mxq_group WHERE '.$sql_clause.' AND (group_jobs_running=0 AND group_jobs_inq=0) AND group_mtime >= DATE_SUB(NOW(),INTERVAL ? DAY) ORDER BY group_id DESC');
+ my %fin_allowed=(
+ (map {$_=>$_} qw(
+ group_id group_name job_threads job_memory stats_max_sumrss job_time
+ user_name group_status group_jobs
+ group_jobs_finished group_jobs_failed group_jobs_cancelled group_jobs_unknown
+ )),
+ used_runtime => '(stats_run_sec+stats_idle_sec)',
+ );
+ my @fin_keys=qw(
+ group_id group_name job_threads job_memory stats_max_sumrss job_time
+ used_runtime user_name group_status group_jobs
+ group_jobs_finished group_jobs_failed group_jobs_cancelled group_jobs_unknown
+ );
+ my $fin_order=order_clause($opt->{fsort},$opt->{fdir},\%fin_allowed,'group_id','DESC');
+
+ $sth=$dbh->prepare('SELECT '.join(',',@cols).' FROM mxq_group WHERE '.$sql_clause.' AND (group_jobs_running=0 AND group_jobs_inq=0) AND group_mtime >= DATE_SUB(NOW(),INTERVAL ? DAY) '.$fin_order);
$sth->execute(@$sql_binds,$days);
$out.=$NL.'';
- $out.=$NL.$q->Tr($q->th(\@head));
+ $out.=$NL.sortable_header(\@fin_keys,\@head,\%fin_allowed,$opt->{fsort},$opt->{fdir},'fsort','fdir',$base);
while (my $row=$sth->fetchrow_arrayref()) {
my ($group_id,$group_name,$job_threads,
$job_memory,
@@ -786,10 +874,16 @@ sub groups {
my @sql_binds;
my $user_name_info='';
- if ($q->param('user_name')) {
+ my $user_name = $q->param('user_name');
+ if (defined $user_name && $user_name ne '') {
push @sql_clauses,'user_name = ?';
- push @sql_binds, scalar $q->param('user_name');
- $user_name_info=' submitted by '. $q->param('user_name');
+ push @sql_binds, scalar $user_name;
+ $user_name_info=' submitted by '. escapeHTML($user_name);
+ }
+ my $group_name = $q->param('group_name');
+ if (defined $group_name && $group_name ne '') {
+ push @sql_clauses,'group_name LIKE ?';
+ push @sql_binds, '%'.$group_name.'%';
}
my $days = 7;
if ($q->param('days')) {
@@ -798,11 +892,27 @@ sub groups {
}
}
+ # Parameters to preserve when following a sort link.
+ my %base;
+ $base{user_name} = $user_name if defined $user_name && $user_name ne '';
+ $base{group_name} = $group_name if defined $group_name && $group_name ne '';
+ $base{days} = $days;
+ for my $p (qw(asort adir fsort fdir)) {
+ my $v = $q->param($p);
+ $base{$p} = $v if defined $v && $v ne '';
+ }
+
my $out=h1("MXQ Groups$user_name_info");
$dbh or db_init();
- $out.=group_table(join('AND',@sql_clauses),\@sql_binds,$days);
+ $out.=groups_filter_form($user_name,$group_name,$days);
+
+ $out.=group_table(join(' AND ',@sql_clauses),\@sql_binds,$days,{
+ asort => scalar $q->param('asort'), adir => scalar $q->param('adir'),
+ fsort => scalar $q->param('fsort'), fdir => scalar $q->param('fdir'),
+ base => \%base,
+ });
return $out;
}
@@ -832,8 +942,9 @@ sub server() {
my $sth=$dbh->prepare('SELECT '.join(',',@cols).' FROM mxq_daemon WHERE status<=200 ORDER BY hostname,daemon_name');
$sth->execute();
- $out.=$NL.'';
+ $out.=$NL.'';
+ $out.=$NL.'';
$out.=$NL.$q->Tr($q->th([
'id',
'name',
@@ -859,6 +970,8 @@ sub server() {
# 'start',
# 'stop',
]));
+ $out.=$NL.'';
+ $out.=$NL.'';
my %S;
while (my $row=$sth->fetchrow_arrayref()) {
@@ -894,15 +1007,15 @@ sub server() {
# $q->td($pid_starttime),
$q->td({class=>'number'},$daemon_pid),
$q->td({class=>'number'},$daemon_slots),
- $q->td({class=>'number'},size($daemon_memory*1048576)),
+ $q->td({class=>'number','data-sort'=>$daemon_memory},size($daemon_memory*1048576)),
$q->td({class=>'number'},$daemon_gpus_max),
$q->td({class=>'number'},$daemon_maxtime ? $daemon_maxtime : ''),
- $q->td({class=>'number'},size($daemon_memory_limit_slot_soft*1048576)),
+ $q->td({class=>'number','data-sort'=>$daemon_memory_limit_slot_soft},size($daemon_memory_limit_slot_soft*1048576)),
# $q->td({class=>'number'},$daemon_memory_limit_slot_hard),
$q->td({class=>'number'},$daemon_jobs_running),
$q->td({class=>'number'},$daemon_slots_running),
$q->td({class=>'number'},$daemon_threads_running),
- $q->td({class=>'number'},size($daemon_memory_used*1048576)),
+ $q->td({class=>'number','data-sort'=>$daemon_memory_used},size($daemon_memory_used*1048576)),
$q->td({class=>'number'},$daemon_gpus_used),
# $q->td($mtime),
# $q->td($daemon_start),
@@ -913,6 +1026,8 @@ sub server() {
$out.=$NL.$q->Tr( $q->td(0),$q->td('-'),$q->td('no mxqd'),$q->td($_),$q->td(' '),$q->td(' '),$q->td(' '),$q->td(' '),$q->td(' '),$q->td(' '),$q->td(' '),$q->td(' '),$q->td(' '),$q->td(' '),$q->td(' '),$q->td(' '));
} keys %{$dead_hosts};
+ $out.=$NL.'';
+ $out.=$NL.'';
$out.=$NL.$q->Tr($q->td({colspan=>16},' '));
my $dist = join(', ',map {"$S{daemon_slots_dist}->{$_}x$_"} sort {$b <=> $a} keys %{$S{daemon_slots_dist}});
$out.=$NL.$q->Tr(
@@ -930,10 +1045,69 @@ sub server() {
$q->td({class=>'number'},$S{daemon_gpus_used_sum})
);
+ $out.=$NL.'';
$out.=$NL.' '.$NL;
+ $out.=server_sort_script();
return $out;
}
+# Client-side click-to-sort for the servers table. All servers are already in
+# the page, so no server round-trip is needed: clicking a header
+# reorders the rows (the totals stay put). Numeric columns sort
+# numerically; the human-readable memory columns carry a raw "data-sort" value
+# so e.g. "2.0 GiB" sorts above "512.0 MiB".
+sub server_sort_script {
+ return <<'EOF';
+
+EOF
+}
+
sub top_menu {
return $NL.' |