Add dashboard stats cards with cached remote data

Add 6 stat cards to the WHM dashboard showing cPanel accounts, backed up
accounts, total snapshots, remotes, schedules, and last backup status.
Remote-dependent stats are collected via `gniza stats` CLI command and
cached in stats.json, with a manual Refresh button on the dashboard.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
shuki
2026-03-05 05:23:40 +02:00
parent 3a342f091e
commit 7404e88cd9
5 changed files with 238 additions and 2 deletions

View File

@@ -1543,6 +1543,93 @@ cmd_sysrestore() {
exit "$EXIT_OK"
}
cmd_stats() {
local config_file; config_file=$(get_opt config "$@" 2>/dev/null) || config_file="$DEFAULT_CONFIG_FILE"
load_config "$config_file"
validate_config || die "Invalid configuration"
init_logging
local log_dir="${LOG_DIR:-$DEFAULT_LOG_DIR}"
local stats_file="$log_dir/stats.json"
local remotes; remotes=$(list_remotes) || true
if [[ -z "$remotes" ]]; then
log_error "No remotes configured"
return 1
fi
local total_accounts=0
local total_snapshots=0
local remote_json_parts=()
local -A seen_accounts
_save_remote_globals
while IFS= read -r rname; do
[[ -z "$rname" ]] && continue
load_remote "$rname" || { log_warn "Failed to load remote: $rname"; continue; }
# Test connectivity
local conn_ok=false
if _is_rclone_mode; then
test_rclone_connection 2>/dev/null && conn_ok=true
else
test_ssh_connection 2>/dev/null && conn_ok=true
fi
local r_accounts=0
local r_snapshots=0
if $conn_ok; then
local accounts_list; accounts_list=$(list_remote_accounts 2>/dev/null) || true
if [[ -n "$accounts_list" ]]; then
while IFS= read -r acc; do
[[ -z "$acc" ]] && continue
((r_accounts++)) || true
seen_accounts["$acc"]=1
local snaps; snaps=$(list_remote_snapshots "$acc" 2>/dev/null) || true
if [[ -n "$snaps" ]]; then
local snap_count; snap_count=$(echo "$snaps" | wc -l)
((r_snapshots += snap_count)) || true
fi
done <<< "$accounts_list"
fi
else
log_warn "Cannot connect to remote: $rname (skipping)"
fi
((total_snapshots += r_snapshots)) || true
remote_json_parts+=("\"$rname\":{\"accounts\":$r_accounts,\"snapshots\":$r_snapshots}")
done <<< "$remotes"
_restore_remote_globals
total_accounts=${#seen_accounts[@]}
# Parse last backup status from most recent log file
local last_status="UNKNOWN"
local last_log=""
local latest_log=""
if [[ -d "$log_dir" ]]; then
latest_log=$(ls -1t "$log_dir"/gniza-[0-9]*-[0-9]*.log 2>/dev/null | head -1) || true
fi
if [[ -n "$latest_log" && -f "$latest_log" ]]; then
last_log=$(basename "$latest_log")
if tail -20 "$latest_log" | grep -qi "Backup completed successfully"; then
last_status="SUCCESS"
elif tail -20 "$latest_log" | grep -qi "Backup failed\|partial failure\|PARTIAL"; then
last_status="FAILURE"
fi
fi
# Build JSON
local updated; updated=$(date -u +"%Y-%m-%dT%H%M%S")
local remotes_json; remotes_json=$(IFS=','; echo "${remote_json_parts[*]}")
local json="{\"updated\":\"$updated\",\"backed_up_accounts\":$total_accounts,\"snapshots\":$total_snapshots,\"remotes\":{$remotes_json},\"last_backup\":{\"status\":\"$last_status\",\"log\":\"$last_log\"}}"
echo "$json" > "$stats_file"
log_info "Stats written to $stats_file"
echo "$json"
}
cmd_usage() {
cat <<EOF
${C_BOLD}gniza v${GNIZA_VERSION}${C_RESET} — cPanel Backup, Restore & Disaster Recovery
@@ -1577,6 +1664,7 @@ ${C_BOLD}Commands:${C_RESET}
schedule run <name> Run a schedule now
schedule list Show configured schedules
schedule {install|show|remove} Manage cron entries
stats Collect backup statistics
init Setup config + first remote
init remote <name> Add a remote destination
version Show version
@@ -1628,6 +1716,7 @@ main() {
status) cmd_status "$@" ;;
remote) cmd_remote "$@" ;;
schedule) cmd_schedule "$@" ;;
stats) cmd_stats "$@" ;;
init) cmd_init "$@" ;;
version) echo "gniza v${GNIZA_VERSION}" ;;
help|-h|--help) cmd_usage ;;

File diff suppressed because one or more lines are too long

View File

@@ -1,2 +1,2 @@
<!-- Tailwind/DaisyUI class safelist for gniza WHM plugin -->
<div class="alert alert-error alert-info alert-success alert-warning badge badge-error badge-sm badge-success badge-warning bg-base-100 bg-base-200 bg-neutral bg-primary/10 border border-base-300 border-base-content/5 breadcrumbs btn btn-error btn-ghost btn-info btn-primary btn-secondary btn-sm btn-xs card card-body card-title checkbox checkbox-sm collapse collapse-arrow collapse-content collapse-title cursor-pointer flex flex-1 flex-col flex-wrap font-bold font-medium font-mono font-semibold gap-1 gap-2 gap-3 hidden inline input input-bordered input-sm items-center items-start mx-auto join join-item link list-disc loading loading-spinner loading-xs max-h-48 max-w-2xl max-w-xs mb-1 mb-2.5 mb-3 mb-4 mb-5 mb-6 ml-2 modal modal-action modal-backdrop modal-box mt-2 mt-3 mt-4 mt-5 my-2 my-4 overflow-x-auto overflow-y-auto p-3 p-4 pt-1 pt-2 pl-5 px-4 py-1 py-3 py-4 radio radio-sm rounded-box rounded-lg select select-bordered select-sm shadow-sm steps tab tab-content table hover tabs tabs-box tabs-lg tab-active text-center text-error text-lg textarea textarea-bordered textarea-sm text-base-content/60 text-neutral-content text-sm text-xl text-xs toggle toggle-sm toggle-success w-11/12 w-44 w-full whitespace-pre-wrap font-sans text-[1.6rem] text-warning badge-info badge-neutral btn-active leading-relaxed inline-flex items-stretch w-fit bg-[#fafafa] px-5 max-h-[360px] m-0 no-underline bg-white p-2.5 animate-pulse badge-outline btn-warning btn-circle mt-1 h-40 tooltip tooltip-top w-52 whitespace-nowrap navbar navbar-start navbar-end menu menu-horizontal h-12 w-auto text-3xl leading-none text-secondary active"></div>
<div class="alert alert-error alert-info alert-success alert-warning badge badge-error badge-sm badge-success badge-warning bg-base-100 bg-base-200 bg-neutral bg-primary/10 border border-base-300 border-base-content/5 breadcrumbs btn btn-error btn-ghost btn-info btn-primary btn-secondary btn-sm btn-xs card card-body card-title checkbox checkbox-sm collapse collapse-arrow collapse-content collapse-title cursor-pointer flex flex-1 flex-col flex-wrap font-bold font-medium font-mono font-semibold gap-1 gap-2 gap-3 hidden inline input input-bordered input-sm items-center items-start mx-auto join join-item link list-disc loading loading-spinner loading-xs max-h-48 max-w-2xl max-w-xs mb-1 mb-2.5 mb-3 mb-4 mb-5 mb-6 ml-2 modal modal-action modal-backdrop modal-box mt-2 mt-3 mt-4 mt-5 my-2 my-4 overflow-x-auto overflow-y-auto p-3 p-4 pt-1 pt-2 pl-5 px-4 py-1 py-3 py-4 radio radio-sm rounded-box rounded-lg select select-bordered select-sm shadow-sm steps tab tab-content table hover tabs tabs-box tabs-lg tab-active text-center text-error text-lg textarea textarea-bordered textarea-sm text-base-content/60 text-neutral-content text-sm text-xl text-xs toggle toggle-sm toggle-success w-11/12 w-44 w-full whitespace-pre-wrap font-sans text-[1.6rem] text-warning badge-info badge-neutral btn-active leading-relaxed inline-flex items-stretch w-fit bg-[#fafafa] px-5 max-h-[360px] m-0 no-underline bg-white p-2.5 animate-pulse badge-outline btn-warning btn-circle mt-1 h-40 tooltip tooltip-top w-52 whitespace-nowrap navbar navbar-start navbar-end menu menu-horizontal h-12 w-auto text-3xl leading-none text-secondary active stat stat-title stat-value stat-desc stat-figure grid-cols-3 grid-cols-2 badge-lg grid"></div>

View File

@@ -9,6 +9,7 @@ use Whostmgr::HTMLInterface ();
use Cpanel::Form ();
use GnizaWHM::Config;
use GnizaWHM::Cron;
use GnizaWHM::Runner;
use GnizaWHM::UI;
my $form = Cpanel::Form::parseform();
@@ -31,6 +32,25 @@ if (($form->{'action'} // '') eq 'status') {
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');
@@ -39,6 +59,131 @@ 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 3-column grid
print qq{<div class="grid grid-cols-3 gap-4 mb-6">\n};
# Card 1: cPanel Accounts
my $acct_count = scalar @cpanel_accounts;
print qq{ <div class="stat bg-white shadow-sm border border-base-300 rounded-box">\n};
print qq{ <div class="stat-figure text-primary text-3xl">&#x1F465;</div>\n};
print qq{ <div class="stat-title">cPanel Accounts</div>\n};
print qq{ <div class="stat-value text-primary">$acct_count</div>\n};
print qq{ <div class="stat-desc">on this server</div>\n};
print qq{ </div>\n};
# Card 2: Backed Up Accounts
print qq{ <div class="stat bg-white shadow-sm border border-base-300 rounded-box">\n};
print qq{ <div class="stat-figure text-secondary text-3xl">&#x2601;</div>\n};
print qq{ <div class="stat-title">Backed Up Accounts</div>\n};
print qq{ <div class="stat-value text-secondary">$backed_up</div>\n};
my $remote_count_desc = scalar @remotes;
print qq{ <div class="stat-desc">across $remote_count_desc remote(s)</div>\n};
print qq{ </div>\n};
# Card 3: Total Snapshots
print qq{ <div class="stat bg-white shadow-sm border border-base-300 rounded-box">\n};
print qq{ <div class="stat-figure text-primary text-3xl">&#x1F4BE;</div>\n};
print qq{ <div class="stat-title">Total Snapshots</div>\n};
print qq{ <div class="stat-value">$total_snaps</div>\n};
print qq{ <div class="stat-desc">across all remotes</div>\n};
print qq{ </div>\n};
# Card 4: Remotes
my $rem_count = scalar @remotes;
print qq{ <div class="stat bg-white shadow-sm border border-base-300 rounded-box">\n};
print qq{ <div class="stat-figure text-primary text-3xl">&#x1F4E1;</div>\n};
print qq{ <div class="stat-title">Remotes</div>\n};
print qq{ <div class="stat-value text-primary">$rem_count</div>\n};
print qq{ <div class="stat-desc">configured destinations</div>\n};
print qq{ </div>\n};
# Card 5: Schedules
print qq{ <div class="stat bg-white shadow-sm border border-base-300 rounded-box">\n};
print qq{ <div class="stat-figure text-secondary text-3xl">&#x23F0;</div>\n};
print qq{ <div class="stat-title">Schedules</div>\n};
print qq{ <div class="stat-value text-secondary">$sched_count</div>\n};
print qq{ <div class="stat-desc">active cron jobs</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="stat bg-white shadow-sm border border-base-300 rounded-box">\n};
print qq{ <div class="stat-figure text-primary text-3xl">&#x1F4CB;</div>\n};
print qq{ <div class="stat-title">Last Backup</div>\n};
print qq{ <div class="stat-value"><span class="badge $badge_class badge-lg">$badge_text</span></div>\n};
if ($last_log) {
my $esc_log = GnizaWHM::UI::esc($last_log);
print qq{ <div class="stat-desc">$esc_log</div>\n};
} else {
print qq{ <div class="stat-desc">click Refresh to collect</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};

View File

@@ -40,6 +40,8 @@ my @ALLOWED = (
# list
{ cmd => 'list', subcmd => undef, args => [] },
{ cmd => 'list', subcmd => 'accounts', args => [] },
# stats
{ cmd => 'stats', subcmd => undef, args => [] },
);
# Named option patterns (--key=value).