Files
gniza4cp/whm/gniza-whm/index.cgi
shuki 7083efcc05 Add background job execution for restore and live status dashboard
- Runner.pm: extract _validate()/_build_cmd_line(), add run_async() that
  forks a detached child via setsid() to run commands in background
- restore.cgi: handle_step4() builds commands array and uses run_async()
  instead of blocking synchronous execution, redirects to logs.cgi
- logs.cgi: add auto-refresh JS (10s list view, 5s file view with
  auto-scroll) that polls index.cgi?action=status while gniza is running
- index.cgi: add live status card with AJAX polling and JSON endpoint
- Cron/schedule: redirect cron output to /dev/null (gniza has own logs)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 18:07:09 +02:00

261 lines
9.5 KiB
Perl

#!/usr/local/cpanel/3rdparty/bin/perl
# gniza WHM Plugin — Dashboard
use strict;
use warnings;
use lib '/usr/local/cpanel/whostmgr/docroot/cgi/gniza-whm/lib';
use Whostmgr::HTMLInterface ();
use Cpanel::Form ();
use GnizaWHM::Config;
use GnizaWHM::Cron;
use GnizaWHM::UI;
my $form = Cpanel::Form::parseform();
# Redirect to setup wizard if gniza is not configured
unless (GnizaWHM::UI::is_configured()) {
if (($form->{'action'} // '') eq 'status') {
print "Content-Type: application/json\r\n\r\n";
print '{"running":false,"pid":"","log_file":"","lines":[]}';
exit;
}
print "Status: 302 Found\r\n";
print "Location: setup.cgi\r\n\r\n";
exit;
}
# JSON status endpoint for AJAX polling
if (($form->{'action'} // '') eq 'status') {
handle_status_json();
exit;
}
print "Content-Type: text/html\r\n\r\n";
Whostmgr::HTMLInterface::defheader('GNIZA Backup Manager — Dashboard', '', '/cgi/gniza-whm/index.cgi');
print GnizaWHM::UI::page_header('GNIZA Backup Manager');
print GnizaWHM::UI::render_nav('index.cgi');
print GnizaWHM::UI::render_flash();
# Live Status card
print qq{<div id="gniza-live-status" class="card bg-white shadow-sm border border-base-300 mb-6">\n<div class="card-body">\n};
print qq{<h2 class="card-title text-sm">Live Status</h2>\n};
print qq{<div id="gniza-status-content">\n};
print qq{ <div class="flex items-center gap-2">\n};
print qq{ <span class="loading loading-spinner loading-xs"></span>\n};
print qq{ <span class="text-sm text-base-content/60">Checking...</span>\n};
print qq{ </div>\n};
print qq{</div>\n};
print qq{</div>\n</div>\n};
# Quick links
print qq{<div class="flex gap-3 mb-5">\n};
print qq{ <button type="button" class="btn btn-primary btn-sm" onclick="location.href='setup.cgi'">Run Setup Wizard</button>\n};
print qq{</div>\n};
# Remote destinations
my @remotes = GnizaWHM::UI::list_remotes();
print qq{<div class="card bg-white shadow-sm border border-base-300 mb-6">\n<div class="card-body">\n};
print qq{<div class="flex items-center gap-3"><h2 class="card-title text-sm">Configured Remotes</h2><button type="button" class="btn btn-primary btn-sm" onclick="location.href='remotes.cgi?action=add_form'">Add New</button></div>\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>Host</th><th>Port</th><th>Retention</th></tr></thead>\n};
print qq{<tbody>\n};
for my $name (@remotes) {
my $conf = GnizaWHM::Config::parse(GnizaWHM::UI::remote_conf_path($name), 'remote');
my $host = GnizaWHM::UI::esc($conf->{REMOTE_HOST} // '');
my $port = GnizaWHM::UI::esc($conf->{REMOTE_PORT} // '22');
my $retention = GnizaWHM::UI::esc($conf->{RETENTION_COUNT} // '30');
my $esc_name = GnizaWHM::UI::esc($name);
print qq{<tr class="hover"><td>$esc_name</td><td>$host</td><td>$port</td><td>$retention</td></tr>\n};
}
print qq{</tbody>\n</table></div>\n};
} else {
print qq{<p>No remotes configured. <a href="setup.cgi" class="link">Run the setup wizard</a> to add one.</p>\n};
}
print qq{</div>\n</div>\n};
# Active schedules
my $schedules = GnizaWHM::Cron::get_current_schedules();
print qq{<div class="card bg-white shadow-sm border border-base-300 mb-6">\n<div class="card-body">\n};
print qq{<div class="flex items-center gap-3"><h2 class="card-title text-sm">Active Cron Schedules</h2><button type="button" class="btn btn-primary btn-sm" onclick="location.href='schedules.cgi?action=add_form'">Add New</button></div>\n};
if (keys %$schedules) {
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>Schedule</th><th>Timing</th><th>Remote Destination(s)</th></tr></thead>\n};
print qq{<tbody>\n};
for my $name (sort keys %$schedules) {
my $esc_name = GnizaWHM::UI::esc($name);
my ($timing, $remotes) = GnizaWHM::Cron::cron_to_human($schedules->{$name});
my $esc_timing = GnizaWHM::UI::esc($timing);
my $esc_remotes = GnizaWHM::UI::esc($remotes);
print qq{<tr class="hover"><td>$esc_name</td><td>$esc_timing</td><td>$esc_remotes</td></tr>\n};
}
print qq{</tbody>\n</table></div>\n};
} else {
print qq{<p>No active gniza cron entries.</p>\n};
}
print qq{</div>\n</div>\n};
# Overview
my $version = GnizaWHM::UI::get_gniza_version();
print qq{<div class="card bg-white shadow-sm border border-base-300 mb-6">\n<div class="card-body">\n};
print qq{<h2 class="card-title text-sm">Overview</h2>\n};
print qq{<div class="overflow-x-auto rounded-box border border-base-content/5 bg-base-100"><table class="table">\n};
print qq{<tr><td class="font-semibold w-44">gniza version</td><td>} . GnizaWHM::UI::esc($version) . qq{</td></tr>\n};
print qq{</table></div>\n};
print qq{</div>\n</div>\n};
# Inline JavaScript for live status polling
print qq{<script>
(function() {
var interval = null;
var wasRunning = false;
var el = document.getElementById('gniza-status-content');
function escHtml(s) {
var d = document.createElement('div');
d.appendChild(document.createTextNode(s));
return d.innerHTML;
}
function levelColor(level) {
if (level === 'ERROR') return 'text-error';
if (level === 'WARN') return 'text-warning';
if (level === 'DEBUG') return 'text-base-content/60';
return '';
}
function update(data) {
var html = '';
if (data.running) {
html += '<div class="flex items-center gap-2 mb-3">';
html += '<span class="badge badge-warning badge-sm animate-pulse">Running</span>';
html += '<span class="text-sm">PID ' + escHtml(data.pid);
if (data.log_file) html += ' &mdash; ' + escHtml(data.log_file);
html += '</span></div>';
if (data.lines && data.lines.length) {
html += '<pre class="font-mono text-xs bg-base-200 rounded-lg p-3 overflow-x-auto whitespace-pre-wrap max-h-48 overflow-y-auto">';
for (var i = 0; i < data.lines.length; i++) {
var cls = levelColor(data.lines[i].level);
html += '<span class="' + cls + '">' + escHtml(data.lines[i].text) + '</span>\\n';
}
html += '</pre>';
}
} else {
html += '<div class="flex items-center gap-2">';
html += '<span class="badge badge-success badge-sm">Idle</span>';
html += '<span class="text-sm">No backup or restore running.</span>';
html += '</div>';
}
el.innerHTML = html;
if (data.running !== wasRunning) {
clearInterval(interval);
interval = setInterval(poll, data.running ? 10000 : 60000);
wasRunning = data.running;
}
}
function poll() {
fetch('index.cgi?action=status')
.then(function(r) { return r.json(); })
.then(update)
.catch(function() {});
}
interval = setInterval(poll, 10000);
poll();
})();
</script>
};
print GnizaWHM::UI::page_footer();
Whostmgr::HTMLInterface::footer();
# --- JSON status endpoint ---
sub handle_status_json {
print "Content-Type: application/json\r\n\r\n";
my $lock_file = '/var/run/gniza.lock';
my $running = 0;
my $pid = '';
# Check lock file for running PID
if (-f $lock_file) {
if (open my $fh, '<', $lock_file) {
$pid = <$fh> // '';
chomp $pid;
close $fh;
if ($pid =~ /^\d+$/ && kill(0, $pid)) {
$running = 1;
} else {
$pid = '';
}
}
}
# Find LOG_DIR from main config
my $log_dir = '/var/log/gniza';
my $main_conf_file = '/etc/gniza/gniza.conf';
if (-f $main_conf_file) {
my $cfg = GnizaWHM::Config::parse($main_conf_file, 'main');
$log_dir = $cfg->{LOG_DIR} if $cfg->{LOG_DIR} && $cfg->{LOG_DIR} ne '';
}
# Find most recent gniza-*.log by mtime
my $log_file = '';
my @lines;
if (opendir my $dh, $log_dir) {
my @logs = sort {
(stat("$log_dir/$b"))[9] <=> (stat("$log_dir/$a"))[9]
} grep { /^gniza-\d{8}-\d{6}\.log$/ } readdir($dh);
closedir $dh;
$log_file = $logs[0] // '';
}
# Read last 15 lines if running and log exists
if ($log_file && $running) {
my $path = "$log_dir/$log_file";
if (open my $fh, '<', $path) {
my @all = <$fh>;
close $fh;
my $start = @all > 15 ? @all - 15 : 0;
@lines = @all[$start .. $#all];
chomp @lines;
}
}
# Build JSON manually (no JSON module dependency)
my $json = '{"running":' . ($running ? 'true' : 'false');
$json .= ',"pid":"' . _json_esc($pid) . '"';
$json .= ',"log_file":"' . _json_esc($log_file) . '"';
$json .= ',"lines":[';
my @json_lines;
for my $line (@lines) {
my $level = 'INFO';
if ($line =~ /\]\s+\[(\w+)\]/) {
$level = uc($1);
}
push @json_lines, '{"level":"' . _json_esc($level) . '","text":"' . _json_esc($line) . '"}';
}
$json .= join(',', @json_lines);
$json .= ']}';
print $json;
}
sub _json_esc {
my ($s) = @_;
$s //= '';
$s =~ s/\\/\\\\/g;
$s =~ s/"/\\"/g;
$s =~ s/\n/\\n/g;
$s =~ s/\r/\\r/g;
$s =~ s/\t/\\t/g;
# Escape control characters
$s =~ s/([\x00-\x1f])/sprintf("\\u%04x", ord($1))/ge;
return $s;
}