Merge branch 'master' of github.com:webmin/webmin

This commit is contained in:
Jamie Cameron
2026-09-27 08:45:05 -07:00
25 changed files with 941 additions and 120 deletions

View File

@@ -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

View File

@@ -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 .= "<table id='show_backup_destination' cellpadding=1 cellspacing=0>";
# Local file field
$rv .= "<tr><td>".&ui_oneradio("$_[0]_mode", 0, undef, $mode == 0)."</td>\n";
$rv .= "<td>".&ui_tag('strong', $text{'backup_mode0'})."&nbsp;</td><td colspan='3'>".
&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")."</td> </tr>\n";
# FTP file fields
$rv .= "<tr><td>".&ui_oneradio("$_[0]_mode", 1, undef, $mode == 1)."</td>\n";
$rv .= "<td>".&ui_tag('strong', $text{'backup_mode1'})."&nbsp;</td><td>".
&ui_textbox("$_[0]_server", $mode == 1 ? $server : undef, 20).
"</td>\n";
$rv .= "<td>&nbsp;$text{'backup_path'}&nbsp;</td><td> ".
&ui_textbox("$_[0]_path", $mode == 1 ? $path : undef, 20).
"</td> </tr>\n";
$rv .= "<tr> <td></td>\n";
$rv .= "<td>$text{'backup_login'}&nbsp;</td><td> ".
&ui_textbox("$_[0]_user", $mode == 1 ? $user : undef, 20).
"</td>\n";
$rv .= "<td>&nbsp;$text{'backup_pass'}&nbsp;</td><td> ".
&ui_password("$_[0]_pass", $mode == 1 ? $pass : undef, 20).
"</td> </tr>\n";
$rv .= "<tr> <td></td>\n";
$rv .= "<td colspan='4'>$text{'backup_port'} ".
&ui_opt_textbox("$_[0]_port", $mode == 1 ? $port : undef, 5,
$text{'default'})."</td> </tr>\n";
# SCP file fields
$rv .= "<tr><td>".&ui_oneradio("$_[0]_mode", 2, undef, $mode == 2)."</td>\n";
$rv .= "<td>".&ui_tag('strong', $text{'backup_mode2'})."&nbsp;</td><td>".
&ui_textbox("$_[0]_sserver", $mode == 2 ? $server : undef, 20).
"</td>\n";
$rv .= "<td>&nbsp;$text{'backup_path'}&nbsp;</td><td> ".
&ui_textbox("$_[0]_spath", $mode == 2 ? $path : undef, 20).
"</td> </tr>\n";
$rv .= "<tr> <td></td>\n";
$rv .= "<td>$text{'backup_login'}&nbsp;</td><td> ".
&ui_textbox("$_[0]_suser", $mode == 2 ? $user : undef, 20).
"</td>\n";
$rv .= "<td>&nbsp;$text{'backup_pass'}&nbsp;</td><td> ".
&ui_password("$_[0]_spass", $mode == 2 ? $pass : undef, 20).
"</td> </tr>\n";
$rv .= "<tr> <td></td>\n";
$rv .= "<td colspan='4'>$text{'backup_port'} ".
&ui_opt_textbox("$_[0]_sport", $mode == 2 ? $port : undef, 5,
$text{'default'})."</td> </tr>\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 .= "<tr><td>".&ui_oneradio("$_[0]_mode", 3, undef, $mode == 3).
"</td>\n";
$rv .= "<td colspan=4>".&ui_tag('strong', $text{'backup_mode3'})." ".
&ui_upload("$_[0]_upload", 40).
"</td> </tr>\n";
push(@options, { 'value' => 3, 'label' => $text{'backup_mode3'},
'content' => &ui_upload("$_[0]_upload", 40) });
}
elsif ($_[2] == 2) {
# Output to browser option
$rv .= "<tr><td>".&ui_oneradio("$_[0]_mode", 4, undef, $mode == 4).
"</td>\n";
$rv .= "<td colspan=4>".&ui_tag('strong', $text{'backup_mode4'}).
"</td> </tr>\n";
push(@options, { 'value' => 4, 'label' => $text{'backup_mode4'} });
}
$rv .= "</table>\n";
return $rv;
return &ui_select_switch("$_[0]_mode", $mode, \@options);
}
=head2 parse_backup_destination(name, &in)

View File

@@ -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'});

View File

@@ -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'});

View File

@@ -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);
}

View File

@@ -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);
}
}

View File

@@ -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();

View File

@@ -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'} &&

View File

@@ -51,6 +51,16 @@ print &ui_hidden("mode", $in{'mode'});
print &ui_grid_table(\@grid, 2),"<p>\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);

View File

@@ -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

View File

@@ -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 = ( );

View File

@@ -62,7 +62,16 @@ elsif ($in{'refresh'} || $in{'refresh_top'}) {
# Force re-fetch
print $text{'refresh_available'},"<br>\n";
@avail = &list_possible_updates();
print &text('refresh_done3', scalar(@avail)),"<p>\n";
$err = &get_updates_error();
if ($err) {
# The check failed, so show the error instead of the count
print $text{'refresh_failed'},"<br>\n";
print "<pre>",&html_escape($err),"</pre>\n";
}
else {
# The check worked, so show how many updates were found
print &text('refresh_done3', scalar(@avail)),"<p>\n";
}
&webmin_log("refresh");
&ui_print_footer($redir, $redirdesc);

View File

@@ -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(

View File

@@ -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(<PKG>) {

View File

@@ -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');
}
{

View File

@@ -60,6 +60,24 @@ sub decode_attr {
my $xss = q{x"><script>alert(1)</script><b onmouseover="alert(1)};
# A decoded value elsewhere on the page must not double-encode the note icon.
{
my $decoded = 'efi';
utf8::upgrade($decoded);
foreach my $prefix ('efi', $decoded) {
my $html = $prefix.main::ui_note('Note', 0);
# Model the UTF-8 response bytes and the browser's decoding step.
utf8::encode($html) if (utf8::is_utf8($html));
utf8::decode($html);
like($html, qr/(?:\x{24d8}|&#9432;|&#x24d8;)/i,
'ui_note icon survives byte and decoded page strings');
}
my $html = main::ui_note("Note \x{20ac}", 0);
like($html, qr/(?:\x{24d8}|&#9432;|&#x24d8;)/i,
'ui_note icon survives decoded note text');
like($html, qr/Note \x{20ac}/, 'ui_note preserves decoded note text');
}
# ---- escaping contract -----------------------------------------------------
assert_no_handler_injection(
@@ -71,6 +89,9 @@ assert_no_handler_injection(
'ui_card title+desc');
assert_no_handler_injection(main::ui_badge($xss, 'success'),
'ui_badge text');
assert_no_handler_injection(
main::ui_badge('B', 'success', { 'class' => $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('<b>x</b>', $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 => '<b>Linux</b>', 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</, 'plain tags still work beside badges');
my ($filter) = $html =~ /data-ui-multi-text="([^"]*)"/;
is(decode_attr($filter), 'Linux QCOW2 ARM64 '.$xss,
'filtering includes every badge label as plain text');
like($html, qr/value="linux"[^>]*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 &amp; <tip> "quoted"';
my $html = main::ui_multi_select_list('literal_metadata', [],
[{ value => 'a', label => 'First', metadata => [{ label => 'Details', title => $title }] }], {});
my ($tooltip) = $html =~ /<span (?=[^>]*\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.
{

View File

@@ -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 &amp; <tip> "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();
}

View File

@@ -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 '<!doctype html><html><head><meta charset="utf-8">';
print '<link rel="stylesheet" href="file://'.$root_directory.'/unauthenticated/css/ui-lib.css">';
print '<script defer src="file://'.$root_directory.'/unauthenticated/js/ui-lib.js"></script>';
print '<style>html{font:14px Arial,sans-serif}body{margin:0;padding:16px;background:var(--ui-surface);color:var(--ui-fg)}section{margin-bottom:20px}h2{font-size:16px}label{font-weight:400}input[type=checkbox]{accent-color:#2689df}</style></head><body>';
# 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 '<form id="checks"><section>';
print ui_multi_select_list('grouped', ['image-0'], \@options,
{ compact => 1, search => 1, summary => 'Images', height => '420px' });
print '</section><section>';
# 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 '</section><section>';
# 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 '</section><section>';
# 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 &amp; <tip> "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 '</section><button type="reset">Reset</button></form>';
# 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 '<section id="cloudmin" class="ui_page"><h2>Add images</h2>';
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 '</section>';
}
print '</body></html>';

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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);

View File

@@ -3756,8 +3756,10 @@ return &theme_ui_note(@_) if (defined(&theme_ui_note));
my ($text, $whitespace) = @_;
$whitespace //= 2;
my $whitespace_str = "&nbsp;" 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 "<font class='ui_note' style='font-size:92%;opacity:0.66'>".
"${whitespace_str}ⓘ&nbsp;&nbsp;$text".
"${whitespace_str}&#9432;&nbsp;&nbsp;$text".
"</font>";
}
@@ -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'} ?

View File

@@ -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;

View File

@@ -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.