Add disk usage and schedule columns to WHM remotes list
Enhance the remotes table with AJAX-loaded disk usage info (SSH via df, S3/GDrive via rclone about) and server-side schedule assignment counts with tooltip showing schedule names. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,8 @@ use lib '/usr/local/cpanel/whostmgr/docroot/cgi/gniza-whm/lib';
|
||||
use Whostmgr::HTMLInterface ();
|
||||
use Cpanel::Form ();
|
||||
use File::Copy ();
|
||||
use IPC::Open3 ();
|
||||
use Symbol qw(gensym);
|
||||
use GnizaWHM::Config;
|
||||
use GnizaWHM::Validator;
|
||||
use GnizaWHM::UI;
|
||||
@@ -17,11 +19,12 @@ my $method = $ENV{'REQUEST_METHOD'} // 'GET';
|
||||
my $action = $form->{'action'} // 'list';
|
||||
|
||||
# Route to handler
|
||||
if ($action eq 'test') { handle_test_connection() }
|
||||
elsif ($action eq 'add') { handle_add() }
|
||||
elsif ($action eq 'edit') { handle_edit() }
|
||||
elsif ($action eq 'delete') { handle_delete() }
|
||||
else { handle_list() }
|
||||
if ($action eq 'test') { handle_test_connection() }
|
||||
elsif ($action eq 'disk_info') { handle_disk_info() }
|
||||
elsif ($action eq 'add') { handle_add() }
|
||||
elsif ($action eq 'edit') { handle_edit() }
|
||||
elsif ($action eq 'delete') { handle_delete() }
|
||||
else { handle_list() }
|
||||
|
||||
exit;
|
||||
|
||||
@@ -146,11 +149,27 @@ sub handle_list {
|
||||
|
||||
my @remotes = GnizaWHM::UI::list_remotes();
|
||||
|
||||
# Build schedule map: remote_name => [schedule_names]
|
||||
my %schedule_map;
|
||||
for my $sname (GnizaWHM::UI::list_schedules()) {
|
||||
my $scfg = GnizaWHM::Config::parse(GnizaWHM::UI::schedule_conf_path($sname), 'schedule');
|
||||
my $remotes_str = $scfg->{REMOTES} // '';
|
||||
if ($remotes_str eq '') {
|
||||
# Empty REMOTES = targets ALL remotes
|
||||
for my $r (@remotes) { push @{$schedule_map{$r}}, $sname; }
|
||||
} else {
|
||||
for my $r (split /,/, $remotes_str) {
|
||||
$r =~ s/^\s+|\s+$//g;
|
||||
push @{$schedule_map{$r}}, $sname if $r ne '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
print qq{<div class="card bg-white shadow-sm border border-base-300 mb-6">\n<div class="card-body">\n};
|
||||
|
||||
if (@remotes) {
|
||||
print qq{<div class="overflow-x-auto rounded-box border border-base-content/5 bg-base-100"><table class="table">\n};
|
||||
print qq{<thead><tr><th>Name</th><th>Type</th><th>Destination</th><th>Retention</th><th>Actions</th></tr></thead>\n};
|
||||
print qq{<thead><tr><th>Name</th><th>Type</th><th>Destination</th><th>Disk Usage</th><th>Schedules</th><th>Retention</th><th>Actions</th></tr></thead>\n};
|
||||
print qq{<tbody>\n};
|
||||
for my $name (@remotes) {
|
||||
my $conf = GnizaWHM::Config::parse(GnizaWHM::UI::remote_conf_path($name), 'remote');
|
||||
@@ -175,10 +194,24 @@ sub handle_list {
|
||||
$dest = "$host:$port";
|
||||
}
|
||||
|
||||
# Schedule count + tooltip
|
||||
my @sched_names = @{$schedule_map{$name} // []};
|
||||
my $sched_count = scalar @sched_names;
|
||||
my $sched_html;
|
||||
if ($sched_count > 0) {
|
||||
my $sched_tip = GnizaWHM::UI::esc(join(', ', @sched_names));
|
||||
$sched_html = qq{<span class="badge badge-sm badge-info tooltip tooltip-top" data-tip="$sched_tip">$sched_count</span>};
|
||||
} else {
|
||||
$sched_html = qq{<span class="text-base-content/60">0</span>};
|
||||
}
|
||||
|
||||
print qq{<tr class="hover">};
|
||||
print qq{<td><strong>$esc_name</strong></td>};
|
||||
print qq{<td><span class="badge badge-sm">$type_label</span></td>};
|
||||
print qq{<td>$dest</td><td>$retention</td>};
|
||||
print qq{<td>$dest</td>};
|
||||
print qq{<td data-remote-disk="$esc_name"><span class="loading loading-spinner loading-xs"></span></td>};
|
||||
print qq{<td>$sched_html</td>};
|
||||
print qq{<td>$retention</td>};
|
||||
print qq{<td>};
|
||||
print qq{<div class="flex items-center gap-2">};
|
||||
print qq{<button type="button" class="btn btn-primary btn-sm" onclick="location.href='remotes.cgi?action=edit&name=$esc_name'">Edit</button>};
|
||||
@@ -193,6 +226,35 @@ sub handle_list {
|
||||
print qq{</tr>\n};
|
||||
}
|
||||
print qq{</tbody>\n</table></div>\n};
|
||||
|
||||
# JavaScript: fetch disk info for each remote via safe DOM methods
|
||||
print <<'JS';
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var cells = document.querySelectorAll('td[data-remote-disk]');
|
||||
cells.forEach(function(td) {
|
||||
var name = td.getAttribute('data-remote-disk');
|
||||
fetch('remotes.cgi?action=disk_info&name=' + encodeURIComponent(name))
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
td.textContent = '';
|
||||
var span = document.createElement('span');
|
||||
span.className = 'text-xs';
|
||||
if (!data.ok) { span.className += ' text-base-content/60'; }
|
||||
span.textContent = data.disk || '\u2014';
|
||||
td.appendChild(span);
|
||||
})
|
||||
.catch(function() {
|
||||
td.textContent = '';
|
||||
var span = document.createElement('span');
|
||||
span.className = 'text-xs text-base-content/60';
|
||||
span.textContent = '\u2014';
|
||||
td.appendChild(span);
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
JS
|
||||
} else {
|
||||
print qq{<p>No remote destinations configured. Add a remote to enable multi-remote backups.</p>\n};
|
||||
print qq{<p class="text-xs text-base-content/60 mt-2">Remote configs are stored in <code>/etc/gniza/remotes.d/</code>.</p>\n};
|
||||
@@ -208,6 +270,216 @@ sub handle_list {
|
||||
Whostmgr::HTMLInterface::footer();
|
||||
}
|
||||
|
||||
# ── Disk Info (JSON, GET) ────────────────────────────────────
|
||||
|
||||
sub handle_disk_info {
|
||||
print "Content-Type: application/json\r\n\r\n";
|
||||
|
||||
my $name = $form->{'name'} // '';
|
||||
my $name_err = GnizaWHM::Validator::validate_remote_name($name);
|
||||
if ($name_err) {
|
||||
print qq({"ok":false,"disk":"Invalid remote name"});
|
||||
exit;
|
||||
}
|
||||
|
||||
my $conf_path = GnizaWHM::UI::remote_conf_path($name);
|
||||
unless (-f $conf_path) {
|
||||
print qq({"ok":false,"disk":"Remote not found"});
|
||||
exit;
|
||||
}
|
||||
|
||||
my $conf = GnizaWHM::Config::parse($conf_path, 'remote');
|
||||
my $type = $conf->{REMOTE_TYPE} // 'ssh';
|
||||
my $base = $conf->{REMOTE_BASE} // '/backups';
|
||||
|
||||
my $disk_str = eval { _get_disk_info($conf, $type, $base) };
|
||||
if ($@ || !defined $disk_str) {
|
||||
my $err = $@ || 'Unknown error';
|
||||
$err =~ s/[\r\n]/ /g;
|
||||
$err =~ s/"/\\"/g;
|
||||
print qq({"ok":false,"disk":"Connection failed"});
|
||||
exit;
|
||||
}
|
||||
|
||||
$disk_str =~ s/\\/\\\\/g;
|
||||
$disk_str =~ s/"/\\"/g;
|
||||
$disk_str =~ s/\n/\\n/g;
|
||||
$disk_str =~ s/\r/\\r/g;
|
||||
$disk_str =~ s/[\x00-\x1f]//g;
|
||||
print qq({"ok":true,"disk":"$disk_str"});
|
||||
exit;
|
||||
}
|
||||
|
||||
sub _get_disk_info {
|
||||
my ($conf, $type, $base) = @_;
|
||||
|
||||
if ($type eq 'ssh') {
|
||||
return _get_disk_info_ssh($conf, $base);
|
||||
} else {
|
||||
return _get_disk_info_rclone($conf, $type, $base);
|
||||
}
|
||||
}
|
||||
|
||||
sub _get_disk_info_ssh {
|
||||
my ($conf, $base) = @_;
|
||||
|
||||
my $host = $conf->{REMOTE_HOST} // '';
|
||||
my $port = $conf->{REMOTE_PORT} || '22';
|
||||
my $user = $conf->{REMOTE_USER} || 'root';
|
||||
my $auth_method = $conf->{REMOTE_AUTH_METHOD} || 'key';
|
||||
my $key = $conf->{REMOTE_KEY} // '';
|
||||
my $password = $conf->{REMOTE_PASSWORD} // '';
|
||||
|
||||
my @ssh_args = (
|
||||
'ssh', '-n',
|
||||
'-p', $port,
|
||||
'-o', 'StrictHostKeyChecking=accept-new',
|
||||
'-o', 'ConnectTimeout=10',
|
||||
'-o', 'BatchMode=yes',
|
||||
);
|
||||
|
||||
# Build df command — quote $base for remote shell
|
||||
(my $safe_base = $base) =~ s/'/'\\''/g;
|
||||
my $remote_cmd = "df -B1 '$safe_base' 2>/dev/null | tail -1";
|
||||
|
||||
my @cmd;
|
||||
if ($auth_method eq 'password') {
|
||||
# Remove BatchMode for password auth
|
||||
@ssh_args = grep { $_ ne 'BatchMode=yes' } @ssh_args;
|
||||
push @ssh_args, "$user\@$host", $remote_cmd;
|
||||
$ENV{SSHPASS} = $password;
|
||||
@cmd = ('sshpass', '-e', @ssh_args);
|
||||
} else {
|
||||
push @ssh_args, '-i', $key;
|
||||
push @ssh_args, "$user\@$host", $remote_cmd;
|
||||
@cmd = @ssh_args;
|
||||
}
|
||||
|
||||
my ($in, $out, $err_fh) = (undef, undef, gensym);
|
||||
my $pid = eval { IPC::Open3::open3($in, $out, $err_fh, @cmd) };
|
||||
delete $ENV{SSHPASS};
|
||||
return undef unless $pid;
|
||||
close $in if $in;
|
||||
|
||||
# Timeout: kill subprocess after 20 seconds
|
||||
local $SIG{ALRM} = sub { kill 'TERM', $pid; };
|
||||
alarm(20);
|
||||
|
||||
my $stdout = do { local $/; <$out> } // '';
|
||||
close $out;
|
||||
close $err_fh;
|
||||
waitpid($pid, 0);
|
||||
alarm(0);
|
||||
|
||||
return undef if ($? >> 8) != 0;
|
||||
|
||||
chomp $stdout;
|
||||
return undef if $stdout eq '';
|
||||
|
||||
# df -B1 output: Filesystem 1B-blocks Used Available Use% Mounted
|
||||
my @fields = split /\s+/, $stdout;
|
||||
return undef if @fields < 4;
|
||||
|
||||
my ($total, $used, $avail) = ($fields[1], $fields[2], $fields[3]);
|
||||
return undef unless $total =~ /^\d+$/ && $used =~ /^\d+$/ && $avail =~ /^\d+$/;
|
||||
|
||||
return _human_size($used) . ' used / ' . _human_size($total) . ' total (' . _human_size($avail) . ' free)';
|
||||
}
|
||||
|
||||
sub _get_disk_info_rclone {
|
||||
my ($conf, $type, $base) = @_;
|
||||
|
||||
# Build temp rclone config
|
||||
my $tmpfile = "/tmp/gniza-rclone-disk-$$.conf";
|
||||
my $conf_content = '';
|
||||
my $about_path = '';
|
||||
|
||||
if ($type eq 's3') {
|
||||
my $key_id = $conf->{S3_ACCESS_KEY_ID} // '';
|
||||
my $secret = $conf->{S3_SECRET_ACCESS_KEY} // '';
|
||||
my $region = $conf->{S3_REGION} || 'us-east-1';
|
||||
my $endpoint = $conf->{S3_ENDPOINT} // '';
|
||||
my $bucket = $conf->{S3_BUCKET} // '';
|
||||
|
||||
$conf_content = "[remote]\ntype = s3\nprovider = AWS\naccess_key_id = $key_id\nsecret_access_key = $secret\nregion = $region\n";
|
||||
$conf_content .= "endpoint = $endpoint\n" if $endpoint ne '';
|
||||
$about_path = "remote:$bucket$base";
|
||||
}
|
||||
elsif ($type eq 'gdrive') {
|
||||
my $sa_file = $conf->{GDRIVE_SERVICE_ACCOUNT_FILE} // '';
|
||||
my $folder_id = $conf->{GDRIVE_ROOT_FOLDER_ID} // '';
|
||||
|
||||
$conf_content = "[remote]\ntype = drive\nscope = drive\nservice_account_file = $sa_file\n";
|
||||
$conf_content .= "root_folder_id = $folder_id\n" if $folder_id ne '';
|
||||
$about_path = "remote:$base";
|
||||
}
|
||||
else {
|
||||
return undef;
|
||||
}
|
||||
|
||||
if (open my $fh, '>', $tmpfile) {
|
||||
print $fh $conf_content;
|
||||
close $fh;
|
||||
chmod 0600, $tmpfile;
|
||||
} else {
|
||||
return undef;
|
||||
}
|
||||
|
||||
my @cmd = ('rclone', '--config', $tmpfile, '--timeout', '15s', 'about', '--json', $about_path);
|
||||
my ($in, $out, $err_fh) = (undef, undef, gensym);
|
||||
my $pid = eval { IPC::Open3::open3($in, $out, $err_fh, @cmd) };
|
||||
unless ($pid) {
|
||||
unlink $tmpfile;
|
||||
return undef;
|
||||
}
|
||||
close $in if $in;
|
||||
|
||||
# Timeout: kill subprocess after 20 seconds
|
||||
local $SIG{ALRM} = sub { kill 'TERM', $pid; };
|
||||
alarm(20);
|
||||
|
||||
my $stdout = do { local $/; <$out> } // '';
|
||||
close $out;
|
||||
close $err_fh;
|
||||
waitpid($pid, 0);
|
||||
alarm(0);
|
||||
my $exit_code = $? >> 8;
|
||||
unlink $tmpfile;
|
||||
|
||||
return undef if $exit_code != 0;
|
||||
|
||||
# Parse JSON fields: used, total, free
|
||||
my ($used) = ($stdout =~ /"used"\s*:\s*(\d+)/);
|
||||
my ($total) = ($stdout =~ /"total"\s*:\s*(\d+)/);
|
||||
my ($free) = ($stdout =~ /"free"\s*:\s*(\d+)/);
|
||||
|
||||
if (defined $used && defined $total && $total > 0) {
|
||||
my $free_str = defined $free ? ' (' . _human_size($free) . ' free)' : '';
|
||||
return _human_size($used) . ' used / ' . _human_size($total) . ' total' . $free_str;
|
||||
} elsif (defined $used) {
|
||||
return _human_size($used) . ' used';
|
||||
}
|
||||
|
||||
return undef;
|
||||
}
|
||||
|
||||
sub _human_size {
|
||||
my ($bytes) = @_;
|
||||
return '0 B' unless defined $bytes && $bytes >= 0;
|
||||
|
||||
my @units = ('B', 'KB', 'MB', 'GB', 'TB', 'PB');
|
||||
my $i = 0;
|
||||
my $size = $bytes + 0.0;
|
||||
while ($size >= 1024 && $i < $#units) {
|
||||
$size /= 1024;
|
||||
$i++;
|
||||
}
|
||||
if ($i == 0) {
|
||||
return sprintf('%d %s', $size, $units[$i]);
|
||||
}
|
||||
return sprintf('%.2f %s', $size, $units[$i]);
|
||||
}
|
||||
|
||||
# ── Add ──────────────────────────────────────────────────────
|
||||
|
||||
sub handle_add {
|
||||
|
||||
Reference in New Issue
Block a user