\n};
if (@remotes) {
print qq{
\n};
- print qq{| Name | Type | Destination | Retention | Actions |
\n};
+ print qq{| Name | Type | Destination | Disk Usage | Schedules | Retention | Actions |
\n};
print qq{\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{$sched_count};
+ } else {
+ $sched_html = qq{0};
+ }
+
print qq{};
print qq{| $esc_name | };
print qq{$type_label | };
- print qq{$dest | $retention | };
+ print qq{$dest | };
+ print qq{ | };
+ print qq{$sched_html | };
+ print qq{$retention | };
print qq{};
print qq{ };
print qq{};
@@ -193,6 +226,35 @@ sub handle_list {
print qq{ |
\n};
}
print qq{\n
\n};
+
+ # JavaScript: fetch disk info for each remote via safe DOM methods
+ print <<'JS';
+
+JS
} else {
print qq{
No remote destinations configured. Add a remote to enable multi-remote backups.
\n};
print qq{
Remote configs are stored in /etc/gniza/remotes.d/.
\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 {