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;