Files
gniza4cp/whm/gniza-whm/index.cgi
shuki 325020338d Replace stat components with card-based layout for stats grid
DaisyUI stat components have internal styles that conflict when used
as standalone grid items, causing vertical overlap. Switch to plain
card components with manual typography classes instead.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 05:30:21 +02:00

412 lines
16 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::Runner;
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;
}
# Refresh stats endpoint
if (($form->{'action'} // '') eq 'refresh_stats' && ($ENV{'REQUEST_METHOD'} // '') eq 'POST') {
unless (GnizaWHM::UI::verify_csrf_token($form->{'gniza_csrf'} // '')) {
GnizaWHM::UI::set_flash('error', 'Invalid or expired form token.');
print "Status: 302 Found\r\n";
print "Location: index.cgi\r\n\r\n";
exit;
}
my ($ok, $stdout, $stderr) = GnizaWHM::Runner::run('stats', undef, [], {});
if ($ok) {
GnizaWHM::UI::set_flash('success', 'Statistics refreshed successfully.');
} else {
GnizaWHM::UI::set_flash('error', 'Failed to refresh statistics: ' . GnizaWHM::UI::esc($stderr));
}
print "Status: 302 Found\r\n";
print "Location: index.cgi\r\n\r\n";
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();
# Stats overview cards
{
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 '';
}
my @cpanel_accounts = GnizaWHM::UI::get_cpanel_accounts();
my @remotes = GnizaWHM::UI::list_remotes();
my $schedules = GnizaWHM::Cron::get_current_schedules();
my $sched_count = scalar keys %$schedules;
# Read stats.json cache
my $stats_file = "$log_dir/stats.json";
my ($backed_up, $total_snaps, $last_status, $last_log, $updated) = (0, 0, '', '', '');
my $has_stats = 0;
if (-f $stats_file) {
if (open my $fh, '<', $stats_file) {
my $json = do { local $/; <$fh> };
close $fh;
$has_stats = 1;
($backed_up) = $json =~ /"backed_up_accounts"\s*:\s*(\d+)/;
($total_snaps) = $json =~ /"snapshots"\s*:\s*(\d+)/;
($last_status) = $json =~ /"status"\s*:\s*"([^"]*)"/;
($last_log) = $json =~ /"log"\s*:\s*"([^"]*)"/;
($updated) = $json =~ /"updated"\s*:\s*"([^"]*)"/;
$backed_up //= 0;
$total_snaps //= 0;
$last_status //= '';
$last_log //= '';
$updated //= '';
}
}
# Updated timestamp + refresh button
print qq{<div class="flex items-center gap-3 mb-4">\n};
if ($has_stats && $updated) {
my $esc_updated = GnizaWHM::UI::esc($updated);
print qq{ <span class="text-sm text-base-content/60">Stats updated: $esc_updated</span>\n};
} else {
print qq{ <span class="text-sm text-base-content/60">No stats collected yet</span>\n};
}
print qq{ <form method="post" action="index.cgi" class="inline">\n};
print qq{ <input type="hidden" name="action" value="refresh_stats">\n};
print qq{ } . GnizaWHM::UI::csrf_hidden_field() . qq{\n};
print qq{ <button type="submit" class="btn btn-secondary btn-sm">Refresh Stats</button>\n};
print qq{ </form>\n};
print qq{</div>\n};
# 6 stat cards in responsive grid
print qq{<div class="grid grid-cols-2 sm:grid-cols-3 gap-4 mb-6">\n};
# Card 1: cPanel Accounts
my $acct_count = scalar @cpanel_accounts;
print qq{ <div class="card bg-white shadow-sm border border-base-300">\n};
print qq{ <div class="card-body p-4">\n};
print qq{ <div class="text-sm text-base-content/60">cPanel Accounts</div>\n};
print qq{ <div class="text-xl font-bold text-primary">$acct_count</div>\n};
print qq{ <div class="text-xs text-base-content/60">on this server</div>\n};
print qq{ </div>\n};
print qq{ </div>\n};
# Card 2: Backed Up Accounts
print qq{ <div class="card bg-white shadow-sm border border-base-300">\n};
print qq{ <div class="card-body p-4">\n};
print qq{ <div class="text-sm text-base-content/60">Backed Up Accounts</div>\n};
print qq{ <div class="text-xl font-bold text-secondary">$backed_up</div>\n};
my $remote_count_desc = scalar @remotes;
print qq{ <div class="text-xs text-base-content/60">across $remote_count_desc remote(s)</div>\n};
print qq{ </div>\n};
print qq{ </div>\n};
# Card 3: Total Snapshots
print qq{ <div class="card bg-white shadow-sm border border-base-300">\n};
print qq{ <div class="card-body p-4">\n};
print qq{ <div class="text-sm text-base-content/60">Total Snapshots</div>\n};
print qq{ <div class="text-xl font-bold">$total_snaps</div>\n};
print qq{ <div class="text-xs text-base-content/60">across all remotes</div>\n};
print qq{ </div>\n};
print qq{ </div>\n};
# Card 4: Remotes
my $rem_count = scalar @remotes;
print qq{ <div class="card bg-white shadow-sm border border-base-300">\n};
print qq{ <div class="card-body p-4">\n};
print qq{ <div class="text-sm text-base-content/60">Remotes</div>\n};
print qq{ <div class="text-xl font-bold text-primary">$rem_count</div>\n};
print qq{ <div class="text-xs text-base-content/60">configured destinations</div>\n};
print qq{ </div>\n};
print qq{ </div>\n};
# Card 5: Schedules
print qq{ <div class="card bg-white shadow-sm border border-base-300">\n};
print qq{ <div class="card-body p-4">\n};
print qq{ <div class="text-sm text-base-content/60">Schedules</div>\n};
print qq{ <div class="text-xl font-bold text-secondary">$sched_count</div>\n};
print qq{ <div class="text-xs text-base-content/60">active cron jobs</div>\n};
print qq{ </div>\n};
print qq{ </div>\n};
# Card 6: Last Backup
my ($badge_class, $badge_text);
if ($last_status eq 'SUCCESS') {
$badge_class = 'badge-success';
$badge_text = 'SUCCESS';
} elsif ($last_status eq 'FAILURE') {
$badge_class = 'badge-error';
$badge_text = 'FAILURE';
} else {
$badge_class = 'badge-neutral';
$badge_text = $has_stats ? 'UNKNOWN' : 'N/A';
}
print qq{ <div class="card bg-white shadow-sm border border-base-300">\n};
print qq{ <div class="card-body p-4">\n};
print qq{ <div class="text-sm text-base-content/60">Last Backup</div>\n};
print qq{ <div class="mt-1 mb-1"><span class="badge $badge_class">$badge_text</span></div>\n};
if ($last_log) {
my $esc_log = GnizaWHM::UI::esc($last_log);
print qq{ <div class="text-xs text-base-content/60">$esc_log</div>\n};
} else {
print qq{ <div class="text-xs text-base-content/60">click Refresh to collect</div>\n};
}
print qq{ </div>\n};
print qq{ </div>\n};
print qq{</div>\n};
}
# 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;
}