From 13ca2f9cc7a6eb7e15910f36a24dbe290ce77ef0 Mon Sep 17 00:00:00 2001 From: Paul Menzel Date: Mon, 20 Jul 2026 06:29:38 +0200 Subject: [PATCH 1/2] mxq.in: make static groups tables sortable and filterable The no-JavaScript groups page now has server-side sortable column headers (clickable links carrying whitelisted sort parameters, so an attacker-supplied sort key can never reach the query) and a filter form for user name, group name substring and the finished-groups day window. Co-Authored-By: Claude Opus 4.8 --- web/pages/mxq/mxq.in | 124 +++++++++++++++++++++++++++++++++++++++---- 1 file changed, 115 insertions(+), 9 deletions(-) diff --git a/web/pages/mxq/mxq.in b/web/pages/mxq/mxq.in index c9bd872..4e3a654 100755 --- a/web/pages/mxq/mxq.in +++ b/web/pages/mxq/mxq.in @@ -33,6 +33,12 @@ 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; } + EOF } @@ -635,9 +641,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__"; +
+user: +group: +days: + +
+__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 +735,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 +812,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 +870,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 +888,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; } From 8a40b164cb9b799a89ce35b64d02f96243c5d331 Mon Sep 17 00:00:00 2001 From: Paul Menzel Date: Mon, 20 Jul 2026 06:46:20 +0200 Subject: [PATCH 2/2] mxq.in: make the static servers table client-side sortable The servers page lists every server in a single table, so sorting needs no server round-trip. Clicking a column header now reorders the rows via a small vanilla-JS click-to-sort (no dependencies): data rows live in , the totals stay in , and the human-readable memory columns carry a raw "data-sort" value so e.g. "2.0 GiB" sorts above "512.0 MiB". Co-Authored-By: Claude Opus 4.8 --- web/pages/mxq/mxq.in | 76 +++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 72 insertions(+), 4 deletions(-) diff --git a/web/pages/mxq/mxq.in b/web/pages/mxq/mxq.in index 4e3a654..176cb06 100755 --- a/web/pages/mxq/mxq.in +++ b/web/pages/mxq/mxq.in @@ -39,6 +39,10 @@ form.filter input[type="text"], form.filter input[type="number"] { padding: 2px 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 } @@ -938,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', @@ -965,6 +970,8 @@ sub server() { # 'start', # 'stop', ])); + $out.=$NL.''; + $out.=$NL.''; my %S; while (my $row=$sth->fetchrow_arrayref()) { @@ -1000,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), @@ -1019,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( @@ -1036,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.'' . $NL . $q->Tr( $q->td(a({href=>selfurl('/groups')},'groups')),$q->td(' '),