diff --git a/mount/config.info b/mount/config.info index ac80a674a..10b6b21bc 100644 --- a/mount/config.info +++ b/mount/config.info @@ -6,6 +6,9 @@ sort_mode=Sort filesystems by,1,2-Mount point,1-Type,0-Order in files show_used=Show used disk space in filesystems list,1,1-Yes,0-No delete_under=Delete directory when un-mounting if under,3,Never delete sysinfo=Show available disk data on Dashboard,1,1-Yes,0-No +sysinfo_exclude_types=Filesystem types to exclude from Dashboard (comma or space separated),0 +sysinfo_exclude_mounts=Mount points to exclude from Dashboard (one per line),9,50,3,\t +sysinfo_exclude_mount_regex=Mount-point regular expression to exclude from Dashboard,15,dashboard_mount_regex line2=System configuration,11 fstab_file=File listing filesystems mounted at boot time,0 auto_file=NFS automounts file,3 diff --git a/mount/config_info.pl b/mount/config_info.pl new file mode 100644 index 000000000..95970fe15 --- /dev/null +++ b/mount/config_info.pl @@ -0,0 +1,18 @@ +require './mount-lib.pl'; + +sub show_dashboard_mount_regex +{ +my ($value) = @_; +return &ui_textbox('sysinfo_exclude_mount_regex', $value, 50); +} + +sub parse_dashboard_mount_regex +{ +my $value = $in{'sysinfo_exclude_mount_regex'} // ''; +if (length($value) && !defined(&dashboard_mount_regex($value))) { + &error($text{'config_edashboard_mount_regex'}); + } +return $value; +} + +1; diff --git a/mount/lang/en b/mount/lang/en index c492d84d9..0b9b5f654 100644 --- a/mount/lang/en +++ b/mount/lang/en @@ -507,4 +507,6 @@ sysinfo_dev=Device ID sysinfo_smalltmp=The filesystem $2 which contains the Webmin temp files directory $1 has a size of only $4, which is less than the recommended minimum of $3 for temporary and backup files. Consider switching the temp files location to a different directory in the Webmin Configuration module. sysinfo_ramtmp=The filesystem $2 which contains the Webmin temp files directory $1 is mounted from a tmpfs RAM disk, which may be too small for temporary and backup files. Consider switching the temp files location to a different directory in the Webmin Configuration module. +config_edashboard_mount_regex=Invalid mount-point regular expression for Dashboard exclusions. + __norefs=1 diff --git a/mount/mount-lib.pl b/mount/mount-lib.pl index 4f76c8eba..19fd4eb2e 100755 --- a/mount/mount-lib.pl +++ b/mount/mount-lib.pl @@ -405,5 +405,73 @@ foreach my $m (@mounted) { return ($total, $free, \@fs, $used); } -1; +# dashboard_mount_path(path) +# Normalize trailing slashes only, without resolving the mount namespace. +sub dashboard_mount_path +{ +my ($path) = @_; +$path =~ s{(?<=.)/+$}{}; +return $path; +} +# dashboard_mount_regex(expression) +# Compile an optional regex defensively, including manually edited config. +sub dashboard_mount_regex +{ +my ($expression) = @_; +return undef if (!defined($expression) || !length($expression)); +local $@; +my $regex = eval { qr/$expression/ }; +return $@ ? undef : $regex; +} + +# filter_dashboard_filesystems(&filesystems) +# Return a new list of Dashboard-visible filesystems. Mount lists use tabs +# (one path per line in config.info), preserving spaces and commas in paths. +sub filter_dashboard_filesystems +{ +my ($filesystems) = @_; +my %types = map { lc($_), 1 } + grep { length($_) } + split(/[\s,]+/, $config{'sysinfo_exclude_types'} // ''); +my %mounts; +foreach my $path (split(/[\t\r\n]+/, $config{'sysinfo_exclude_mounts'} // '')) { + $path =~ s/^\s+|\s+$//g; + $mounts{&dashboard_mount_path($path)} = 1 if (length($path)); + } +my $regex = &dashboard_mount_regex($config{'sysinfo_exclude_mount_regex'}); +my @visible = grep { + !$types{lc($_->{'type'})} && + !$mounts{&dashboard_mount_path($_->{'dir'})} && + !($regex && $_->{'dir'} =~ $regex) + } @$filesystems; +return \@visible; +} + +# filter_dashboard_disk_space(total, free, &filesystems, used) +# Filter live or cached disk data and recalculate byte totals only when a +# filesystem was excluded, preserving the original values otherwise. +sub filter_dashboard_disk_space +{ +my ($total, $free, $filesystems, $used) = @_; +my $visible = &filter_dashboard_filesystems($filesystems); +if (@$visible != @$filesystems) { + ($total, $free, $used) = (0, 0, 0); + foreach my $fs (@$visible) { + $total += $fs->{'total'} // 0; + $free += $fs->{'free'} // 0; + $used += $fs->{'used'} // + (($fs->{'total'} // 0) - ($fs->{'free'} // 0)); + } + } +return ($total, $free, $visible, $used); +} + +# dashboard_disk_space([&always-count]) +# Dashboard-only wrapper; other local_disk_space callers remain unaffected. +sub dashboard_disk_space +{ +return &filter_dashboard_disk_space(&local_disk_space(@_)); +} + +1; diff --git a/mount/system_info.pl b/mount/system_info.pl index a5b30f152..ca74b1e97 100644 --- a/mount/system_info.pl +++ b/mount/system_info.pl @@ -7,7 +7,7 @@ my $can = &foreign_available($module_name) && $access{'sysinfo'}; if ((length($config{'sysinfo'}) && !$config{'sysinfo'}) || !$can) { return (); } -my (undef, undef, $disks, undef) = &local_disk_space(); +my (undef, undef, $disks, undef) = &dashboard_disk_space(); if (!@$disks) { return (); } diff --git a/mount/t/dashboard-filesystem-filter.t b/mount/t/dashboard-filesystem-filter.t new file mode 100644 index 000000000..6008970f8 --- /dev/null +++ b/mount/t/dashboard-filesystem-filter.t @@ -0,0 +1,235 @@ +#!/usr/bin/perl +use strict; +use warnings; +no warnings 'once'; +use Test::More; +use Cwd qw(abs_path); +use File::Basename qw(dirname); +use Storable qw(dclone); +use File::Temp qw(tempdir); + +my $root = abs_path(dirname(__FILE__)."/../..") or die "rootdir: $!"; +unshift(@INC, "$root/mount"); + +# Load the real helpers and presenters without a Webmin installation or +# mounted test devices. Only discovery, access and HTML primitives are mocked. +BEGIN { $INC{'WebminCore.pm'} = 1; } +sub WebminCore::import { } +sub mount::init_config { } +sub mount::get_module_acl { return (); } +{ +package mount; +do "$root/mount/mount-lib.pl" or die "mount-lib.pl: $@ $!"; +} +$INC{'./mount-lib.pl'} = 1; +{ +package mount; +do "$root/mount/config_info.pl" or die "config_info.pl: $@ $!"; +} + +my $filesystems = [ + { dir => '/', type => 'ext4', device => '/dev/test-root', + total => 1000, free => 400, used => 550, itotal => 100, ifree => 40 }, + { dir => '/mnt/iso', type => 'udf', device => '/dev/loop-test', + total => 200, free => 0, used => 200, itotal => 100, ifree => 0 }, + { dir => '/mnt/iso-backup', type => 'ext4', device => '/dev/test-backup', + total => 300, free => 100, used => 180 }, + { dir => '/mnt/iso/debian', type => 'iso9660', device => '/dev/loop-test2', + total => 100, free => 0, used => 100, itotal => 100, ifree => 0 }, + { dir => '/mnt/a path,with comma', type => 'xfs', device => '/dev/test-data', + total => 500, free => 200 }, +]; +my $original = dclone($filesystems); +sub visible_paths +{ +return [ map { $_->{'dir'} } @{mount::filter_dashboard_filesystems($filesystems)} ]; +} +my @paths = map { $_->{'dir'} } @$filesystems; +%mount::config = (); +is_deeply(visible_paths(), \@paths, 'empty configuration preserves every filesystem'); +is_deeply([ mount::filter_dashboard_disk_space(2100, 700, $filesystems, undef) ], + [ 2100, 700, $filesystems, undef ], 'unchanged list preserves raw aggregate semantics'); + +$mount::config{'sysinfo_exclude_types'} = ' UDF, udf, , ISO9660 '; +is_deeply(visible_paths(), [ @paths[0,2,4] ], 'types normalize case, whitespace and duplicates'); +$mount::config{'sysinfo_exclude_types'} = 'ud'; +is_deeply(visible_paths(), \@paths, 'type matching is exact'); +%mount::config = (sysinfo_exclude_mounts => " /mnt/iso/// \t/mnt/a path,with comma\n"); +is_deeply(visible_paths(), [ @paths[0,2,3] ], 'exact paths preserve spaces and commas, trim trailing slash'); +$mount::config{'sysinfo_exclude_mounts'} = '/'; +is_deeply(visible_paths(), [ @paths[1..4] ], 'root path remains matchable'); +$mount::config{'sysinfo_exclude_mounts'} = '/mnt/ISO'; +is_deeply(visible_paths(), \@paths, 'mount path matching is case sensitive'); +%mount::config = (sysinfo_exclude_mount_regex => '^/mnt/iso(?:/|$)'); +is_deeply(visible_paths(), [ @paths[0,2,4] ], 'regex excludes children but not similarly named siblings'); +$mount::config{'sysinfo_exclude_mount_regex'} = '['; +is_deeply(visible_paths(), \@paths, 'invalid stored regex is ignored safely'); +$mount::config{'sysinfo_exclude_mount_regex'} = '(?{ die "executed" })'; +is_deeply(visible_paths(), \@paths, 'regex cannot execute embedded Perl code'); +%mount::config = (sysinfo_exclude_types => 'udf', + sysinfo_exclude_mounts => '/mnt/iso-backup', + sysinfo_exclude_mount_regex => 'debian$'); +is_deeply(visible_paths(), [ @paths[0,4] ], 'combined rules use logical OR'); +is_deeply([ mount::filter_dashboard_disk_space(2100, 700, $filesystems, 1330) ], + [ 1500, 600, [ @$filesystems[0,4] ], 850 ], + 'filtered totals use bytes, reported used values and fallback for missing used'); +$mount::config{'sysinfo_exclude_mount_regex'} = '.*'; +is_deeply([ mount::filter_dashboard_disk_space(2100, 700, $filesystems, 1330) ], + [ 0, 0, [], 0 ], 'excluding every filesystem produces empty list and zero totals'); +is_deeply($filesystems, $original, 'filtering never changes input hashes'); + +# Exercise the actual config.info parser, including multiline storage and +# rejection before config is written, rather than calling validators alone. +sub mount::error { die $_[0]; } +$mount::text{'config_edashboard_mount_regex'} = 'Invalid Dashboard regex'; +sub read_file +{ +my ($file, $hash, $order) = @_; +open(my $fh, '<', $file) or return 0; +while (<$fh>) { + chomp; + if (/^([^=#]+)=(.*)$/) { $hash->{$1} = $2; push(@$order, $1) if ($order); } + } +return 1; +} +sub foreign_require { } +sub foreign_exists { return 0; } +sub unique { my %seen; return grep { !$seen{$_}++ } @_; } +sub foreign_call +{ +my ($module, $fn, @args) = @_; +no strict 'refs'; +local %mount::in = %main::in; +return &{"${module}::$fn"}(@args); +} +do "$root/config-lib.pl" or die "config-lib.pl: $@ $!"; +our %in = (sysinfo_exclude_types => 'udf', + sysinfo_exclude_mounts => "/mnt/iso\r\n/mnt/a path,with comma", + sysinfo_exclude_mount_regex => '^/mnt/iso(?:/|$)'); +my %saved; +parse_config(\%saved, "$root/mount/config.info", 'mount'); +is($saved{'sysinfo_exclude_mounts'}, "/mnt/iso\t/mnt/a path,with comma", + 'standard multiline widget saves paths with tab separators'); +is($saved{'sysinfo_exclude_mount_regex'}, $in{'sysinfo_exclude_mount_regex'}, + 'configuration parser persists a validated regex'); +$in{'sysinfo_exclude_mount_regex'} = '['; +eval { parse_config(\%saved, "$root/mount/config.info", 'mount'); }; +like($@, qr/Invalid Dashboard regex/, 'configuration parser rejects invalid regex'); +$in{'sysinfo_exclude_mount_regex'} = ''; +eval { parse_config(\%saved, "$root/mount/config.info", 'mount'); }; +is($@, '', 'empty regex can be saved'); + +# The cached raw record is deliberately shared between successive renders. +my $cached = { disk_total => 2100, disk_free => 700, disk_used => 1330, + disk_fs => $filesystems }; +my $cache_original = dclone($cached); +$INC{'system-status-lib.pl'} = 1; +sub status::get_collected_info { return $cached; } +sub status::foreign_require { } +sub status::foreign_available { return 0; } +sub status::get_module_acl { return (show => 'disk'); } +sub status::indexof { my ($v, @a) = @_; for (0..$#a) { return $_ if $a[$_] eq $v; } return -1; } +sub status::nice_size { return $_[0]; } +sub status::text { return join(' ', @_); } +{ +package status; +do "$root/system-status/system_info.pl" or die "system_info.pl: $@ $!"; +} +$status::text{'right_disk'} = 'Local disk space'; +%mount::config = (); +my @unfiltered = status::list_system_info(); +is(scalar(grep { $_->{'type'} eq 'warning' } @unfiltered), 2, + 'UDF emits block and inode warnings, ISO9660 retains existing suppression'); +%mount::config = (sysinfo_exclude_types => 'udf'); +my @filtered = status::list_system_info(); +is(scalar(grep { $_->{'type'} eq 'warning' } @filtered), 0, + 'config change immediately removes block and inode warnings from cached data'); +is_deeply($filtered[0]->{'table'}->[0]->{'chart'}, [1900, 1130], + 'aggregate chart is recalculated from cached visible filesystems'); +is_deeply($filtered[0]->{'raw'}->[0]->{'disk_fs'}, [ @$filesystems[0,2,3,4] ], + 'Dashboard raw presentation data is also filtered'); +is_deeply($cached, $cache_original, 'presentation does not mutate cached record'); +%mount::config = (); +my @restored = status::list_system_info(); +is_deeply(\@restored, \@unfiltered, 'removing exclusions restores cached data without recollection'); + +# Non-excluded writable filesystems must still report both space and inode +# exhaustion, including the nearly-full thresholds. +foreach my $remaining (0, 1) { + my $full = { dir => '/data', type => 'ext4', total => 1000, + free => $remaining, itotal => 1000, ifree => $remaining }; + local $cached->{'disk_fs'} = [ $full, $filesystems->[1] ]; + local %mount::config = (sysinfo_exclude_types => 'udf'); + my @warnings = grep { $_->{'type'} eq 'warning' } status::list_system_info(); + is(scalar(@warnings), 2, "unexcluded ext4 with $remaining free blocks/inodes still warns"); + is_deeply([ map { $_->{'level'} } @warnings ], + [ ($remaining ? 'warn' : 'danger') x 2 ], + 'original block and inode warning thresholds are preserved'); +} + +# Run the unchanged raw enumeration with synthetic device/size discovery. +my $discovery_root = tempdir(CLEANUP => 1, TMPDIR => 1); +sub mount::has_command { return undef; } +sub mount::list_mounted +{ +return map { [ $discovery_root.$_->{'dir'}, $_->{'device'}, $_->{'type'}, 'ro' ] } @$filesystems; +} +sub mount::disk_space +{ +my ($type, $dir) = @_; +$dir =~ s/^\Q$discovery_root\E//; +my ($fs) = grep { $_->{'dir'} eq $dir } @$filesystems; +return ($fs->{'total'}/1024, $fs->{'free'}/1024, + ($fs->{'used'} // $fs->{'total'}-$fs->{'free'})/1024); +} +sub mount::inode_space +{ +my ($type, $dir) = @_; +$dir =~ s/^\Q$discovery_root\E//; +my ($fs) = grep { $_->{'dir'} eq $dir } @$filesystems; +return ($fs->{'itotal'}, $fs->{'ifree'}); +} +sub mount::indexof { return status::indexof(@_); } +%mount::config = (sysinfo_exclude_types => 'udf iso9660'); +my @raw_space = mount::local_disk_space(); +is_deeply([ @raw_space[0,1,3] ], [2100, 700, 1330], + 'raw local_disk_space totals are unaffected by Dashboard exclusions'); +is_deeply([ map { $_->{'dir'} } @{$raw_space[2]} ], + [ map { $discovery_root.$_ } @paths ], + 'raw enumeration keeps read-only images and normal filesystems'); +my @dashboard_space = mount::dashboard_disk_space(); +is_deeply([ @dashboard_space[0,1,3] ], [1800, 700, 1030], + 'Dashboard wrapper filters the real raw enumerator output'); + +# Render the mount Dashboard table with synthetic local disk data. +sub mount::foreign_available { return $_[0] eq 'mount'; } +sub mount::load_theme_library { } +sub mount::ui_columns_start { return ''; } +sub mount::ui_columns_end { return ''; } +sub mount::ui_columns_row { return join('|', @{$_[0]})."\n"; } +sub mount::ui_text_color { return "$_[1]:$_[0]"; } +sub mount::nice_size { return $_[0]; } +{ +package mount; +do "$root/mount/system_info.pl" or die "mount/system_info.pl: $@ $!"; +} +{ +no warnings 'redefine'; +*mount::local_disk_space = sub { return (2100, 700, $filesystems, 1330); }; +} +$mount::module_name = 'mount'; +$mount::access{'sysinfo'} = 1; +%mount::config = (); +my @table = mount::list_system_info(); +is($table[0]->{'open'}, 1, 'unfiltered full images open the disk table'); +like($table[0]->{'html'}, qr/danger:0%/, 'unfiltered images receive danger styling'); +%mount::config = (sysinfo_exclude_types => 'udf iso9660'); +@table = mount::list_system_info(); +is($table[0]->{'open'}, 0, 'excluded images do not force the disk table open'); +unlike($table[0]->{'html'}, qr{/mnt/iso\||/mnt/iso/debian|danger:}, + 'excluded images and their danger cells are absent'); +like($table[0]->{'html'}, qr{/mnt/iso-backup}, 'unexcluded sibling remains in table'); +$mount::config{'sysinfo_exclude_mount_regex'} = '.*'; +is_deeply([ mount::list_system_info() ], [], 'all excluded means no disk table'); + +done_testing(); diff --git a/system-status/system_info.pl b/system-status/system_info.pl index c4ceebdda..47bcc1bff 100644 --- a/system-status/system_info.pl +++ b/system-status/system_info.pl @@ -12,6 +12,16 @@ sub list_system_info { my ($data, $in, $modskip) = @_; my $info = &get_collected_info(undef, $modskip); +# Filter a presentation copy so cached collection data and history stay raw. +if ($info->{'disk_fs'}) { + &foreign_require("mount"); + $info = { %$info }; + ($info->{'disk_total'}, $info->{'disk_free'}, + $info->{'disk_fs'}, $info->{'disk_used'}) = + &mount::filter_dashboard_disk_space( + $info->{'disk_total'}, $info->{'disk_free'}, + $info->{'disk_fs'}, $info->{'disk_used'}); + } my @rv; my @table; my @raw = $info;