Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
200 changes: 187 additions & 13 deletions web/pages/mxq/mxq.in
Original file line number Diff line number Diff line change
Expand Up @@ -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; }

</style>
EOF
}
Expand Down Expand Up @@ -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 <tr> of <th> 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' ? ' &#9650;' : ' &#9660;') : '';
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__";
<form class="filter" method="get" action="$action">
user: <input type="text" name="user_name" value="$u" size="12">
group: <input type="text" name="group_name" value="$g" size="16">
days: <input type="number" name="days" value="$d" size="4" min="1">
<input type="submit" value="filter">
</form>
__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';

Expand Down Expand Up @@ -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(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

my %active_allowed = map {$_ => 1} @cols; ?

  • avoid duplication of @cols

  • use more sensible whitespace (I confess, I set the bad example in the surrounding code)

  • %active_allowed is only evaluated with exists so the value doesn't matter. We can use an optimized constant instead of a string which needs allocation for the value.

  • unested! (I'm on mobile)

  • same next line

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.'<table class="groups">';
$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,
Expand Down Expand Up @@ -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.'<table class="groups">';
$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,
Expand Down Expand Up @@ -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')) {
Expand All @@ -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;
}
Expand Down Expand Up @@ -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.'<table class="server">';
$out.=$NL.'<table class="server sortable">';

$out.=$NL.'<thead>';
$out.=$NL.$q->Tr($q->th([
'id',
'name',
Expand All @@ -859,6 +970,8 @@ sub server() {
# 'start',
# 'stop',
]));
$out.=$NL.'</thead>';
$out.=$NL.'<tbody>';

my %S;
while (my $row=$sth->fetchrow_arrayref()) {
Expand Down Expand Up @@ -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),
Expand All @@ -913,6 +1026,8 @@ sub server() {
$out.=$NL.$q->Tr( $q->td(0),$q->td('-'),$q->td('no mxqd'),$q->td($_),$q->td('&nbsp;'),$q->td('&nbsp;'),$q->td('&nbsp;'),$q->td('&nbsp;'),$q->td('&nbsp;'),$q->td('&nbsp;'),$q->td('&nbsp;'),$q->td('&nbsp;'),$q->td('&nbsp;'),$q->td('&nbsp;'),$q->td('&nbsp;'),$q->td('&nbsp;'));
} keys %{$dead_hosts};

$out.=$NL.'</tbody>';
$out.=$NL.'<tfoot>';
$out.=$NL.$q->Tr($q->td({colspan=>16},'&nbsp;'));
my $dist = join(', ',map {"$S{daemon_slots_dist}->{$_}x$_"} sort {$b <=> $a} keys %{$S{daemon_slots_dist}});
$out.=$NL.$q->Tr(
Expand All @@ -930,10 +1045,69 @@ sub server() {
$q->td({class=>'number'},$S{daemon_gpus_used_sum})
);

$out.=$NL.'</tfoot>';
$out.=$NL.'</table>'.$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 <thead> header
# reorders the <tbody> rows (the <tfoot> 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';
<script>
(function () {
function val(row, col) {
var c = row.cells[col];
if (!c) return '';
var d = c.getAttribute('data-sort');
return (d !== null ? d : c.textContent).trim();
}
function isNum(s) {
return s !== '' && /^[-+]?(\d+\.?\d*|\.\d+)([eE][-+]?\d+)?$/.test(s);
}
function arrow(th) {
var a = th.querySelector('span.arrow');
if (!a) { a = document.createElement('span'); a.className = 'arrow'; th.appendChild(a); }
return a;
}
function sortBy(table, col, th) {
var body = table.tBodies[0];
if (!body) return;
var rows = Array.prototype.slice.call(body.rows);
var asc = th.getAttribute('data-dir') !== 'asc';
Array.prototype.forEach.call(table.tHead.rows[0].cells, function (h) {
h.removeAttribute('data-dir');
var a = h.querySelector('span.arrow');
if (a) a.textContent = '';
});
th.setAttribute('data-dir', asc ? 'asc' : 'desc');
arrow(th).textContent = asc ? ' ▲' : ' ▼';
rows.sort(function (r1, r2) {
var x = val(r1, col), y = val(r2, col), cmp;
if (isNum(x) && isNum(y)) cmp = parseFloat(x) - parseFloat(y);
else cmp = x.localeCompare(y, undefined, { numeric: true, sensitivity: 'base' });
return asc ? cmp : -cmp;
});
rows.forEach(function (r) { body.appendChild(r); });
}
document.addEventListener('DOMContentLoaded', function () {
document.querySelectorAll('table.sortable').forEach(function (table) {
if (!table.tHead) return;
Array.prototype.forEach.call(table.tHead.rows[0].cells, function (th, col) {
th.title = 'click to sort';
th.addEventListener('click', function () { sortBy(table, col, th); });
});
});
});
})();
</script>
EOF
}

sub top_menu {
return $NL.'<table class="menu">' . $NL . $q->Tr(
$q->td(a({href=>selfurl('/groups')},'groups')),$q->td('&nbsp;'),
Expand Down