diff --git a/CHANGELOG.md b/CHANGELOG.md index 762841fb5..d4d7af770 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ * Fix ACL operations bypassing configured allowed paths in File Manager * Fix arbitrary file reads in Software Packages module * Fix TLS client certificate verification +* Update Backup Configuration module's destination selector to use the new select-based UI * Update the Authentic theme to the latest version with various improvements: - Add support for HTML signatures in Usermin - Fix light palette layout gaps to be slightly tighter to match dark palette better diff --git a/backup-config/backup-config-lib.pl b/backup-config/backup-config-lib.pl index 347996f5a..51cbc4bab 100755 --- a/backup-config/backup-config-lib.pl +++ b/backup-config/backup-config-lib.pl @@ -147,81 +147,69 @@ else { =head2 show_backup_destination(name, value, [local-mode]) -Returns HTML for a field for selecting a local or FTP file. +Returns HTML for selecting a backup destination. =cut sub show_backup_destination { my ($mode, $user, $pass, $server, $path, $port) = &parse_backup_url($_[1]); -my $rv; -$rv .= ""; -# Local file field -$rv .= "\n"; -$rv .= "\n"; - -# FTP file fields -$rv .= "\n"; -$rv .= "\n"; -$rv .= "\n"; -$rv .= "\n"; -$rv .= "\n"; -$rv .= "\n"; -$rv .= "\n"; -$rv .= "\n"; - -# SCP file fields -$rv .= "\n"; -$rv .= "\n"; -$rv .= "\n"; -$rv .= "\n"; -$rv .= "\n"; -$rv .= "\n"; -$rv .= "\n"; -$rv .= "\n"; +# Keep the existing input names while showing only the selected fields +my @options = ( + # Local file path and chooser + { 'value' => 0, 'label' => $text{'backup_mode0'}, + 'content' => + &ui_textbox("$_[0]_file", $mode == 0 ? $path : "", 60, + undef, undef, + ($_[2] != 1 && $config{'date_subs'}) ? + 'placeholder="/backups/configs-%y-%m-%d-%H-%M-%S.tar.gz"' : + undef). + " ".&file_chooser_button("$_[0]_file") }, + # FTP connection and remote path + { 'value' => 1, 'label' => $text{'backup_mode1'}, + 'content' => &ui_textbox("$_[0]_server", + $mode == 1 ? $server : undef, 30), + 'fields' => [ + [ $text{'backup_path'}, &ui_textbox("$_[0]_path", + $mode == 1 ? $path : undef, 30) ], + [ $text{'backup_login'}, &ui_textbox("$_[0]_user", + $mode == 1 ? $user : undef, 15) ], + [ $text{'backup_pass'}, &ui_password("$_[0]_pass", + $mode == 1 ? $pass : undef, 15, 0, + undef, 'data-password-meter-skip') ], + [ $text{'backup_port'}, &ui_opt_textbox("$_[0]_port", + $mode == 1 ? $port : undef, 5, + $text{'default'}) ], + ] }, + # SSH connection and remote path + { 'value' => 2, 'label' => $text{'backup_mode2'}, + 'content' => &ui_textbox("$_[0]_sserver", + $mode == 2 ? $server : undef, 30), + 'fields' => [ + [ $text{'backup_path'}, &ui_textbox("$_[0]_spath", + $mode == 2 ? $path : undef, 30) ], + [ $text{'backup_login'}, &ui_textbox("$_[0]_suser", + $mode == 2 ? $user : undef, 15) ], + [ $text{'backup_pass'}, &ui_password("$_[0]_spass", + $mode == 2 ? $pass : undef, 15, 0, + undef, 'data-password-meter-skip') ], + [ $text{'backup_port'}, &ui_opt_textbox("$_[0]_sport", + $mode == 2 ? $port : undef, 5, + $text{'default'}) ], + ] }, + ); if ($_[2] == 1) { # Uploaded file field - $rv .= "\n"; - $rv .= "\n"; + push(@options, { 'value' => 3, 'label' => $text{'backup_mode3'}, + 'content' => &ui_upload("$_[0]_upload", 40) }); } elsif ($_[2] == 2) { # Output to browser option - $rv .= "\n"; - $rv .= "\n"; + push(@options, { 'value' => 4, 'label' => $text{'backup_mode4'} }); } -$rv .= "
".&ui_oneradio("$_[0]_mode", 0, undef, $mode == 0)."".&ui_tag('strong', $text{'backup_mode0'})." ". - &ui_textbox("$_[0]_file", $mode == 0 ? $path : "", 60, undef, undef, - ($_[2] != 1 && $config{'date_subs'}) ? - 'placeholder="/backups/configs-%y-%m-%d-%H-%M-%S.tar.gz"' : undef). - " ".&file_chooser_button("$_[0]_file")."
".&ui_oneradio("$_[0]_mode", 1, undef, $mode == 1)."".&ui_tag('strong', $text{'backup_mode1'})." ". - &ui_textbox("$_[0]_server", $mode == 1 ? $server : undef, 20). - " $text{'backup_path'}  ". - &ui_textbox("$_[0]_path", $mode == 1 ? $path : undef, 20). - "
$text{'backup_login'}  ". - &ui_textbox("$_[0]_user", $mode == 1 ? $user : undef, 20). - " $text{'backup_pass'}  ". - &ui_password("$_[0]_pass", $mode == 1 ? $pass : undef, 20). - "
$text{'backup_port'} ". - &ui_opt_textbox("$_[0]_port", $mode == 1 ? $port : undef, 5, - $text{'default'})."
".&ui_oneradio("$_[0]_mode", 2, undef, $mode == 2)."".&ui_tag('strong', $text{'backup_mode2'})." ". - &ui_textbox("$_[0]_sserver", $mode == 2 ? $server : undef, 20). - " $text{'backup_path'}  ". - &ui_textbox("$_[0]_spath", $mode == 2 ? $path : undef, 20). - "
$text{'backup_login'}  ". - &ui_textbox("$_[0]_suser", $mode == 2 ? $user : undef, 20). - " $text{'backup_pass'}  ". - &ui_password("$_[0]_spass", $mode == 2 ? $pass : undef, 20). - "
$text{'backup_port'} ". - &ui_opt_textbox("$_[0]_sport", $mode == 2 ? $port : undef, 5, - $text{'default'})."
".&ui_oneradio("$_[0]_mode", 3, undef, $mode == 3). - "".&ui_tag('strong', $text{'backup_mode3'})." ". - &ui_upload("$_[0]_upload", 40). - "
".&ui_oneradio("$_[0]_mode", 4, undef, $mode == 4). - "".&ui_tag('strong', $text{'backup_mode4'}). - "
\n"; -return $rv; +return &ui_select_switch("$_[0]_mode", $mode, \@options); } =head2 parse_backup_destination(name, &in) diff --git a/backup-config/edit.cgi b/backup-config/edit.cgi index b19330894..68706cd4f 100755 --- a/backup-config/edit.cgi +++ b/backup-config/edit.cgi @@ -29,6 +29,7 @@ else { $backup = &get_backup($in{'id'}); } +print &ui_page_start(); print &ui_form_start("save.cgi", "post"); print &ui_hidden("new", $in{'new'}); print &ui_hidden("id", $in{'id'}); @@ -107,6 +108,7 @@ else { [ 'delete', $text{'delete'} ] ], "100%"); } +print &ui_page_end(); &ui_print_footer("", $text{'index_return'}); diff --git a/backup-config/index.cgi b/backup-config/index.cgi index e45c9cc79..70f26b72e 100755 --- a/backup-config/index.cgi +++ b/backup-config/index.cgi @@ -15,6 +15,7 @@ my @mods = &list_backup_modules(); if (!@mods) { &ui_print_endpage($text{'index_emods'}); } +print &ui_page_start(); my %mods = map { $_->{'dir'}, $_ } @mods; # Show tabs @@ -64,7 +65,10 @@ my $hostname = &get_system_hostname() || "localhost"; $hostname =~ s/\./-/g; my $filename = $hostname."+configuration_backup-webmin-". strftime("%Y-%m-%d-%H-%M", localtime); -print &ui_form_start("backup.cgi/$filename.tar.gz", "post"); +# Let themes bypass AJAX when the current destination is a browser download +print &ui_form_start("backup.cgi/$filename.tar.gz", "post", undef, + "onsubmit=\"this.dataset.download = ". + "this.elements.dest_mode.value == '4' ? 'true' : 'false'\""); print &ui_table_start($text{'index_header'}, undef, 2); my @dmods = split(/\s+/, $config{'mods'} || ""); @@ -114,5 +118,6 @@ print &ui_form_end([ [ 'restore', $text{'index_now2'} ] ]); print &ui_tabs_end_tab(); print &ui_tabs_end(1); +print &ui_page_end(); &ui_print_footer("/", $text{'index'}); diff --git a/filemin/search.cgi b/filemin/search.cgi index 5ffe741a7..53bbf06ed 100755 --- a/filemin/search.cgi +++ b/filemin/search.cgi @@ -29,8 +29,8 @@ if (&test_allowed_paths()) { foreach my $path (@allowed_paths) { my $slashed = $path; $slashed .= "/" if ($slashed !~ /\/$/); - @alist = grep { $_ eq $path || - $_ =~ /^\Q$slashed\E/ } @list; + push(@alist, grep { $_ eq $path || + $_ =~ /^\Q$slashed\E/ } @list); } @list = &unique(@alist); } diff --git a/fsdump/ftp.pl b/fsdump/ftp.pl index 2cbcaa84a..8a5360b5a 100755 --- a/fsdump/ftp.pl +++ b/fsdump/ftp.pl @@ -4,12 +4,22 @@ $no_acl_check++; require './fsdump-lib.pl'; +# start_tls(handle, channel-name, [control-handle]) +# Enables TLS and optionally reuses the control connection's session. sub start_tls { -my ($fh, $what) = @_; +my ($fh, $what, $control) = @_; eval { require IO::Socket::SSL; IO::Socket::SSL->import(); 1; } || &error_exit("FTP server requires TLS, but IO::Socket::SSL is not installed"); -IO::Socket::SSL->start_SSL($fh, SSL_verify_mode => 0) || +my %sslopts = ( SSL_verify_mode => 0, + SSL_session_key => $host ); +if ($control) { + $sslopts{'SSL_reuse_ctx'} = $control; + } +else { + $sslopts{'SSL_session_cache_size'} = 1; + } +IO::Socket::SSL->start_SSL($fh, %sslopts) || &error_exit("FTP $what TLS handshake failed : ". IO::Socket::SSL::errstr()); } @@ -197,7 +207,6 @@ else { $opened = 0; } if ($opened && $ssl_enabled) { - &start_tls(\*CON, "data"); + &start_tls(\*CON, "data", \*SOCK); } } - diff --git a/fsdump/t/ftp-tls.t b/fsdump/t/ftp-tls.t index 3b7a43f87..f7e0f1890 100644 --- a/fsdump/t/ftp-tls.t +++ b/fsdump/t/ftp-tls.t @@ -1,6 +1,6 @@ #!/usr/bin/perl # Regression test for explicit FTP TLS servers that require login before -# accepting PBSZ and PROT. +# accepting PBSZ and PROT, and TLS session reuse on the data connection. use strict; use warnings; @@ -40,7 +40,7 @@ sub open_socket my ($host, $port, $name, $err) = @_; my $socket = IO::Socket::INET->new( PeerAddr => $host, - PeerPort => $ENV{'FSDUMP_TEST_FTP_PORT'}, + PeerPort => $port == 21 ? $ENV{'FSDUMP_TEST_FTP_PORT'} : $port, Proto => 'tcp'); if (!$socket) { $$err = $!; @@ -87,6 +87,16 @@ PEM_key2file($key, $key_file); CERT_free($cert); KEY_free($key); +# Use one TLS context for both server connections so session reuse can be +# required and observed on the data connection. +my $server_context = IO::Socket::SSL::SSL_Context->new( + SSL_server => 1, + SSL_version => 'TLSv1_2', + SSL_cert_file => $cert_file, + SSL_key_file => $key_file, + SSL_session_id_context => 'fsdump-ftps-test') or + die "server TLS context: ".IO::Socket::SSL::errstr()."\n"; + my $listener = IO::Socket::INET->new( LocalAddr => '127.0.0.1', LocalPort => 0, @@ -95,6 +105,7 @@ my $listener = IO::Socket::INET->new( ReuseAddr => 1) or die "listen: $!"; my $port = $listener->sockport(); my $log_file = File::Spec->catfile($tmp, 'server.log'); +my $data_file = File::Spec->catfile($tmp, 'backup.tar'); my $server_pid = fork(); defined($server_pid) or die "fork: $!"; if (!$server_pid) { @@ -113,8 +124,7 @@ if (!$server_pid) { $socket = IO::Socket::SSL->start_SSL( $socket, SSL_server => 1, - SSL_cert_file => $cert_file, - SSL_key_file => $key_file) or + SSL_reuse_ctx => $server_context) or die "server TLS: ".IO::Socket::SSL::errstr()."\n"; $socket->autoflush(1); @@ -125,8 +135,7 @@ if (!$server_pid) { [ qr/^PASS /, "230 logged in\r\n" ], [ qr/^PBSZ 0$/, "200 buffer size set\r\n" ], [ qr/^PROT P$/, "200 private data channel\r\n" ], - [ qr/^TYPE I$/, "200 binary mode\r\n" ], - [ qr/^QUIT$/, "221 goodbye\r\n" ]) { + [ qr/^TYPE I$/, "200 binary mode\r\n" ]) { $line = <$socket>; defined($line) or die "connection closed early\n"; $line =~ s/\r?\n$//; @@ -135,6 +144,60 @@ if (!$server_pid) { die "unexpected command $line\n"; print $socket $step->[1]; } + + # Accept a protected passive upload and require it to reuse the + # control connection's TLS session, as strict FTPS servers do. + my $data_listener = IO::Socket::INET->new( + LocalAddr => '127.0.0.1', + LocalPort => 0, + Proto => 'tcp', + Listen => 1, + ReuseAddr => 1) or die "data listen: $!"; + my $data_port = $data_listener->sockport(); + $line = <$socket>; + defined($line) or die "connection closed before PASV\n"; + $line =~ s/\r?\n$//; + push(@commands, $line); + $line eq 'PASV' or die "expected PASV, got $line\n"; + print $socket "227 Entering Passive Mode (127,0,0,1,". + int($data_port / 256).",".($data_port % 256).")\r\n"; + my $data_socket = $data_listener->accept() or die "data accept: $!"; + close($data_listener); + + $line = <$socket>; + defined($line) or die "connection closed before STOR\n"; + $line =~ s/\r?\n$//; + push(@commands, $line); + $line eq 'STOR /backup.tar' or die "expected STOR, got $line\n"; + print $socket "150 opening data connection\r\n"; + $data_socket = IO::Socket::SSL->start_SSL( + $data_socket, + SSL_server => 1, + SSL_reuse_ctx => $server_context) or + die "server data TLS: ".IO::Socket::SSL::errstr()."\n"; + $data_socket->get_session_reused() or + die "data TLS session was not reused\n"; + push(@commands, 'DATA SESSION REUSED'); + + my $received = ''; + while(1) { + my $read = read($data_socket, my $chunk, 8192); + defined($read) or die "data read: $!"; + last if (!$read); + $received .= $chunk; + } + close($data_socket); + open(my $DATA, '>', $data_file) or die "open data: $!"; + print $DATA $received; + close($DATA) or die "close data: $!"; + print $socket "226 transfer complete\r\n"; + + $line = <$socket>; + defined($line) or die "connection closed before QUIT\n"; + $line =~ s/\r?\n$//; + push(@commands, $line); + $line eq 'QUIT' or die "expected QUIT, got $line\n"; + print $socket "221 goodbye\r\n"; open(my $LOG, '>', $log_file) or die "open log: $!"; print $LOG join("\n", @commands), "\n"; close($LOG) or die "close log: $!"; @@ -158,7 +221,8 @@ my $old_cwd = File::Spec->rel2abs('.'); chdir($tmp) or die "chdir $tmp: $!"; my $client_pid = open3(my $input, my $output, $stderr, $^X, $client_script, '127.0.0.1', 'unused', 'test-user', 'touch'); -print $input "O/backup.tar\n64\nC\n"; +my $payload = "protected backup data\n"; +print $input "O/backup.tar\n64\nW".length($payload)."\n$payload"."C\n"; close($input); my $client_output = do { local $/; <$output> }; my $client_error = do { local $/; <$stderr> }; @@ -178,8 +242,14 @@ is($server_status, 0, 'mock FTPS server accepts the command sequence') or diag($server_log); is($server_log, "AUTH TLS\nUSER test-user\nPASS test-password\nPBSZ 0\n". - "PROT P\nTYPE I\nQUIT\n", - 'login precedes data-channel protection setup'); -is($client_output, "A0\nA0\n", 'rmt protocol receives open and close success'); + "PROT P\nTYPE I\nPASV\nSTOR /backup.tar\n". + "DATA SESSION REUSED\nQUIT\n", + 'login precedes protection and the data TLS session is reused'); +is($client_output, "A0\nA".length($payload)."\nA0\n", + 'rmt protocol receives open, write and close success'); +open(my $DATA, '<', $data_file) or die "open received data: $!"; +my $received = do { local $/; <$DATA> }; +close($DATA); +is($received, $payload, 'protected data reaches the FTP server intact'); done_testing(); diff --git a/gray-theme/left.cgi b/gray-theme/left.cgi index 8789cdb2a..7e592a16e 100755 --- a/gray-theme/left.cgi +++ b/gray-theme/left.cgi @@ -133,17 +133,6 @@ if ($mode eq "modules" && foreign_available("webmin")) { 'icon' => '/images/reload.png' }); } -# Show the UI demo module when it has been dropped into the Webmin root, -# so it is reachable even before it has been added to the user's module list -if (-d &module_root_directory('ui-demo')) { - my %ui_demo = &get_module_info('ui-demo'); - push(@leftitems, { 'type' => 'item', - 'id' => 'ui-demo', - 'desc' => $ui_demo{'desc'} || 'UI Demo', - 'icon' => '/ui-demo/images/menu.svg', - 'link' => '/ui-demo/' }); - } - # Show logout link get_miniserv_config(\%miniserv); if ($miniserv{'logout'} && !$ENV{'SSL_USER'} && !$ENV{'LOCAL_USER'} && diff --git a/package-updates/index.cgi b/package-updates/index.cgi index 3b003b104..fcd90ffca 100755 --- a/package-updates/index.cgi +++ b/package-updates/index.cgi @@ -51,6 +51,16 @@ print &ui_hidden("mode", $in{'mode'}); print &ui_grid_table(\@grid, 2),"

\n"; print &ui_form_end(); +# Warn if the last check for updates failed, as the list may be stale +$updates_error = &get_updates_error(); +if ($updates_error) { + print &ui_tag('div', &ui_details({ + 'title' => $text{'index_updateserr'}, + 'content' => &ui_tag('tt', &html_escape($updates_error)), + 'class' => 'error', + 'html' => 1 }, 1), {'style' => 'margin-bottom: 0.333em;'}); + } + # Work out what packages to show @current = &list_current(1); diff --git a/package-updates/lang/en b/package-updates/lang/en index 54a83fe30..9c5d37413 100644 --- a/package-updates/lang/en +++ b/package-updates/lang/en @@ -35,6 +35,7 @@ index_action_note=Updates may also be installed by $1; disable external updates index_action_disable=Disable external updates index_err=Failed to fetch package list index_refresh=Refresh Available Packages +index_updateserr=The last check for updates failed, so the list below may be out of date index_noupdate=No update exists from version $1 index_caninstall=Available for installation index_webmin=Webmin module $1 @@ -138,6 +139,7 @@ refresh_clearing=Clearing package caches .. refresh_done=.. done refresh_available=Re-fetching available packages .. refresh_done3=.. found $1 possible updates +refresh_failed=.. failed, so the last known list of updates is kept : view_title=Package Details view_header=Updatable package information diff --git a/package-updates/package-updates-lib.pl b/package-updates/package-updates-lib.pl index 91c5a7af2..1eca50ef8 100644 --- a/package-updates/package-updates-lib.pl +++ b/package-updates/package-updates-lib.pl @@ -18,10 +18,14 @@ eval "use WebminCore;"; "dnf-automatic-notifyonly.timer", ); +# Cache files for the package lists and the error from a failed check for +# updates $available_cache_file = &cache_file_path("available.cache"); $current_cache_file = &cache_file_path("current.cache"); $updates_cache_file = &cache_file_path("updates.cache"); $held_updates_cache_file = &cache_file_path("held-updates.cache"); +$updates_error_file = &cache_file_path("updates.error"); +$failed_check_retry_mins = 15; # Minutes before a failed check is retried $cron_cmd = "$module_config_directory/update.pl"; $yum_cache_file = &cache_file_path("yumcache"); @@ -222,6 +226,32 @@ if (-r $file) { return ( ); } +# save_updates_error([error]) +# Saves the error from a failed check for updates, or removes the saved +# error if none is given +sub save_updates_error +{ +my ($err) = @_; +if ($err) { + # Save it for the module pages and the scheduled job to report + &open_tempfile(ERR, ">$updates_error_file"); + &print_tempfile(ERR, $err); + &close_tempfile(ERR); + } +else { + # No error, so stop warning about an earlier failure + unlink($updates_error_file); + } +} + +# get_updates_error() +# Returns the error if the last check for updates failed, or undef +sub get_updates_error +{ +return undef if (!-r $updates_error_file); +return &read_file_contents($updates_error_file); +} + # compare_versions(&pkg1, &pkg2) # Returns -1 if the version of pkg1 is older than pkg2, 1 if newer, 0 if same. sub compare_versions @@ -356,25 +386,47 @@ return $text{'hold_enotsupported'} if (!&supports_package_holds()); return &software::update_system_hold($packages, $hold); } -# updates_available(no-cache, [include-held]) +# updates_available([no-cache], [include-held]) # Returns an array of hash refs of package updates available, according to -# the update system, with caching. +# the update system, with caching. If the check fails, returns the last +# known list and saves the error for get_updates_error. sub updates_available { my ($nocache, $include_held) = @_; +# Held updates have their own cache file and in-memory list my $cache_file = $include_held ? $held_updates_cache_file : $updates_cache_file; my $cache = $include_held ? \@held_updates_available_cache : \@updates_available_cache; +# Load the list, unless a non-empty one is already in memory if (!scalar(@$cache)) { if ($nocache || &cache_expired($cache_file)) { # Get from original source @$cache = &software::update_system_updates($include_held); + if ($software::update_system_error) { + # The check failed, so keep the last known list rather + # than caching an empty one, and save the error + @$cache = &read_cache_file($cache_file); + &save_updates_error($software::update_system_error); + } + else { + # The check worked, so clear any earlier error + &save_updates_error(undef); + } + # Add the fields callers use, and cache the list foreach my $a (@$cache) { $a->{'update'} = $a->{'name'}; $a->{'system'} = $software::update_system; } &write_cache_file($cache_file, $cache); + if ($software::update_system_error) { + # Expire the cache after $failed_check_retry_mins + # minutes, so a failed check is retried soon but not on + # every page load. A shorter cache time is left alone. + my $retry = time() - $config{'cache_time'}*60*60 + + $failed_check_retry_mins*60; + utime($retry, $retry, $cache_file) if ($retry < time()); + } } else { # Use on-disk cache @@ -750,14 +802,23 @@ if ($pkg->{'system'} eq 'yum') { return undef; } +# flush_package_caches() +# Clears the package list caches, so the next lookups fetch fresh data. Also +# removes any saved error from a failed check for updates. sub flush_package_caches { unlink($current_cache_file); -unlink($updates_cache_file); -unlink($held_updates_cache_file); unlink($available_cache_file); +# Expire the update lists rather than deleting them, so a failed check can +# still fall back to the last known list +foreach my $f ($updates_cache_file, $held_updates_cache_file) { + utime(0, 0, $f) if (-e $f); + } +# Delete the remaining cache files and the saved error unlink($available_cache_file.'0'); unlink($available_cache_file.'1'); +unlink($updates_error_file); +# Clear the lists held in memory by this process @packages_available_cache = ( ); @updates_available_cache = ( ); @held_updates_available_cache = ( ); diff --git a/package-updates/update.cgi b/package-updates/update.cgi index 9d871700f..d4a717a65 100755 --- a/package-updates/update.cgi +++ b/package-updates/update.cgi @@ -62,7 +62,16 @@ elsif ($in{'refresh'} || $in{'refresh_top'}) { # Force re-fetch print $text{'refresh_available'},"
\n"; @avail = &list_possible_updates(); - print &text('refresh_done3', scalar(@avail)),"

\n"; + $err = &get_updates_error(); + if ($err) { + # The check failed, so show the error instead of the count + print $text{'refresh_failed'},"
\n"; + print "

",&html_escape($err),"
\n"; + } + else { + # The check worked, so show how many updates were found + print &text('refresh_done3', scalar(@avail)),"

\n"; + } &webmin_log("refresh"); &ui_print_footer($redir, $redirdesc); diff --git a/package-updates/update.pl b/package-updates/update.pl index 562de1a57..1a002976b 100755 --- a/package-updates/update.pl +++ b/package-updates/update.pl @@ -13,6 +13,11 @@ if ($ARGV[0] eq "--debug" || $ARGV[0] eq "-debug") { &clear_repository_cache(); @todo = &list_possible_updates(); +# If the check for updates failed, the list is only the last known one, so +# ignore it and report the failure instead +$checkerr = &get_updates_error(); +@todo = ( ) if ($checkerr); + # Install packages that are needed $tellcount = 0; %already = ( ); @@ -41,6 +46,15 @@ $newcount = 0; $tellbody = ""; %notified = ( ); %pending = ( ); + +# A failed check for updates counts as a failure, so the "any update fails" +# mode emails it. It is also tracked like a pending update, so new-only mode +# emails it once until a check works again. Its key is fixed, because DNF's +# error text varies between runs. +if ($checkerr) { + $fcount++; + $pending{'error'} = 1; + } foreach $t (@todo) { next if ($already{$t->{'update'}}); my $umsg = $t->{'security'} ? "security update" : "update"; @@ -104,6 +118,13 @@ else { $body .= $tellbody; } +# Report a failed check for updates first, unless only new updates are +# being reported and the failure was already reported +if ($checkerr && (!$newonly || $newcount)) { + $body = "Checking for updates failed :\n". + "$checkerr\n\n".$body; + } + if (@updated && $config{'sched_post_script'}) { my @unique_updated = &unique(@updated); my ($post_ok, $post_body) = &run_update_script( diff --git a/software/yum-lib.pl b/software/yum-lib.pl index 68328cec7..1bdb07a19 100755 --- a/software/yum-lib.pl +++ b/software/yum-lib.pl @@ -505,19 +505,49 @@ close(PKG); # update_system_updates([include-holds]) # Returns available package updates, optionally including version-locked ones. +# On DNF, a failed check returns an empty list and sets $update_system_error +# to the error output. Any other result clears it, so callers can tell a +# failure from no updates. sub update_system_updates { my ($include_holds) = @_; local @rv; local %done; +$update_system_error = undef; if ($yum_command =~ /dnf/) { + # With DNF, add the flag that also lists version-locked updates if + # asked, escaping * for the shell my $holdflag = $include_holds && &supports_update_system_holds() ? " ".&update_system_hold_flags() : ""; $holdflag =~ s/\*/\\*/g; - &open_execute_command(PKG, - "$yum_command$holdflag check-update 2>/dev/null", 1, 1); + + # Capture the exit status: DNF exits with 0 for no updates, 100 for + # updates found and any other status on failure. Stderr goes to a file + # rather than a second pipe, which could fill up and block DNF while + # stdout is still being read. Stdin comes from /dev/null so a prompt, + # such as for a repository key import, cannot hang. + my $out = ""; + my $errfile = &transname(); + my $ex = &execute_command("$yum_command$holdflag check-update", + "/dev/null", \$out, $errfile, undef, 1); + my $code = $ex >> 8; + my $err = &read_file_contents($errfile); + unlink($errfile); + if ($code != 0 && $code != 100) { + # The check failed, so return no updates and set the error from + # stderr, or from stdout or the exit status if stderr is empty + $err =~ s/\s+$//; + $out =~ s/\s+$//; + $update_system_error = $err || $out || + "$yum_command check-update exited with status $code"; + return ( ); + } + # Parse the captured output with the shared loop below + open(PKG, "<", \$out); } else { + # YUM wraps long entries onto indented lines, so join them first. + # Failed YUM checks are not detected. &open_execute_command(PKG, "$yum_command check-update 2>/dev/null | tr '\n' '#' | sed -e 's/# / /g' | tr '#' '\n'", 1, 1); } while() { diff --git a/t/software-yum.t b/t/software-yum.t index f64bbb2fa..889fbfd86 100644 --- a/t/software-yum.t +++ b/t/software-yum.t @@ -8,7 +8,8 @@ use File::Spec; use Cwd qw(abs_path); our (%config, %packages, %text); -our ($yum_command, $supports_dnf_versionlock, $dnf_version); +our ($yum_command, $supports_dnf_versionlock, $dnf_version, + $update_system_error); sub has_command { @@ -232,22 +233,26 @@ no warnings qw(once redefine); my $dnf_output = "bash.aarch64 5.1.8-10.el9 baseos\n". "coreutils.aarch64 8.32-40.el9 baseos\n"; +my $dnf_stderr = ''; +my $dnf_status = 100; my @commands; local *supports_update_system_holds = sub { return 1; }; local *list_update_system_holds = sub { return ('bash'); }; local *set_yum_security_field = sub { }; local *get_dnf_version = sub { return 4; }; -local *open_execute_command = sub { - my ($fh, $command) = @_; +local *transname = sub { return 'simulated-stderr-file'; }; +local *read_file_contents = sub { return $dnf_stderr; }; +local *execute_command = sub { + my ($command, $stdin, $stdout) = @_; push(@commands, $command); - no strict 'refs'; - open(ref($fh) ? $fh : \*{$fh}, '<', \$dnf_output) - or die "open simulated DNF updates: $!"; + $$stdout = $dnf_output; + return $dnf_status << 8; }; my @normal = update_system_updates(0); is_deeply([ map { $_->{'name'} } @normal ], [ 'coreutils' ], 'DNF 4 regular updates exclude held packages'); +is($update_system_error, undef, 'a successful check reports no error'); my @with_holds = update_system_updates(1); is_deeply([ map { $_->{'name'} } @with_holds ], [ 'bash', 'coreutils' ], 'DNF 4 held-update query includes locks'); @@ -260,6 +265,39 @@ local *get_dnf_version = sub { return 5; }; like($commands[0], qr/--setopt=disable_excludes=\\\* check-update/, 'DNF 5 disables excludes for held-update discovery'); ok($with_holds[0]->{'held'}, 'marks a DNF 5 locked update as held'); + +$dnf_output = ''; +$dnf_status = 0; +is_deeply([ update_system_updates(0) ], [ ], + 'DNF exit status 0 means no updates'); +is($update_system_error, undef, 'no updates is not reported as an error'); + +$dnf_output = "Some repo 0.0 B/s | 0 B 00:00\n"; +$dnf_stderr = + "Errors during downloading metadata for repository 'x':\n". + "Error: Failed to download metadata for repo 'x'\n"; +$dnf_status = 1; +is_deeply([ update_system_updates(0) ], [ ], + 'a failed check returns no updates'); +is($update_system_error, + "Errors during downloading metadata for repository 'x':\n". + "Error: Failed to download metadata for repo 'x'", + 'a failed check reports the DNF error output'); + +$dnf_stderr = ''; +update_system_updates(0); +is($update_system_error, 'Some repo 0.0 B/s | 0 B 00:00', + 'a failed check without error output reports its normal output'); + +$dnf_output = ''; +update_system_updates(0); +is($update_system_error, '/usr/bin/dnf check-update exited with status 1', + 'a silent failure reports the exit status'); + +$dnf_status = 137; +update_system_updates(0); +is($update_system_error, '/usr/bin/dnf check-update exited with status 137', + 'a killed check is reported as a failure'); } { diff --git a/t/ui-lib-widgets.t b/t/ui-lib-widgets.t index 046e56ae8..001c63f42 100644 --- a/t/ui-lib-widgets.t +++ b/t/ui-lib-widgets.t @@ -60,6 +60,24 @@ sub decode_attr { my $xss = q{x"> $xss, 'title' => $xss }), + 'ui_badge class+title'); assert_no_handler_injection(main::ui_chip($xss), 'ui_chip text'); assert_no_handler_injection(main::ui_code($xss), 'ui_code'); assert_no_handler_injection(main::ui_tip('x', $xss), 'ui_tip'); @@ -119,6 +140,19 @@ like(main::ui_badge('down', 'err'), qr/ui_badge_danger/, like(main::ui_badge('what', 'bogus<'), qr/ui_badge_neutral/, 'unknown state falls back to neutral'); +# Badges take a smaller size, rounded ends and extra class names +{ + my $plain = main::ui_badge('B', 'info'); + unlike($plain, qr/ui_badge_small|ui_badge_rounded/, + 'a badge is full-sized and square by default'); + my $html = main::ui_badge('B', 'info', + { 'small' => 1, 'rounded' => 1, 'class' => 'mine' }); + like($html, qr/\bui_badge_small\b/, 'small option adds its class'); + like($html, qr/\bui_badge_rounded\b/, 'rounded option adds its class'); + like($html, qr/\bui_badge_info\b/, 'and the state class stays'); + like($html, qr/\bmine\b/, 'extra class names are passed through'); +} + # The scheme option stamps the wrapper for the dark or auto palette like(main::ui_page_start({ 'scheme' => 'auto' }), qr/data-ui-scheme="auto"/, 'scheme auto stamps the wrapper'); @@ -383,6 +417,75 @@ like(main::ui_form_columns_table('x.cgi', [ [ 'go', 'Go' ] ], 0, undef, undef, is($second, '', 'second assets call emits nothing'); } +# Separate multi-select badges retain plain-text escaping and filter metadata. +{ + my $html = main::ui_multi_select_list('images', [ 'linux' ], [ + { value => 'linux', label => 'Linux', tag => 'QCOW2', badges => [ + [ 'ARM64', 'neutral', { small => 1, rounded => 1 } ], + [ $xss, 'warning', { small => 1, rounded => 1 } ] ] }, + ], { html => 1 }); + my @badges = $html =~ /class="[^"\n]*\bui_badge\b[^"\n]*"/g; + is(scalar(@badges), 2, 'each metadata badge is a separate element'); + ok(!grep(!/ui_badge_small.*ui_badge_rounded/, @badges), + 'badge rendering retains size and shape options'); + like($html, qr/ui_chip">QCOW2]*checked/, 'badges do not alter selection'); + assert_no_handler_injection($html, 'multi-select badges with HTML labels'); +} + +# Grouped metadata stays plain text and preserves values and legacy defaults. +{ + my $group = { label => $xss, state => 'success', icon => 'star' }; + my @options = ( + { value => 'a', label => 'First', group => $group, metadata => [ + { label => '10 GiB', icon => 'hard-drive', title => $xss }, + { label => $xss, icon => 'clock', state => 'warning' } ] }, + { value => 'b', label => 'Second', group => $group }, + { value => 'c', label => 'Third', group => { label => 'Older', state => 'warning' } }, + ); + my $html = main::ui_multi_select_list('grouped', [ 'b', 'a' ], \@options, + { compact => 1, search => 1, summary => $xss, summary_icon => 'download' }); + is(scalar(() = $html =~ /data-ui-multi-heading=/g), 2, 'consecutive options share a heading'); + is(scalar(() = $html =~ /class="[^"]*\bui_multi_metadata\b/g), 1, 'metadata facts share one badge'); + is(scalar(() = $html =~ /class="[^"]*\bui_multi_item\b/g), 3, 'headings are not selectable options'); + like($html, qr/ui_multi_compact/, 'compact layout is explicitly enabled'); + like($html, qr/name="grouped"[^>]*value="a\nb"/, 'grouping retains normal submission order'); + my ($filter) = $html =~ /data-ui-multi-text="([^"]*)"/; + like(decode_attr($filter), qr/First.*\Q$xss\E.*10 GiB.*\Q$xss\E/s, + 'group names and metadata tooltips are searchable'); + assert_no_handler_injection($html, 'group names, summary and metadata'); + ok(!exists($options[0]->{'attrs'}), 'group rendering does not mutate input options'); + my $plain = main::ui_multi_select_list('plain', [], [ [ 'a', 'First' ] ], {}); + unlike($plain, qr/ui_multi_compact|data-ui-multi-group|data-ui-multi-heading|ui_multi_metadata/, + 'existing callers opt into none of the new layout'); +} + +# Metadata tooltips treat entity names as literal text, like their filter text. +{ + my $title = 'Literal & "quoted"'; + my $html = main::ui_multi_select_list('literal_metadata', [], + [{ value => 'a', label => 'First', metadata => [{ label => 'Details', title => $title }] }], {}); + my ($tooltip) = $html =~ /]*\bui_multi_meta\b)[^>]*\btitle="([^"]*)"/; + is(decode_attr($tooltip), $title, 'metadata tooltip retains literal entity text'); + my ($filter) = $html =~ /data-ui-multi-text="([^"]*)"/; + is(decode_attr($filter), 'First Details '.$title, 'metadata filtering matches the tooltip text'); +} + +# Hiding bulk actions must retain the summary and automatic filter on long lists. +{ + my $html = main::ui_multi_select_list('no_bulk', [], + [ map { [ $_, "Option $_" ] } 1..9 ], + { bulk => 0, count => 0, summary => $xss, summary_icon => 'star' }); + unlike($html, qr/data-ui-multi-action="(?:all|invert)"/, + 'bulk option hides selection links on long lists'); + like($html, qr/data-ui-multi-search="1"/, 'bulk option preserves filtering'); + like($html, qr/ui_multi_summary/, 'summary remains visible without a counter'); + assert_no_handler_injection($html, 'summary without bulk actions'); +} + # Multi-select values, modes, controls and hierarchy. # Match attributes independently because their order varies. { diff --git a/t/ui-multi-select-browser.mjs b/t/ui-multi-select-browser.mjs new file mode 100644 index 000000000..22a12940f --- /dev/null +++ b/t/ui-multi-select-browser.mjs @@ -0,0 +1,152 @@ +// Run real Chromium selection checks without third-party browser dependencies. +// Usage: node t/ui-multi-select-browser.mjs [artifact-directory] [cloudmin-checkout] +import assert from 'node:assert/strict'; +import { spawn, execFileSync } from 'node:child_process'; +import { mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const dir = process.argv[2] || mkdtempSync(join(tmpdir(), 'ui-multi-browser-')); +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const html = join(dir, 'selector.html'); +writeFileSync(html, execFileSync('perl', [join(root, 't/ui-multi-select-fixture.pl'), ...process.argv.slice(3)])); +const binary = process.env.CHROMIUM_BINARY || (process.platform === 'darwin' + ? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome' : 'chromium'); +const chrome = spawn(binary, ['--headless', '--remote-debugging-pipe', '--no-first-run', + '--no-default-browser-check', '--disable-background-networking', '--disable-extensions', + '--disable-sync', `--user-data-dir=${join(dir, 'browser-test-profile')}`, 'about:blank'], + { stdio: ['ignore', 'ignore', 'ignore', 'pipe', 'pipe'] }); +let nextId = 0, buffer = ''; +const pending = new Map(); +// Fail outstanding calls when Chromium cannot start or exits unexpectedly. +const rejectPending = error => { + for (const request of pending.values()) request.reject(error); + pending.clear(); +}; +chrome.on('error', rejectPending); +chrome.on('exit', code => rejectPending(new Error(`Chromium exited (${code})`))); +chrome.stdio[4].on('data', chunk => { + buffer += chunk; + let end; + while ((end = buffer.indexOf('\0')) >= 0) { + const message = JSON.parse(buffer.slice(0, end)); + buffer = buffer.slice(end + 1); + const promise = pending.get(message.id); + if (promise) { + pending.delete(message.id); + message.error ? promise.reject(new Error(JSON.stringify(message.error))) : promise.resolve(message.result); + } + } +}); + +// command(method, params, sessionId) sends one DevTools request over private pipes. +function command(method, params = {}, sessionId) { + if (chrome.exitCode !== null || chrome.signalCode !== null) return Promise.reject(new Error('Chromium is closed')); + const id = ++nextId; + return new Promise((resolve, reject) => { + pending.set(id, { resolve, reject }); + chrome.stdio[3].write(JSON.stringify({ id, method, params, sessionId }) + '\0'); + }); +} +const timeout = setTimeout(() => { console.error('Browser checks timed out'); chrome.kill(); process.exitCode = 1; }, 45000); +try { + const { targetId } = await command('Target.createTarget', { url: 'about:blank' }); + const { sessionId } = await command('Target.attachToTarget', { targetId, flatten: true }); + const page = (method, params) => command(method, params, sessionId); + const evaluate = async expression => { + const result = await page('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true }); + if (result.exceptionDetails) throw new Error(JSON.stringify(result.exceptionDetails)); + return result.result.value; + }; + await page('Emulation.setDeviceMetricsOverride', { width: 1120, height: 900, deviceScaleFactor: 1, mobile: false }); + await page('Page.navigate', { url: pathToFileURL(html).href }); + for (let n = 0; n < 100; n++) { + if (await evaluate("document.readyState === 'complete' && !!document.querySelector('#ui_multi_grouped')")) break; + await new Promise(r => setTimeout(r, 50)); + } + await evaluate(`window.box = id => document.querySelector('#ui_multi_' + id); + window.value = id => box(id).querySelector('input[type=hidden][name="' + id + '"]').value; + window.search = text => { const input = box('grouped').querySelector('[data-ui-multi-search]'); input.value = text; input.dispatchEvent(new Event('input', {bubbles:true})); }; + window.visible = selector => [...box('grouped').querySelectorAll(selector)].filter(e => !e.hidden).length;`); + + // Filtering retains selections and removes empty headings; bulk actions stay scoped. + assert.equal(await evaluate("value('grouped')"), 'image-0'); + await evaluate("search('Older')"); + assert.equal(await evaluate("visible('[data-ui-multi-heading]')"), 1); + assert.equal(await evaluate("visible('.ui_multi_item')"), 2); + await evaluate("box('grouped').querySelector('[data-ui-multi-action=all]').click()"); + assert.equal(await evaluate("value('grouped')"), 'image-0\nimage-8'); + await evaluate("box('grouped').querySelector('[data-ui-multi-action=invert]').click()"); + assert.equal(await evaluate("value('grouped')"), 'image-0'); + await evaluate("search('Minimum disk size')"); + assert.equal(await evaluate("visible('.ui_multi_item')"), 10); + await evaluate("search('not present')"); + assert.equal(await evaluate("visible('[data-ui-multi-heading]')"), 0); + assert.equal(await evaluate("box('grouped').querySelector('.ui_multi_empty').hidden"), false); + await evaluate("search('')"); + + // Shift-click crosses headings while excluding disabled choices. + await evaluate(`const first = box('grouped').querySelector('input[value="image-1"]'); first.click(); + box('grouped').querySelector('input[value="image-5"]').dispatchEvent(new MouseEvent('click', {bubbles:true, shiftKey:true}));`); + assert.equal(await evaluate("value('grouped')"), 'image-0\nimage-1\nimage-2\nimage-3\nimage-4\nimage-5'); + + // Virtualmin hierarchy, selected child retention, modes and tags remain unchanged. + assert.equal(await evaluate("value('domains')"), 'parent\nchild'); + assert.equal(await evaluate("box('domains').querySelector('[data-ui-multi-level]').hidden"), true); + assert.equal(await evaluate("box('domains').querySelector('.ui_multi_count').textContent"), '1 selected'); + assert.equal(await evaluate("box('domains').querySelector('.ui_chip').textContent"), 'Disabled'); + await evaluate("box('domains').querySelector('[data-ui-multi-action=children]').click()"); + assert.equal(await evaluate("box('domains').querySelector('[data-ui-multi-level]').hidden"), false); + await evaluate(`const mode = box('domains').querySelector('select'); mode.value = '1'; mode.dispatchEvent(new Event('change', {bubbles:true}));`); + assert.equal(await evaluate("box('domains').querySelector('.ui_multi_body').hidden"), true); + assert.equal(await evaluate("value('domains')"), 'parent\nchild'); + assert.equal(await evaluate("box('domains').classList.contains('ui_multi_compact')"), false); + + // Decoration must not flatten hierarchy or change literal tooltip text. + for (const compact of [0, 1]) { + const id = 'nested' + compact; + assert.equal(await evaluate(`box('${id}').querySelector('[data-ui-multi-heading="2"]').hidden`), true); + await evaluate(`box('${id}').querySelector('[data-ui-multi-action=children]').click()`); + assert.equal(await evaluate(`box('${id}').querySelector('[data-ui-multi-heading="2"]').hidden`), false); + assert.deepEqual(await evaluate(`[...box('${id}').querySelectorAll('[data-ui-multi-level]')].map(row => parseFloat(getComputedStyle(row).paddingLeft))`), [27, 47]); + assert.equal(await evaluate(`box('${id}').querySelector('.ui_multi_meta').title`), 'Literal & "quoted"'); + } + + // Legacy order and selected labels survive changes and a native form reset. + assert.equal(await evaluate("value('legacy')"), 'b\na'); + await evaluate("box('legacy').querySelector('input[value=c]').click()"); + assert.equal(await evaluate("value('legacy')"), 'c\nb\na'); + assert.match(await evaluate("box('legacy').textContent"), /Chosen B/); + await evaluate("document.querySelector('#checks').reset(); new Promise(r => setTimeout(r, 30))"); + assert.equal(await evaluate("value('legacy')"), 'b\na'); + assert.equal(await evaluate("value('grouped')"), 'image-0'); + assert.equal(await evaluate("box('domains').querySelector('[data-ui-multi-level]').hidden"), true); + + // Inspect real Cloudmin metadata in both schemes and a narrow viewport. + if (process.argv[3]) { + await evaluate("document.querySelector('#checks').hidden = true"); + assert.equal(await evaluate("[...box('images').querySelectorAll('.ui_multi_item')].every(row => row.querySelectorAll('.ui_badge').length <= 1)"), true); + assert.doesNotMatch(await evaluate("box('images').textContent"), /ARM64|Min\. disk:|GiB/); + assert.equal(await evaluate("box('images').querySelector('[data-ui-multi-action=all], [data-ui-multi-action=invert]')"), null); + await evaluate("box('images').querySelector('input[data-ui-multi-item]').click()"); + assert.equal(await evaluate("value('images').split('\\n').length"), 1); + assert.equal(await evaluate("box('images').querySelector('.ui_multi_count')"), null); + for (const scheme of ['light', 'dark']) { + await evaluate(`document.body.className = 'ui_page'; document.body.dataset.uiScheme = '${scheme}'; document.querySelector('#cloudmin').dataset.uiScheme = '${scheme}'`); + const shot = await page('Page.captureScreenshot', { format: 'png' }); + writeFileSync(join(dir, scheme + '.png'), Buffer.from(shot.data, 'base64')); + } + const height = await evaluate("box('images').querySelector('.ui_multi_item').getBoundingClientRect().height"); + assert.ok(height <= 30, `compact row height is ${height}px`); + await page('Emulation.setDeviceMetricsOverride', { width: 375, height: 900, deviceScaleFactor: 1, mobile: false }); + assert.equal(await evaluate("document.documentElement.scrollWidth <= innerWidth"), true, 'no narrow-screen horizontal overflow'); + const shot = await page('Page.captureScreenshot', { format: 'png' }); + writeFileSync(join(dir, 'narrow.png'), Buffer.from(shot.data, 'base64')); + } + console.log('PASS: grouped selection, filtering, bulk actions, shift-click, Virtualmin hierarchy, modes, legacy order and reset'); +} finally { + clearTimeout(timeout); + await command('Browser.close').catch(() => {}); + chrome.kill(); +} diff --git a/t/ui-multi-select-fixture.pl b/t/ui-multi-select-fixture.pl new file mode 100644 index 000000000..747baca6c --- /dev/null +++ b/t/ui-multi-select-fixture.pl @@ -0,0 +1,83 @@ +#!/usr/bin/perl +# Render real selector markup for browser checks, optionally with Cloudmin images. +use strict; +use warnings; +use FindBin qw($Bin); +use lib "$Bin/.."; +use WebminCore; +our ($root_directory, %text); +$root_directory = "$Bin/.."; +$main::ui_page_assets_done = 1; +open(my $lang, '<', "$root_directory/lang/en") or die $!; +while (<$lang>) { $text{$1} = $2 if /^([^=]+)=(.*)/; } +close($lang); +%WebminCore::text = %text; +print ''; +print ''; +print ''; +print ''; + +# Grouped options include a disabled choice and enough rows for bulk actions. +my @options = map { { value => "image-$_", label => "Image $_", + group => { label => $_ < 4 ? 'Recommended' : $_ < 8 ? 'Supported' : 'Older', + state => $_ < 4 ? 'success' : $_ < 8 ? 'neutral' : 'warning' }, + disabled => $_ == 9, + metadata => [ { label => '10 GiB', title => 'Minimum disk size: 10 GiB', icon => 'hard-drive' }, + { label => $_ < 8 ? 'Support until 2030' : 'Support ended 2025', + icon => 'clock', state => $_ < 8 ? 'neutral' : 'warning' } ] } +} 0..9; +print '

'; +print ui_multi_select_list('grouped', ['image-0'], \@options, + { compact => 1, search => 1, summary => 'Images', height => '420px' }); +print '
'; + +# Virtualmin's domain picker uses hierarchy, suffixes, tags and child folding. +my @domains = ( + { value => 'parent', label => 'example.test' }, + { value => 'child', label => 'shop', suffix => '.example.test', level => 1 }, + { value => 'disabled', label => 'disabled.test', tag => 'Disabled' }, + map { { value => "domain-$_", label => "site$_.test" } } 1..7 +); +$text{'browser_child_note'} = '+$1 subdomains'; +$WebminCore::text{'browser_child_note'} = $text{'browser_child_note'}; +print ui_multi_select_list('domains', ['parent', 'child'], \@domains, + { search => 1, modes => { name => 'domain_mode', value => 0, + options => [[0, 'Selected servers'], [1, 'All servers']], hide => [1] }, + children => { name => 'fold', checked => 1, label => 'Hide subdomains', note => 'browser_child_note' } }); +print '
'; + +# Legacy positional callers preserve selected labels and prepend new selections. +print ui_multi_select_list('legacy', [['b', 'Chosen B'], ['a', 'Chosen A']], + [[a => 'A'], [b => 'B'], [c => 'C']], 8); +print '
'; + +# Badges and compact rows must preserve both levels of child indentation. +my @nested = map { { value => "nested-$_", label => "Level $_", level => $_, + group => { label => $_ ? 'Children' : 'Parents' }, + badges => [[ 'Status', 'neutral' ]], + metadata => [{ label => 'Details', title => 'Literal & "quoted"' }] } +} 0..2; +foreach my $compact (0, 1) { + print ui_multi_select_list('nested'.$compact, [], \@nested, + { compact => $compact, search => 1, bulk => 0, count => 0, + summary => 'Nested choices', + children => { name => 'fold'.$compact, checked => 1, label => 'Hide children' } }); +} +print '
'; + +# Optional preview uses the actual Cloudmin helper and host-filtered catalogue. +if (my $cloudmin = $ARGV[0]) { + open($lang, '<', "$cloudmin/lang/en") or die $!; + while (<$lang>) { $text{$1} = $2 if /^([^=]+)=(.*)/; } + close($lang); + require "$cloudmin/lib/Cloudmin/ImageCatalog.pm"; + require "$cloudmin/cloudmin-pages.pl"; + my $catalogue = Cloudmin::ImageCatalog->new(path => "$cloudmin/catalog/images.json"); + my @images = map { _cloudmin_image_option($_) } @{$catalogue->list_images('aarch64')}; + print '

Add images

'; + print ui_multi_select_list('images', [], \@images, + { compact => 1, search => 1, bulk => 0, count => 0, height => '650px', + summary => $text{'images_bundled_cloud'}, summary_icon => 'download' }); + print '
'; +} +print ''; diff --git a/ui-demo/README.md b/ui-demo/README.md index aaefa7fde..318d561df 100644 --- a/ui-demo/README.md +++ b/ui-demo/README.md @@ -4,7 +4,7 @@ A read-only Webmin module that shows the widgets added at the end of `ui-lib.pl` next to the existing tabs, forms, buttons and tables they are meant to be combined with. It is a reference for writing new modules and is not part of the Webmin distribution. Dropping this directory into the -Webmin root is enough to make it appear under the *Others* category. +Webmin root is enough to make it appear under the *Tools* category. ## What it shows @@ -13,7 +13,7 @@ Every tab of `index.cgi` is built by one function in `ui-demo-pages.pl` : | Tab | Function | Shows | |------------|-----------------------|------------------------------------------------------------------| | Cards | `demo_cards_tab` | `ui_card` in its variations : buttons inside a card, header actions and footer, state accents with icon titles, a two-column `ui_dl`, a flush list filtered by a `ui_search` in the header, a standard table inside a card, a metric card with `ui_stat` and inline `ui_progress`, a card printed with `ui_card_start`/`ui_card_end`; `ui_stats`; `ui_grid` with the `template` option | -| Elements | `demo_elements_tab` | a `ui_dl` with help bubbles and HTML values, `ui_stats` with icons and links, `ui_feed` with an HTML event, `ui_empty_state`, then badges with their icon, dot and title options, chips, `ui_code`, `ui_note`, `ui_help`, `ui_tip`, every `ui_progress` variation and the ring gauges, and `ui_svg_icon` | +| Elements | `demo_elements_tab` | a `ui_dl` with help bubbles and HTML values, `ui_stats` with icons and links, `ui_feed` with an HTML event, `ui_empty_state`, then badges with their icon, dot, title, small and rounded options, chips, `ui_code`, `ui_note`, `ui_help`, `ui_tip`, every `ui_progress` variation and the ring gauges, and `ui_svg_icon` | | Forms | `demo_forms_tab` | the usual `ui_table_start` / `ui_table_row` form with `ui_toggle`, `ui_search`, the date chooser, password fields and a table of inputs inside one row built with `ui_columns_start` / `ui_columns_row` with the `no-hover` class, as the Nginx module's URL rewrites; a second form of choosers : `file_chooser_button` for files and directories, `ui_user_textbox`, `ui_group_textbox`, `ui_users_textbox`, `ui_groups_textbox`, and an `hlink` help link | | Choices | `demo_choices_tab` | `ui_choice`, `ui_select_switch` and `ui_radio_list` for backup destinations and IP addresses; `ui_multi_select_list` for backup servers with modes and child folding, secondary groups, and an empty list with a custom label | | Buttons | `demo_buttons_tab` | `ui_submit`, `ui_reset` and `ui_link_button` in one row, with a disabled and a confirmed one; a form ended by `ui_form_end`, one by `ui_form_grouped_buttons`, one by `ui_form_end_side_by_side` with a separate form at the right; a `ui_confirmation_form` page | @@ -46,6 +46,12 @@ entry and Shift-click another to select or clear the range between them. Checkboxes, labels and row backgrounds work alike; filtered, folded and disabled entries are skipped. +The "Image metadata" example opts into `compact` rows, a toolbar `summary`, +and `group` headings. Each option's `metadata` array combines icon, label, +state and tooltip fields in one small rounded badge. Labels and tooltips are +escaped and searchable; filtering hides empty group headings. Existing callers +keep their layout, tags, separate badges and selection behavior. + The server example starts with a parent and two children selected. Turning on "Include sub-servers" folds the children and changes the count from three to one. Expanding restores the count to three; the child selections diff --git a/ui-demo/lang/en b/ui-demo/lang/en index bc1769540..aa10f68d8 100644 --- a/ui-demo/lang/en +++ b/ui-demo/lang/en @@ -360,6 +360,16 @@ index_c_except=All except index_c_subservers=Include sub-servers of those selected index_c_subs=+$1 sub-servers index_c_member=Group membership +index_c_images=Image metadata +index_c_image_linux=Linux cloud image +index_c_image_custom=Custom disk image +index_c_bundled=Bundled +index_c_custom=Custom +index_c_recommended=Recommended +index_c_older=Older +index_c_disk_hint=Minimum disk size: 10 GiB +index_c_support_until=Support until 2030 +index_c_support_ended=Support ended 2025 index_c_secgroups=Secondary groups index_c_attrs=Option attributes index_c_managed=Managed by policy diff --git a/ui-demo/module.info b/ui-demo/module.info index eca45203e..daf24bbd2 100644 --- a/ui-demo/module.info +++ b/ui-demo/module.info @@ -1,6 +1,5 @@ desc=UI Demo longdesc=Read-only gallery of the widgets in ui-lib.pl and of the existing forms, buttons and tables they are meant to be combined with. A reference for writing new modules; not part of the distribution. -category=others os_support=* noacl=1 readonly=1 diff --git a/ui-demo/ui-demo-pages.pl b/ui-demo/ui-demo-pages.pl index 31569fb60..62119a3f8 100644 --- a/ui-demo/ui-demo-pages.pl +++ b/ui-demo/ui-demo-pages.pl @@ -372,6 +372,22 @@ $rv .= ui_grid([ ui_chip('journal'), ui_chip('ipv6'), ]), + # The small badge, which does not outweigh the + # text beside it, and the rounded one. Only + # badges take a rounded option; the widgets are + # drawn square by default + ui_cluster([ + ui_badge($text{'index_running'}, 'success', + { 'small' => 1 }), + ui_badge($text{'index_stopped'}, 'danger', + { 'small' => 1, 'dot' => 1 }), + ui_badge($text{'index_off'}, 'neutral', + { 'small' => 1, 'icon' => '' }), + ui_badge($text{'index_running'}, 'success', + { 'rounded' => 1 }), + ui_badge($text{'index_syncing'}, 'info', + { 'small' => 1, 'rounded' => 1 }), + ]), # Inline code, a note, the existing help bubble, and # ui_tip, which gives any HTML the same theme tooltip ui_cluster([ @@ -815,6 +831,22 @@ $rv .= ui_table_row($text{'index_c_html'}, { 'html' => 1, 'count' => 0, 'search' => 1 }), 2); $rv .= ui_table_end(); +# Compact grouped choices combine related facts in one badge per row. +$rv .= ui_table_start($text{'index_c_images'}, 'width=100%', 2); +$rv .= ui_table_row(undef, + ui_multi_select_list('images', [ ], [ + { value => 'bundled', label => $text{'index_c_image_linux'}, + group => { label => $text{'index_c_recommended'}, icon => 'star', state => 'success' }, + metadata => [ { label => '10 GiB', icon => 'hard-drive', title => $text{'index_c_disk_hint'} }, + { label => $text{'index_c_support_until'}, icon => 'clock' } ] }, + { value => 'custom', label => $text{'index_c_image_custom'}, + group => { label => $text{'index_c_older'}, icon => 'warning', state => 'warning' }, + metadata => [ { label => $text{'index_c_custom'}, icon => 'edit' }, + { label => $text{'index_c_support_ended'}, icon => 'warning', state => 'warning' } ] }, + ], { search => 1, compact => 1, summary => $text{'index_c_images'}, + summary_icon => 'download' }), 2); +$rv .= ui_table_end(); + # Both APIs retain descriptions in their respective label arguments. my $staff_label = text('index_c_staff_desc', 'staff'); $rv .= ui_table_start($text{'index_c_labels'}, 'width=100%', 4); diff --git a/ui-lib.pl b/ui-lib.pl index 0c2e92cb3..9d5d66e15 100755 --- a/ui-lib.pl +++ b/ui-lib.pl @@ -3756,8 +3756,10 @@ return &theme_ui_note(@_) if (defined(&theme_ui_note)); my ($text, $whitespace) = @_; $whitespace //= 2; my $whitespace_str = " " x $whitespace; +# Written as an entity, not a literal character, so the icon survives +# whether the page is handled as bytes or as decoded text return "". - "${whitespace_str}ⓘ  $text". + "${whitespace_str}ⓘ  $text". ""; } @@ -4336,6 +4338,8 @@ my %ui_svg_icons = ( 'stop' => 'M4.7 4.7h6.6v6.6H4.7z', 'power' => 'M8 2.2v5.6|M11.4 4.4a5.4 5.4 0 1 1-6.8 0', 'clock' => 'M8 14.25A6.25 6.25 0 1 0 8 1.75a6.25 6.25 0 0 0 0 12.5z|M8 4.8V8l2.2 1.4', + 'star' => 'M8 1.5l2 4.1 4.5.7-3.25 3.2.75 4.5L8 11.9l-4 2.1.75-4.5L1.5 6.3 6 5.6z', + 'hard-drive' => 'M3 2.5h10l1.5 7v4H1.5v-4z|M1.5 9.5h13|M4 11.5h.01|M7 11.5h.01', 'shield' => 'M8 1.8l5.2 2v4c0 3.2-2.2 5.3-5.2 6.4C4.8 13.1 2.8 11 2.8 7.8v-4z', 'server' => 'M2 3.2h12v4.2H2z|M2 8.6h12v4.2H2z|M4.6 5.3h.01|M4.6 10.7h.01', 'gear' => 'M8 10.4a2.4 2.4 0 1 0 0-4.8 2.4 2.4 0 0 0 0 4.8z|M8 1.8v2|M8 12.2v2|M1.8 8h2|M12.2 8h2|M3.6 3.6L5 5|M11 11l1.4 1.4|M12.4 3.6L11 5|M5 11l-1.4 1.4', @@ -4955,6 +4959,12 @@ default icon. Set to an empty string for no icon at all. =item title - Tooltip text for the badge. +=item small - Set to 1 for a smaller badge, so it does not outweigh the text next to it. List rows already draw their badges small. + +=item rounded - Set to 1 for rounded ends, instead of the square corners badges use by default. + +=item class - Extra CSS class names for the badge. + =cut sub ui_badge { @@ -4974,7 +4984,10 @@ elsif ($ui_state_icons{$state}) { $icon = &ui_svg_icon($ui_state_icons{$state}, { 'size' => 13 }); } return &ui_tag('span', $icon.&ui_tag('span', &html_escape($label)), - &_ui_attrs({ 'class' => "ui_badge ui_badge_$state", + &_ui_attrs({ 'class' => &_ui_class('ui_badge', 'ui_badge_'.$state, + $opts->{'small'} ? 'ui_badge_small' : undef, + $opts->{'rounded'} ? 'ui_badge_rounded' : undef, + $opts->{'class'}), 'title' => $opts->{'title'} })); } @@ -5740,9 +5753,17 @@ Size, add-if-missing, titles and width are ignored. =item options - Array reference of [ value, label, attributes ] entries or hashes with keys value, label, suffix (muted text after the label), level (indentation depth), tag (chip at the right), disabled and attrs. Attributes may be a hash or trusted HTML attribute string, as in ui_select. disabled applies to the checkbox; other attributes, such as title, style and class, apply to the row. Widget identity and selection remain controlled by the library. +=item badges - An option hash may also contain a badges array. Each entry holds ui_badge arguments: [ label, state, options ]. Badges appear separately at the right, beside any tag. Their labels are escaped and included in filtering. + +=item metadata - An option hash may contain an array of hashes with label, icon, title and state. These appear together in one compact badge at the right. Labels and tooltip titles are plain text and included in filtering. Unknown metadata should be omitted. + +=item group - An option hash may contain a hash with label, icon and state. Consecutive options with the same group share a heading. Group labels are plain text and included in filtering. Empty groups disappear when filtering; headings do not affect selection or submission order. + =item opts - Optional hash reference with the keys : -=item search - Show or hide the filter button; defaults to on above eight entries. The input opens to its left in reserved space. Selection links appear above eight entries, affect visible, enabled entries and are omitted when disabled. +=item search - Show or hide the filter button; defaults to on above eight entries. The input opens to its left in reserved space. + +=item bulk - Set to 0 to omit Select all and Invert selection. By default, these links appear above eight entries, affect visible, enabled entries and are omitted when disabled. =item count - Show the selection count; defaults to on. Set to 0 to omit it. @@ -5754,6 +5775,10 @@ Size, add-if-missing, titles and width are ignored. =item height - Height beyond which the list scrolls : a CSS length, 170px by default. +=item compact - Opt into tighter rows and a highlight for selected entries. Other callers keep their existing layout. + +=item summary - Optional plain text displayed at the start of the toolbar. summary_icon adds an icon before it. + =item modes - Hash with name, value and options as for ui_select. The hide array lists modes that hide the list, such as "all servers". Set radios to 1 to use radio buttons. =item children - Hash defining a switch that folds indented entries under their parent. Keys: name, value, checked, label or label_html, and note (a language key with $1 for the child count, shown beside the parent while folded). Folded selections are retained but excluded from the count. @@ -5912,7 +5937,14 @@ if ($hasmodes) { # Larger lists get themed selection links, with actions scoped to this list. my $tools = ""; -if (!$dis && @items > 8) { +if (defined($opts->{'summary'}) && $opts->{'summary'} ne '') { + # Shared context belongs in the toolbar instead of every option. + $tools .= &ui_tag('span', + ($opts->{'summary_icon'} ? &ui_svg_icon($opts->{'summary_icon'}, + { 'size' => 14 }) : '').&html_escape($opts->{'summary'}), + { 'class' => 'ui_multi_summary' }); + } +if (!$dis && @items > 8 && (!defined($opts->{'bulk'}) || $opts->{'bulk'})) { my $links = &ui_links_row([ &ui_tag('a', &html_escape($text{'ui_selall'}), { 'href' => '#', 'class' => 'select_all', @@ -5959,7 +5991,24 @@ my $body = $tools ? &ui_tag('div', $tools, { 'class' => 'ui_multi_tools' }) : '' # Render themed checkboxes with optional suffixes, child counts and tags. my $rows = ""; +my ($lastgroup, $groupnum) = ('', 0); foreach my $it (@items) { + # Group headings never become selectable items or change option order. + my $group = ref($it->{'group'}) eq 'HASH' ? $it->{'group'} : {}; + my $group_label = defined($group->{'label'}) ? $group->{'label'} : ''; + my $group_key = $group_label eq '' ? '' : + join("\0", $group_label, $group->{'state'} || '', $group->{'icon'} || ''); + if ($group_key ne '' && $group_key ne $lastgroup) { + $groupnum++; + $rows .= &ui_tag('div', + &ui_svg_icon($group->{'icon'} || 'dot', { 'size' => 14 }). + &ui_tag('span', &html_escape($group_label)), + { 'class' => 'ui_multi_group ui_multi_group_' . + &_ui_state($group->{'state'}), + 'role' => 'heading', 'aria-level' => 3, + 'data-ui-multi-heading' => $groupnum }); + } + $lastgroup = $group_key; my $val = $it->{'value'}; my $label = $opts->{'html'} ? $it->{'label'} : &html_escape($it->{'label'}, $legacy); @@ -5982,9 +6031,32 @@ foreach my $it (@items) { $row .= &ui_tag('span', &html_escape(&text($note, $it->{'kids'})), $nattrs); } - $row .= &ui_tag('span', &ui_chip($it->{'tag'}), - { 'class' => 'ui_multi_side' }) - if (defined($it->{'tag'}) && $it->{'tag'} ne ''); + # Keep plain tags compatible while allowing separate state badges. + my @badges = ref($it->{'badges'}) eq 'ARRAY' ? + grep { ref($_) eq 'ARRAY' && defined($_->[0]) } @{$it->{'badges'}} : (); + my $side = defined($it->{'tag'}) && $it->{'tag'} ne '' ? + &ui_chip($it->{'tag'}) : ''; + $side .= join('', map { &ui_badge(@$_) } @badges); + # Several facts share one badge, with plain labels and accessible tooltips. + my @metadata = ref($it->{'metadata'}) eq 'ARRAY' ? + grep { ref($_) eq 'HASH' && defined($_->{'label'}) && + $_->{'label'} ne '' } @{$it->{'metadata'}} : (); + if (@metadata) { + my $parts = join('', map { + &ui_tag('span', + ($_->{'icon'} ? &ui_svg_icon($_->{'icon'}, { 'size' => 13 }) : ''). + &ui_tag('span', &html_escape($_->{'label'})), + &_ui_attrs({ 'class' => 'ui_multi_meta ui_multi_meta_' . + &_ui_state($_->{'state'}), + 'title' => &html_escape($_->{'title'}) })) + } @metadata); + $side .= &ui_tag('span', $parts, + { 'class' => 'ui_badge ui_badge_neutral ui_badge_small '. + 'ui_badge_rounded ui_multi_metadata' }); + } + $row .= &ui_tag('span', $side, + { 'class' => &_ui_class('ui_multi_side', @badges ? 'ui_multi_badges' : undef) }) + if (length($side)); # Lowercase in the browser, after UTF-8 bytes have been decoded. my $attrs = { %{$it->{'attrs'}}, %{&_ui_attrs({ 'class' => &_ui_class('ui_multi_item', @@ -5994,10 +6066,15 @@ foreach my $it (@items) { $it->{'disabled'} ? 'ui_multi_disabled' : undef), 'data-ui-multi-level' => $it->{'level'} ? int($it->{'level'}) : undef, + 'data-ui-multi-group' => $group_key ne '' ? $groupnum : undef, 'data-ui-multi-text' => join(" ", grep { defined($_) && $_ ne '' } $filter_label.&html_escape($it->{'suffix'}, $legacy), - &html_escape($it->{'tag'}, $legacy)) }) } }; + &html_escape($it->{'tag'}, $legacy), + &html_escape($group_label), + (map { &html_escape($_->[0]) } @badges), + (map { &html_escape($_->{'label'}).' '. + &html_escape($_->{'title'}) } @metadata)) }) } }; $attrs->{'hidden'} = undef if ($folded && $it->{'level'}); $rows .= &ui_tag('div', $row, $attrs); } @@ -6033,6 +6110,7 @@ $battrs->{'hidden'} = undef if ($hidden); $rv .= &_ui_block('div', $body, $battrs); my $attrs = &_ui_attrs({ 'class' => &_ui_class('ui_multi', $opts->{'class'}, + $opts->{'compact'} ? 'ui_multi_compact' : undef, $dis ? 'ui_multi_disabled' : undef), 'id' => defined($opts->{'id'}) ? $opts->{'id'} : 'ui_multi_'.$name, 'style' => $opts->{'height'} ? diff --git a/unauthenticated/css/ui-lib.css b/unauthenticated/css/ui-lib.css index 41c1ab400..4afad54f0 100644 --- a/unauthenticated/css/ui-lib.css +++ b/unauthenticated/css/ui-lib.css @@ -513,6 +513,17 @@ a.ui_stat:hover .ui_stat_value { color: var(--ui-accent); } border-color: var(--ui-neutral-line); color: var(--ui-neutral-text); } +/* A smaller badge, so it does not outweigh the text next to it. The + * icon scales with the text rather than staying at full size */ +.ui_badge_small { + padding: 0 8px; + font-size: 0.8em; + line-height: 1.25; +} +.ui_badge_small .ui_svg_icon { width: 1em; height: 1em; } +/* Rounded ends, only when a caller asks for them. Widgets are drawn + * square, apart from the toggle and the badge dot, which are round */ +.ui_badge_rounded { border-radius: 999px; } .ui_chip { display: inline-flex; align-items: center; @@ -564,16 +575,17 @@ a.ui_stat:hover .ui_stat_value { color: var(--ui-accent); } padding-top: 1px; } .ui_list_meta { color: var(--ui-fg-muted); font-size: 0.9em; } -/* Badges in list rows are chip-sized, so they do not outweigh the text */ +/* Badges in list rows are small, so they do not outweigh the text */ .ui_list_item .ui_badge { - padding: 1px 8px; + padding: 0 8px; gap: 4px; - font-size: 0.86em; + font-size: 0.81em; } .ui_list_item .ui_badge .ui_svg_icon { width: 1em; height: 1em; } /* Description text is 0.93em, so badges and chips inside it are scaled * back by that much to match the ones next to the title */ -.ui_list_desc :is(.ui_badge, .ui_chip) { font-size: calc(0.86em / 0.93); } +.ui_list_desc .ui_badge { font-size: calc(0.81em / 0.93); } +.ui_list_desc .ui_chip { font-size: calc(0.86em / 0.93); } .ui_list_icon { padding-top: 2px; color: var(--ui-fg-muted); } a.ui_list_link, a.ui_list_link:hover { color: inherit !important; @@ -1163,8 +1175,6 @@ a.ui_list_link:hover { color: var(--ui-accent) !important; } font-style: normal; } .ui_multi_item:hover { background: var(--ui-surface-2); } -.ui_multi_level1 { padding-left: 27px; } -.ui_multi_level2 { padding-left: 47px; } .ui_multi_suffix { color: var(--ui-fg-muted); } .ui_multi_note { margin-left: 6px; @@ -1173,6 +1183,111 @@ a.ui_list_link:hover { color: var(--ui-accent) !important; } white-space: nowrap; } .ui_multi_side { margin-left: auto; padding-left: 8px; flex-shrink: 0; } +/* Metadata stays secondary to the label, with room between rows and badges. */ +.ui_multi_item:has(> .ui_multi_badges) { padding: 7px 10px; } +.ui_multi_badges { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: flex-end; + gap: 6px 8px; + flex-shrink: 1; + padding-left: 16px; +} +.ui_multi_badges .ui_badge { + padding: 3px 10px; + font-weight: 400; + line-height: 1.4; + border-color: color-mix(in srgb, currentColor 16%, transparent); +} +.ui_multi_badges .ui_badge_neutral { + background: color-mix(in srgb, var(--ui-neutral-soft) 35%, transparent); + color: var(--ui-fg-muted); +} +.ui_multi_badges .ui_badge_success { + background: color-mix(in srgb, var(--ui-success-soft) 45%, transparent); +} +.ui_multi_badges .ui_badge_warning { + background: color-mix(in srgb, var(--ui-warning-soft) 45%, transparent); +} +.ui_multi_badges .ui_badge_info { + background: color-mix(in srgb, var(--ui-info-soft) 45%, transparent); +} +/* Grouping and combined metadata are opt-in; ordinary selectors keep their layout. */ +.ui_multi_summary { + display: inline-flex; + align-items: center; + gap: 6px; + flex: 1 1 auto; + padding-right: 12px; + color: var(--ui-fg-muted); +} +.ui_multi_group { + display: flex; + align-items: center; + gap: 6px; + padding: 6px; + border-top: 1px solid var(--ui-border); + color: var(--ui-fg-muted); + font-size: 0.93em; + font-weight: 600; +} +.ui_multi_group:first-child { border-top: 0; } +.ui_multi_group_success > .ui_svg_icon { color: var(--ui-success); } +.ui_multi_group_warning > .ui_svg_icon { color: var(--ui-warning); } +.ui_multi_group_danger > .ui_svg_icon { color: var(--ui-danger); } +.ui_multi_group_info > .ui_svg_icon { color: var(--ui-info); } +.ui_multi_metadata.ui_badge { + gap: 8px; + padding: 1px 6px; + font-weight: 400; + line-height: 1.35; + color: var(--ui-fg-muted); + background: color-mix(in srgb, var(--ui-neutral-soft) 25%, transparent); + border-color: color-mix(in srgb, currentColor 15%, transparent); +} +.ui_multi_meta { + display: inline-flex; + align-items: center; + gap: 4px; + --ui-meta-color: var(--ui-fg-muted); + color: var(--ui-meta-color); +} +/* Theme table rules may override SVG color, so paint icons explicitly. */ +.ui_multi_meta > .ui_svg_icon { stroke: var(--ui-meta-color); } +.ui_multi_meta > .ui_svg_icon[fill="currentColor"] { fill: var(--ui-meta-color); } +/* Mixed metadata keeps each fact's color; the strongest state colors its border. */ +.ui_multi_meta_info, .ui_multi_metadata:has(> .ui_multi_meta_info) { + --ui-meta-color: var(--ui-info-text); + color: var(--ui-meta-color); +} +.ui_multi_meta_success, .ui_multi_metadata:has(> .ui_multi_meta_success) { + --ui-meta-color: var(--ui-success-text); + color: var(--ui-meta-color); +} +.ui_multi_meta_warning, .ui_multi_metadata:has(> .ui_multi_meta_warning) { + --ui-meta-color: var(--ui-warning-text); + color: var(--ui-meta-color); +} +.ui_multi_meta_danger, .ui_multi_metadata:has(> .ui_multi_meta_danger) { + --ui-meta-color: var(--ui-danger-text); + color: var(--ui-meta-color); +} +.ui_multi_metadata:has(> .ui_multi_meta:not(.ui_multi_meta_neutral)) { + border-color: color-mix(in srgb, currentColor 40%, transparent); +} +.ui_multi_compact .ui_multi_item { padding: 2px 6px; min-height: 24px; } +.ui_multi_compact .ui_multi_item:has(input:checked) { + background: color-mix(in srgb, var(--ui-accent) 10%, var(--ui-surface)); +} +@media (max-width: 600px) { + .ui_multi_compact .ui_multi_item { flex-wrap: wrap; } + .ui_multi_compact .ui_multi_side { max-width: 100%; } + .ui_multi_compact .ui_multi_metadata { flex-wrap: wrap; justify-content: flex-end; } +} +/* Preserve child indentation when compact or badge styles set row padding. */ +.ui_multi_item.ui_multi_level1 { padding-left: 27px; } +.ui_multi_item.ui_multi_level2 { padding-left: 47px; } .ui_multi_disabled { opacity: 0.6; } .ui_multi_empty { padding: 1px 2px 2px 2px; diff --git a/unauthenticated/js/ui-lib.js b/unauthenticated/js/ui-lib.js index c82d22bdd..20f357434 100644 --- a/unauthenticated/js/ui-lib.js +++ b/unauthenticated/js/ui-lib.js @@ -140,6 +140,7 @@ var fold = box.querySelector('[data-ui-multi-action="children"]'); var folded = !!(fold && fold.checked); var matched = 0; + var shownGroups = new Set(); var chosen = []; var selectedCount = 0; box.querySelectorAll('.ui_multi_item').forEach(function (item) { @@ -157,9 +158,16 @@ if (input && input.checked) selectedCount++; var match = query === '' || (item.getAttribute('data-ui-multi-text') || '').toLowerCase().indexOf(query) >= 0; - if (match) matched++; + if (match) { + matched++; + shownGroups.add(item.getAttribute('data-ui-multi-group')); + } item.hidden = !match; }); + // Hide headings whose entries are all filtered or folded. + box.querySelectorAll('[data-ui-multi-heading]').forEach(function (heading) { + heading.hidden = !shownGroups.has(heading.getAttribute('data-ui-multi-heading')); + }); var empty = box.querySelector('.ui_multi_empty'); if (empty) empty.hidden = matched > 0; // Hide the count for empty selections or hidden lists.