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:
89
bin/gniza
89
bin/gniza
@@ -1543,6 +1543,93 @@ cmd_sysrestore() {
|
|||||||
exit "$EXIT_OK"
|
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() {
|
cmd_usage() {
|
||||||
cat <<EOF
|
cat <<EOF
|
||||||
${C_BOLD}gniza v${GNIZA_VERSION}${C_RESET} — cPanel Backup, Restore & Disaster Recovery
|
${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 run <name> Run a schedule now
|
||||||
schedule list Show configured schedules
|
schedule list Show configured schedules
|
||||||
schedule {install|show|remove} Manage cron entries
|
schedule {install|show|remove} Manage cron entries
|
||||||
|
stats Collect backup statistics
|
||||||
init Setup config + first remote
|
init Setup config + first remote
|
||||||
init remote <name> Add a remote destination
|
init remote <name> Add a remote destination
|
||||||
version Show version
|
version Show version
|
||||||
@@ -1628,6 +1716,7 @@ main() {
|
|||||||
status) cmd_status "$@" ;;
|
status) cmd_status "$@" ;;
|
||||||
remote) cmd_remote "$@" ;;
|
remote) cmd_remote "$@" ;;
|
||||||
schedule) cmd_schedule "$@" ;;
|
schedule) cmd_schedule "$@" ;;
|
||||||
|
stats) cmd_stats "$@" ;;
|
||||||
init) cmd_init "$@" ;;
|
init) cmd_init "$@" ;;
|
||||||
version) echo "gniza v${GNIZA_VERSION}" ;;
|
version) echo "gniza v${GNIZA_VERSION}" ;;
|
||||||
help|-h|--help) cmd_usage ;;
|
help|-h|--help) cmd_usage ;;
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -1,2 +1,2 @@
|
|||||||
<!-- Tailwind/DaisyUI class safelist for gniza WHM plugin -->
|
<!-- 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>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use Whostmgr::HTMLInterface ();
|
|||||||
use Cpanel::Form ();
|
use Cpanel::Form ();
|
||||||
use GnizaWHM::Config;
|
use GnizaWHM::Config;
|
||||||
use GnizaWHM::Cron;
|
use GnizaWHM::Cron;
|
||||||
|
use GnizaWHM::Runner;
|
||||||
use GnizaWHM::UI;
|
use GnizaWHM::UI;
|
||||||
|
|
||||||
my $form = Cpanel::Form::parseform();
|
my $form = Cpanel::Form::parseform();
|
||||||
@@ -31,6 +32,25 @@ if (($form->{'action'} // '') eq 'status') {
|
|||||||
exit;
|
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";
|
print "Content-Type: text/html\r\n\r\n";
|
||||||
|
|
||||||
Whostmgr::HTMLInterface::defheader('GNIZA Backup Manager — Dashboard', '', '/cgi/gniza-whm/index.cgi');
|
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_nav('index.cgi');
|
||||||
print GnizaWHM::UI::render_flash();
|
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">👥</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">☁</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">💾</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">📡</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">⏰</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">📋</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
|
# 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{<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{<h2 class="card-title text-sm">Live Status</h2>\n};
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ my @ALLOWED = (
|
|||||||
# list
|
# list
|
||||||
{ cmd => 'list', subcmd => undef, args => [] },
|
{ cmd => 'list', subcmd => undef, args => [] },
|
||||||
{ cmd => 'list', subcmd => 'accounts', args => [] },
|
{ cmd => 'list', subcmd => 'accounts', args => [] },
|
||||||
|
# stats
|
||||||
|
{ cmd => 'stats', subcmd => undef, args => [] },
|
||||||
);
|
);
|
||||||
|
|
||||||
# Named option patterns (--key=value).
|
# Named option patterns (--key=value).
|
||||||
|
|||||||
Reference in New Issue
Block a user