Merge pull request #2870 from ste94pz/feature/dashboard-filesystem-filters

Add filesystem exclusion filters for dashboard disk usage
This commit is contained in:
Jamie Cameron
2026-10-07 18:32:34 -07:00
committed by GitHub
7 changed files with 338 additions and 2 deletions

View File

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

18
mount/config_info.pl Normal file
View File

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

View File

@@ -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 <a href='$5'>Webmin Configuration</a> module.
sysinfo_ramtmp=The filesystem $2 which contains the Webmin temp files directory $1 is mounted from a <tt>tmpfs</tt> RAM disk, which may be too small for temporary and backup files. Consider switching the temp files location to a different directory in the <a href='$3'>Webmin Configuration</a> module.
config_edashboard_mount_regex=Invalid mount-point regular expression for Dashboard exclusions.
__norefs=1

View File

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

View File

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

View File

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

View File

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