Add cPanel user activity logs page and WHM user log visibility

- Add per-user activity logging to AdminBin: every RESTORE_* action
  writes to /var/log/gniza/cpanel-<user>.log with action details and
  gniza command output
- New logs.live.cgi CGI with paginated activity list and detail view
- WHM logs.cgi now shows cpanel-*.log files with Owner column and
  structured activity entry viewer with expandable command output
- Add Logs nav item to cPanel plugin, update install.sh

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
shuki
2026-03-05 19:37:08 +02:00
parent 5ffd365c43
commit c20c019048
7 changed files with 583 additions and 31 deletions

View File

@@ -108,6 +108,64 @@ sub _run_gniza {
# ── Action dispatch ─────────────────────────────────────────── # ── Action dispatch ───────────────────────────────────────────
# ── Per-user activity logging ─────────────────────────────────
my $ACTIVITY_ENTRY_RE = qr/^[0-9]+$/;
sub _get_log_dir {
my $log_dir = '/var/log/gniza';
if (open my $fh, '<', $MAIN_CONFIG) {
while (my $line = <$fh>) {
if ($line =~ /^LOG_DIR=(?:"([^"]*)"|'([^']*)'|(\S*))$/) {
my $val = defined $1 ? $1 : (defined $2 ? $2 : ($3 // ''));
$log_dir = $val if $val ne '';
}
}
close $fh;
}
return $log_dir;
}
sub _activity_log_path {
my ($user) = @_;
my $log_dir = _get_log_dir();
return "$log_dir/cpanel-$user.log";
}
my %ACTION_LABELS = (
RESTORE_ACCOUNT => 'Full Account',
RESTORE_FILES => 'Home Directory',
RESTORE_DATABASE => 'Database',
RESTORE_MAILBOX => 'Email',
RESTORE_CRON => 'Cron Jobs',
RESTORE_DBUSERS => 'DB Users',
RESTORE_DOMAINS => 'Domains',
RESTORE_SSL => 'SSL Certificates',
);
sub _log_activity {
my ($user, $action, $details, $status, $output) = @_;
my $log_file = _activity_log_path($user);
my $log_dir = _get_log_dir();
mkdir $log_dir, 0700 unless -d $log_dir;
my @t = gmtime(time);
my $ts = sprintf('%04d-%02d-%02d %02d:%02d:%02d',
$t[5]+1900, $t[4]+1, $t[3], $t[2], $t[1], $t[0]);
my $label = $ACTION_LABELS{$action} // $action;
if (open my $fh, '>>', $log_file) {
print $fh "--- ENTRY ---\n";
print $fh "Date: $ts\n";
print $fh "Action: $label\n";
print $fh "Details: $details\n";
print $fh "Status: $status\n";
print $fh $output if defined $output && $output ne '';
print $fh "--- END ---\n";
close $fh;
}
}
sub _actions { sub _actions {
return qw( return qw(
LIST_ALLOWED_REMOTES LIST_ALLOWED_REMOTES
@@ -119,6 +177,8 @@ sub _actions {
LIST_CRON LIST_CRON
LIST_DNS LIST_DNS
LIST_SSL LIST_SSL
LIST_LOGS
GET_LOG
RESTORE_ACCOUNT RESTORE_ACCOUNT
RESTORE_FILES RESTORE_FILES
RESTORE_DATABASE RESTORE_DATABASE
@@ -130,6 +190,81 @@ sub _actions {
); );
} }
sub LIST_LOGS {
my ($self) = @_;
my $user = $self->get_caller_username() // '';
return "ERROR: Invalid user" unless $user =~ $ACCOUNT_RE;
my $log_file = _activity_log_path($user);
return '' unless -f $log_file && !-l $log_file;
# Parse entries from the activity log (newest first)
my @entries;
if (open my $fh, '<', $log_file) {
my $in_entry = 0;
my %cur;
while (my $line = <$fh>) {
chomp $line;
if ($line eq '--- ENTRY ---') {
$in_entry = 1;
%cur = ();
} elsif ($line eq '--- END ---' && $in_entry) {
push @entries, { %cur } if $cur{date};
$in_entry = 0;
} elsif ($in_entry) {
if ($line =~ /^Date:\s+(.+)$/) { $cur{date} = $1; }
elsif ($line =~ /^Action:\s+(.+)$/) { $cur{action} = $1; }
elsif ($line =~ /^Details:\s+(.+)$/) { $cur{details} = $1; }
elsif ($line =~ /^Status:\s+(.+)$/) { $cur{status} = $1; }
}
}
close $fh;
}
# Return newest first, one line per entry: index\tdate\taction\tdetails\tstatus
my @lines;
for my $i (reverse 0 .. $#entries) {
my $e = $entries[$i];
push @lines, join("\t", $i, $e->{date} // '', $e->{action} // '',
$e->{details} // '', $e->{status} // '');
}
return join("\n", @lines);
}
sub GET_LOG {
my ($self, $entry_idx) = @_;
my $user = $self->get_caller_username() // '';
return "ERROR: Invalid user" unless $user =~ $ACCOUNT_RE;
return "ERROR: Invalid entry" unless defined $entry_idx && $entry_idx =~ $ACTIVITY_ENTRY_RE;
my $log_file = _activity_log_path($user);
return "ERROR: No activity log" unless -f $log_file && !-l $log_file;
# Parse the Nth entry
my $idx = int($entry_idx);
my @entries;
if (open my $fh, '<', $log_file) {
my $in_entry = 0;
my @cur_lines;
while (my $line = <$fh>) {
chomp $line;
if ($line eq '--- ENTRY ---') {
$in_entry = 1;
@cur_lines = ();
} elsif ($line eq '--- END ---' && $in_entry) {
push @entries, join("\n", @cur_lines);
$in_entry = 0;
} elsif ($in_entry) {
push @cur_lines, $line;
}
}
close $fh;
}
return "ERROR: Entry not found" if $idx < 0 || $idx > $#entries;
return $entries[$idx];
}
sub LIST_ALLOWED_REMOTES { sub LIST_ALLOWED_REMOTES {
my ($self) = @_; my ($self) = @_;
my @remotes = _get_filtered_remotes(); my @remotes = _get_filtered_remotes();
@@ -267,7 +402,12 @@ sub RESTORE_ACCOUNT {
push @opts, "--exclude=$exclude"; push @opts, "--exclude=$exclude";
} }
my $details = "remote=$remote snapshot=$timestamp";
$details .= " exclude=$exclude" if defined $exclude && $exclude ne '';
my ($ok, $stdout, $stderr) = _run_gniza('restore', 'account', $user, @opts); my ($ok, $stdout, $stderr) = _run_gniza('restore', 'account', $user, @opts);
_log_activity($user, 'RESTORE_ACCOUNT', $details,
$ok ? 'OK' : 'Error', $ok ? $stdout : $stderr);
return $ok ? "OK\n$stdout" : "ERROR: $stderr"; return $ok ? "OK\n$stdout" : "ERROR: $stderr";
} }
@@ -290,7 +430,13 @@ sub RESTORE_FILES {
push @opts, "--exclude=$exclude"; push @opts, "--exclude=$exclude";
} }
my $details = "remote=$remote snapshot=$timestamp";
$details .= " path=$path" if defined $path && $path ne '';
$details .= " exclude=$exclude" if defined $exclude && $exclude ne '';
my ($ok, $stdout, $stderr) = _run_gniza('restore', 'files', $user, @opts); my ($ok, $stdout, $stderr) = _run_gniza('restore', 'files', $user, @opts);
_log_activity($user, 'RESTORE_FILES', $details,
$ok ? 'OK' : 'Error', $ok ? $stdout : $stderr);
return $ok ? "OK\n$stdout" : "ERROR: $stderr"; return $ok ? "OK\n$stdout" : "ERROR: $stderr";
} }
@@ -309,8 +455,13 @@ sub RESTORE_DATABASE {
push @args, $dbname; push @args, $dbname;
} }
my $details = "remote=$remote snapshot=$timestamp";
$details .= " database=$dbname" if defined $dbname && $dbname ne '';
my ($ok, $stdout, $stderr) = _run_gniza('restore', 'database', @args, my ($ok, $stdout, $stderr) = _run_gniza('restore', 'database', @args,
"--remote=$remote", "--timestamp=$timestamp"); "--remote=$remote", "--timestamp=$timestamp");
_log_activity($user, 'RESTORE_DATABASE', $details,
$ok ? 'OK' : 'Error', $ok ? $stdout : $stderr);
return $ok ? "OK\n$stdout" : "ERROR: $stderr"; return $ok ? "OK\n$stdout" : "ERROR: $stderr";
} }
@@ -329,8 +480,13 @@ sub RESTORE_MAILBOX {
push @args, $email; push @args, $email;
} }
my $details = "remote=$remote snapshot=$timestamp";
$details .= " email=$email" if defined $email && $email ne '';
my ($ok, $stdout, $stderr) = _run_gniza('restore', 'mailbox', @args, my ($ok, $stdout, $stderr) = _run_gniza('restore', 'mailbox', @args,
"--remote=$remote", "--timestamp=$timestamp"); "--remote=$remote", "--timestamp=$timestamp");
_log_activity($user, 'RESTORE_MAILBOX', $details,
$ok ? 'OK' : 'Error', $ok ? $stdout : $stderr);
return $ok ? "OK\n$stdout" : "ERROR: $stderr"; return $ok ? "OK\n$stdout" : "ERROR: $stderr";
} }
@@ -343,8 +499,12 @@ sub RESTORE_CRON {
return "ERROR: Invalid timestamp" unless defined $timestamp && $timestamp =~ $TS_RE; return "ERROR: Invalid timestamp" unless defined $timestamp && $timestamp =~ $TS_RE;
return "ERROR: Remote not allowed" unless _is_remote_allowed($remote); return "ERROR: Remote not allowed" unless _is_remote_allowed($remote);
my $details = "remote=$remote snapshot=$timestamp";
my ($ok, $stdout, $stderr) = _run_gniza('restore', 'cron', $user, my ($ok, $stdout, $stderr) = _run_gniza('restore', 'cron', $user,
"--remote=$remote", "--timestamp=$timestamp"); "--remote=$remote", "--timestamp=$timestamp");
_log_activity($user, 'RESTORE_CRON', $details,
$ok ? 'OK' : 'Error', $ok ? $stdout : $stderr);
return $ok ? "OK\n$stdout" : "ERROR: $stderr"; return $ok ? "OK\n$stdout" : "ERROR: $stderr";
} }
@@ -363,8 +523,13 @@ sub RESTORE_DBUSERS {
push @args, $dbuser; push @args, $dbuser;
} }
my $details = "remote=$remote snapshot=$timestamp";
$details .= " dbuser=$dbuser" if defined $dbuser && $dbuser ne '';
my ($ok, $stdout, $stderr) = _run_gniza('restore', 'dbusers', @args, my ($ok, $stdout, $stderr) = _run_gniza('restore', 'dbusers', @args,
"--remote=$remote", "--timestamp=$timestamp"); "--remote=$remote", "--timestamp=$timestamp");
_log_activity($user, 'RESTORE_DBUSERS', $details,
$ok ? 'OK' : 'Error', $ok ? $stdout : $stderr);
return $ok ? "OK\n$stdout" : "ERROR: $stderr"; return $ok ? "OK\n$stdout" : "ERROR: $stderr";
} }
@@ -383,8 +548,13 @@ sub RESTORE_DOMAINS {
push @args, $domain; push @args, $domain;
} }
my $details = "remote=$remote snapshot=$timestamp";
$details .= " domain=$domain" if defined $domain && $domain ne '';
my ($ok, $stdout, $stderr) = _run_gniza('restore', 'domains', @args, my ($ok, $stdout, $stderr) = _run_gniza('restore', 'domains', @args,
"--remote=$remote", "--timestamp=$timestamp"); "--remote=$remote", "--timestamp=$timestamp");
_log_activity($user, 'RESTORE_DOMAINS', $details,
$ok ? 'OK' : 'Error', $ok ? $stdout : $stderr);
return $ok ? "OK\n$stdout" : "ERROR: $stderr"; return $ok ? "OK\n$stdout" : "ERROR: $stderr";
} }
@@ -403,8 +573,13 @@ sub RESTORE_SSL {
push @args, $domain; push @args, $domain;
} }
my $details = "remote=$remote snapshot=$timestamp";
$details .= " domain=$domain" if defined $domain && $domain ne '';
my ($ok, $stdout, $stderr) = _run_gniza('restore', 'ssl', @args, my ($ok, $stdout, $stderr) = _run_gniza('restore', 'ssl', @args,
"--remote=$remote", "--timestamp=$timestamp"); "--remote=$remote", "--timestamp=$timestamp");
_log_activity($user, 'RESTORE_SSL', $details,
$ok ? 'OK' : 'Error', $ok ? $stdout : $stderr);
return $ok ? "OK\n$stdout" : "ERROR: $stderr"; return $ok ? "OK\n$stdout" : "ERROR: $stderr";
} }

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 369 KiB

After

Width:  |  Height:  |  Size: 685 B

View File

@@ -20,13 +20,14 @@ use GnizaCPanel::UI;
my $cpanel = Cpanel::LiveAPI->new(); my $cpanel = Cpanel::LiveAPI->new();
print "Content-Type: text/html\r\n\r\n"; print "Content-Type: text/html\r\n\r\n";
print $cpanel->header('GNIZA Backups'); print $cpanel->header('');
# Get allowed remotes via AdminBin # Get allowed remotes via AdminBin
my $remotes_raw = eval { Cpanel::AdminBin::Call::call('Gniza', 'Restore', 'LIST_ALLOWED_REMOTES') } // ''; my $remotes_raw = eval { Cpanel::AdminBin::Call::call('Gniza', 'Restore', 'LIST_ALLOWED_REMOTES') } // '';
my @remotes = grep { $_ ne '' } split /\n/, $remotes_raw; my @remotes = grep { $_ ne '' } split /\n/, $remotes_raw;
print GnizaCPanel::UI::page_header('GNIZA Backups'); print GnizaCPanel::UI::page_header('GNIZA Backups');
print GnizaCPanel::UI::render_nav('index.live.cgi');
print GnizaCPanel::UI::render_flash(); print GnizaCPanel::UI::render_flash();
if (!@remotes) { if (!@remotes) {

223
cpanel/gniza/logs.live.cgi Normal file
View File

@@ -0,0 +1,223 @@
#!/usr/local/cpanel/3rdparty/bin/perl
# gniza cPanel Plugin — Activity Logs
# Shows user-initiated restore actions and their results
use strict;
use warnings;
BEGIN {
my $base;
if ($0 =~ m{^(.*)/}) {
$base = $1;
} else {
$base = '.';
}
unshift @INC, "$base/lib";
}
use Cpanel::LiveAPI ();
use Cpanel::AdminBin::Call ();
use Cpanel::Form ();
use GnizaCPanel::UI;
my $cpanel = Cpanel::LiveAPI->new();
END { $cpanel->end() if $cpanel }
my $form = Cpanel::Form::parseform();
my $entry = $form->{'entry'} // '';
if ($entry ne '') {
show_entry($entry);
} else {
show_list();
}
exit;
# ── List View ────────────────────────────────────────────────
sub show_list {
print "Content-Type: text/html\r\n\r\n";
print $cpanel->header('');
print GnizaCPanel::UI::page_header('GNIZA Activity Log');
print GnizaCPanel::UI::render_nav('logs.live.cgi');
print GnizaCPanel::UI::render_flash();
my $raw = eval { Cpanel::AdminBin::Call::call('Gniza', 'Restore', 'LIST_LOGS') } // '';
if ($raw =~ /^ERROR: (.*)/) {
print qq{<div class="alert alert-error mb-4">} . GnizaCPanel::UI::esc($1) . qq{</div>\n};
print GnizaCPanel::UI::page_footer();
print $cpanel->footer();
return;
}
my @entries;
for my $line (split /\n/, $raw) {
next if $line eq '';
my ($idx, $date, $action, $details, $status) = split /\t/, $line;
push @entries, {
idx => $idx // '',
date => $date // '',
action => $action // '',
details => $details // '',
status => $status // '',
};
}
if (!@entries) {
print qq{<div class="alert alert-info mb-4">No restore activity yet. Actions you perform in the Restore section will appear here.</div>\n};
print GnizaCPanel::UI::page_footer();
print $cpanel->footer();
return;
}
# Pagination
my $per_page = 25;
my $total = scalar @entries;
my $page = int($form->{'page'} // 1);
$page = 1 if $page < 1;
my $total_pages = int(($total + $per_page - 1) / $per_page);
$page = $total_pages if $page > $total_pages;
my $start = ($page - 1) * $per_page;
my $end = $start + $per_page - 1;
$end = $#entries if $end > $#entries;
my @page_entries = @entries[$start .. $end];
print qq{<div class="overflow-x-auto rounded-box border border-base-content/5 bg-base-100">\n};
print qq{<table class="table">\n};
print qq{<thead><tr><th>Date (UTC)</th><th>Action</th><th>Details</th><th>Status</th><th></th></tr></thead>\n};
print qq{<tbody>\n};
for my $e (@page_entries) {
my $esc_date = GnizaCPanel::UI::esc($e->{date});
my $esc_action = GnizaCPanel::UI::esc($e->{action});
my $esc_details = GnizaCPanel::UI::esc($e->{details});
my $esc_status = GnizaCPanel::UI::esc($e->{status});
my $esc_idx = GnizaCPanel::UI::esc($e->{idx});
my $status_badge = $e->{status} eq 'Error' ? 'badge-error' : 'badge-success';
my $href = 'logs.live.cgi?entry=' . _uri_escape($e->{idx});
print qq{<tr>\n};
print qq{ <td class="whitespace-nowrap">$esc_date</td>\n};
print qq{ <td><span class="badge badge-info badge-sm">$esc_action</span></td>\n};
print qq{ <td class="text-sm">$esc_details</td>\n};
print qq{ <td><span class="badge $status_badge badge-sm">$esc_status</span></td>\n};
print qq{ <td><button type="button" class="btn btn-secondary btn-sm" onclick="location.href='$href'">View</button></td>\n};
print qq{</tr>\n};
}
print qq{</tbody>\n</table>\n</div>\n};
# Pagination controls
if ($total_pages > 1) {
print qq{<div class="flex items-center justify-center gap-2 mt-4">\n};
if ($page > 1) {
my $prev = $page - 1;
print qq{ <button type="button" class="btn btn-sm" onclick="location.href='logs.live.cgi?page=$prev'">&laquo; Prev</button>\n};
}
print qq{ <span class="text-sm">Page $page of $total_pages ($total entries)</span>\n};
if ($page < $total_pages) {
my $next = $page + 1;
print qq{ <button type="button" class="btn btn-sm" onclick="location.href='logs.live.cgi?page=$next'">Next &raquo;</button>\n};
}
print qq{</div>\n};
}
print GnizaCPanel::UI::page_footer();
print $cpanel->footer();
}
# ── Entry Detail View ────────────────────────────────────────
sub show_entry {
my ($entry_idx) = @_;
print "Content-Type: text/html\r\n\r\n";
print $cpanel->header('');
print GnizaCPanel::UI::page_header('GNIZA Activity Detail');
print GnizaCPanel::UI::render_nav('logs.live.cgi');
# Validate entry index (numeric only)
unless ($entry_idx =~ /^[0-9]+$/) {
print qq{<div class="alert alert-error mb-4">Invalid entry.</div>\n};
print qq{<p><a href="logs.live.cgi" class="link">&larr; Back to activity log</a></p>\n};
print GnizaCPanel::UI::page_footer();
print $cpanel->footer();
return;
}
my $content = eval { Cpanel::AdminBin::Call::call('Gniza', 'Restore', 'GET_LOG', $entry_idx) } // '';
if ($content =~ /^ERROR: (.*)/) {
print qq{<div class="alert alert-error mb-4">} . GnizaCPanel::UI::esc($1) . qq{</div>\n};
print qq{<p><a href="logs.live.cgi" class="link">&larr; Back to activity log</a></p>\n};
print GnizaCPanel::UI::page_footer();
print $cpanel->footer();
return;
}
# Parse header fields and output from entry content
my ($date, $action, $details, $status, $output) = ('', '', '', '', '');
my @lines = split /\n/, $content;
my @output_lines;
my $in_output = 0;
for my $line (@lines) {
if (!$in_output) {
if ($line =~ /^Date:\s+(.+)$/) { $date = $1; }
elsif ($line =~ /^Action:\s+(.+)$/) { $action = $1; }
elsif ($line =~ /^Details:\s+(.+)$/) { $details = $1; }
elsif ($line =~ /^Status:\s+(.+)$/) { $status = $1; $in_output = 1; }
} else {
push @output_lines, $line;
}
}
# Back link
print qq{<p class="mb-4"><a href="logs.live.cgi" class="link">&larr; Back to activity log</a></p>\n};
# Entry info card
my $status_badge = $status eq 'Error' ? 'badge-error' : 'badge-success';
print qq{<div class="card bg-base-200 shadow-sm border border-base-300 mb-4">\n};
print qq{<div class="card-body py-3 px-4">\n};
print qq{<div class="flex flex-wrap gap-4 items-center text-sm">\n};
print qq{ <span><strong>Date:</strong> } . GnizaCPanel::UI::esc($date) . qq{</span>\n};
print qq{ <span><strong>Action:</strong> <span class="badge badge-info badge-sm">} . GnizaCPanel::UI::esc($action) . qq{</span></span>\n};
print qq{ <span><strong>Status:</strong> <span class="badge $status_badge badge-sm">} . GnizaCPanel::UI::esc($status) . qq{</span></span>\n};
print qq{</div>\n};
print qq{<div class="text-sm mt-2"><strong>Details:</strong> } . GnizaCPanel::UI::esc($details) . qq{</div>\n};
print qq{</div>\n</div>\n};
# Output section
if (@output_lines) {
print qq{<h3 class="text-sm font-bold mb-2">Command Output</h3>\n};
print qq{<pre class="bg-base-100 border border-base-300 rounded-lg p-4 text-sm font-mono overflow-x-auto leading-relaxed">};
for my $line (@output_lines) {
my $esc = GnizaCPanel::UI::esc($line);
if ($line =~ /\[ERROR\]/) {
print qq{<span class="text-error font-bold">$esc</span>\n};
} elsif ($line =~ /\[WARN\]/) {
print qq{<span class="text-warning">$esc</span>\n};
} elsif ($line =~ /\[DEBUG\]/) {
print qq{<span class="text-base-content/60">$esc</span>\n};
} else {
print "$esc\n";
}
}
print qq{</pre>\n};
} else {
print qq{<div class="alert alert-info">No output recorded for this action.</div>\n};
}
print GnizaCPanel::UI::page_footer();
print $cpanel->footer();
}
# ── Helpers ───────────────────────────────────────────────────
sub _uri_escape {
my $str = shift // '';
$str =~ s/([^A-Za-z0-9\-._~])/sprintf("%%%02X", ord($1))/ge;
return $str;
}
1;

View File

@@ -195,8 +195,9 @@ sub handle_step2 {
} }
print "Content-Type: text/html\r\n\r\n"; print "Content-Type: text/html\r\n\r\n";
print $cpanel->header('GNIZA Backups'); print $cpanel->header('');
print GnizaCPanel::UI::page_header('Restore Options'); print GnizaCPanel::UI::page_header('Restore Options');
print GnizaCPanel::UI::render_nav('restore.live.cgi');
print GnizaCPanel::UI::render_flash(); print GnizaCPanel::UI::render_flash();
my $esc_remote = GnizaCPanel::UI::esc($remote); my $esc_remote = GnizaCPanel::UI::esc($remote);
@@ -901,8 +902,9 @@ sub handle_step3 {
} }
print "Content-Type: text/html\r\n\r\n"; print "Content-Type: text/html\r\n\r\n";
print $cpanel->header('GNIZA Backups'); print $cpanel->header('');
print GnizaCPanel::UI::page_header('Restore: Confirm'); print GnizaCPanel::UI::page_header('Restore: Confirm');
print GnizaCPanel::UI::render_nav('restore.live.cgi');
print GnizaCPanel::UI::render_flash(); print GnizaCPanel::UI::render_flash();
my $esc_remote = GnizaCPanel::UI::esc($remote); my $esc_remote = GnizaCPanel::UI::esc($remote);
@@ -1020,8 +1022,9 @@ sub handle_step4 {
} }
print "Content-Type: text/html\r\n\r\n"; print "Content-Type: text/html\r\n\r\n";
print $cpanel->header('GNIZA Backups'); print $cpanel->header('');
print GnizaCPanel::UI::page_header('Restore Results'); print GnizaCPanel::UI::page_header('Restore Results');
print GnizaCPanel::UI::render_nav('restore.live.cgi');
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="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">Restore Results</h2>\n}; print qq{<h2 class="card-title text-sm">Restore Results</h2>\n};

View File

@@ -98,6 +98,7 @@ if [[ -d "$CPANEL_BASE" ]]; then
mkdir -p "$CPANEL_BASE/gniza/lib/GnizaCPanel" "$CPANEL_BASE/gniza/assets" mkdir -p "$CPANEL_BASE/gniza/lib/GnizaCPanel" "$CPANEL_BASE/gniza/assets"
cp "$SOURCE_DIR/cpanel/gniza/index.live.cgi" "$CPANEL_BASE/gniza/" cp "$SOURCE_DIR/cpanel/gniza/index.live.cgi" "$CPANEL_BASE/gniza/"
cp "$SOURCE_DIR/cpanel/gniza/restore.live.cgi" "$CPANEL_BASE/gniza/" cp "$SOURCE_DIR/cpanel/gniza/restore.live.cgi" "$CPANEL_BASE/gniza/"
cp "$SOURCE_DIR/cpanel/gniza/logs.live.cgi" "$CPANEL_BASE/gniza/"
chmod +x "$CPANEL_BASE/gniza/"*.cgi chmod +x "$CPANEL_BASE/gniza/"*.cgi
cp "$SOURCE_DIR/cpanel/gniza/lib/GnizaCPanel/UI.pm" "$CPANEL_BASE/gniza/lib/GnizaCPanel/" cp "$SOURCE_DIR/cpanel/gniza/lib/GnizaCPanel/UI.pm" "$CPANEL_BASE/gniza/lib/GnizaCPanel/"
cp "$SOURCE_DIR/cpanel/gniza/assets/gniza-whm.css" "$CPANEL_BASE/gniza/assets/" cp "$SOURCE_DIR/cpanel/gniza/assets/gniza-whm.css" "$CPANEL_BASE/gniza/assets/"

View File

@@ -52,17 +52,27 @@ sub show_list {
my @files; my @files;
if (opendir my $dh, $log_dir) { if (opendir my $dh, $log_dir) {
while (my $entry = readdir $dh) { while (my $entry = readdir $dh) {
next unless $entry =~ /^gniza-\d{8}-\d{6}\.log$/; next unless $entry =~ /^gniza-\d{8}-\d{6}\.log$/ || $entry =~ /^cpanel-[a-z][a-z0-9_-]*\.log$/;
my $path = "$log_dir/$entry"; my $path = "$log_dir/$entry";
next unless -f $path; next unless -f $path;
my @stat = stat($path); my @stat = stat($path);
my $status = _detect_log_status($path); my ($type, $status, $owner);
if ($entry =~ /^cpanel-([a-z][a-z0-9_-]*)\.log$/) {
$type = 'User';
$owner = $1;
$status = _detect_activity_status($path);
} else {
$type = _detect_log_type($path);
$status = _detect_log_status($path);
$owner = '';
}
push @files, { push @files, {
name => $entry, name => $entry,
size => $stat[7] // 0, size => $stat[7] // 0,
mtime => $stat[9] // 0, mtime => $stat[9] // 0,
type => _detect_log_type($path), type => $type,
status => $status, status => $status,
owner => $owner,
}; };
} }
closedir $dh; closedir $dh;
@@ -91,13 +101,14 @@ sub show_list {
print qq{<div class="overflow-x-auto rounded-box border border-base-content/5 bg-base-100">\n}; print qq{<div class="overflow-x-auto rounded-box border border-base-content/5 bg-base-100">\n};
print qq{<table class="table">\n}; print qq{<table class="table">\n};
print qq{<thead><tr><th>Filename</th><th>Type</th><th>Status</th><th>Date</th><th>Size</th><th></th></tr></thead>\n}; print qq{<thead><tr><th>Filename</th><th>Type</th><th>Owner</th><th>Status</th><th>Date</th><th>Size</th><th></th></tr></thead>\n};
print qq{<tbody>\n}; print qq{<tbody>\n};
for my $f (@page_files) { for my $f (@page_files) {
my $esc_name = GnizaWHM::UI::esc($f->{name}); my $esc_name = GnizaWHM::UI::esc($f->{name});
my $badge = $f->{type} eq 'Cron' ? 'badge-neutral' my $badge = $f->{type} eq 'Cron' ? 'badge-neutral'
: $f->{type} eq 'System' ? 'badge-warning' : $f->{type} eq 'System' ? 'badge-warning'
: $f->{type} eq 'User' ? 'badge-secondary'
: 'badge-info'; : 'badge-info';
my $date = _format_time($f->{mtime}); my $date = _format_time($f->{mtime});
my $size = _human_size($f->{size}); my $size = _human_size($f->{size});
@@ -106,6 +117,8 @@ sub show_list {
print qq{<tr>\n}; print qq{<tr>\n};
print qq{ <td><code>$esc_name</code></td>\n}; print qq{ <td><code>$esc_name</code></td>\n};
print qq{ <td><span class="badge $badge badge-sm">$f->{type}</span></td>\n}; print qq{ <td><span class="badge $badge badge-sm">$f->{type}</span></td>\n};
my $esc_owner = GnizaWHM::UI::esc($f->{owner} // '');
print qq{ <td>$esc_owner</td>\n};
my $status_badge = $f->{status} eq 'Error' ? 'badge-error' my $status_badge = $f->{status} eq 'Error' ? 'badge-error'
: $f->{status} eq 'Warning' ? 'badge-warning' : $f->{status} eq 'Warning' ? 'badge-warning'
: 'badge-success'; : 'badge-success';
@@ -181,6 +194,12 @@ sub show_file {
return; return;
} }
# Detect activity log vs standard log
if ($filename =~ /^cpanel-([a-z][a-z0-9_-]*)\.log$/) {
_show_activity_file($filename, $filepath, $1);
return;
}
# Read file # Read file
my @lines; my @lines;
if (open my $fh, '<', $filepath) { if (open my $fh, '<', $filepath) {
@@ -313,6 +332,133 @@ sub show_file {
print GnizaWHM::UI::page_footer(); print GnizaWHM::UI::page_footer();
} }
# ── Activity Log Viewer (cpanel-*.log) ───────────────────────
sub _show_activity_file {
my ($filename, $filepath, $owner) = @_;
my $esc_name = GnizaWHM::UI::esc($filename);
my $esc_owner = GnizaWHM::UI::esc($owner);
my @stat = stat($filepath);
my $file_size = _human_size($stat[7] // 0);
my $file_date = _format_time($stat[9] // 0);
# Back link
print qq{<p class="mb-4"><a href="logs.cgi" class="link">&larr; Back to logs</a></p>\n};
# File info card
print qq{<div class="card bg-base-200 shadow-sm border border-base-300 mb-4">\n};
print qq{<div class="card-body py-3 px-4">\n};
print qq{<div class="flex flex-wrap gap-3 items-center text-sm">\n};
print qq{ <span><strong>User:</strong> $esc_owner</span>\n};
print qq{ <span><strong>File:</strong> <code>$esc_name</code></span>\n};
print qq{ <span><strong>Size:</strong> $file_size</span>\n};
print qq{ <span><strong>Last Modified:</strong> $file_date</span>\n};
print qq{</div>\n</div>\n</div>\n};
# Parse activity entries
my @entries;
if (open my $fh, '<', $filepath) {
my $in_entry = 0;
my (%cur, @cur_output);
while (my $line = <$fh>) {
chomp $line;
if ($line eq '--- ENTRY ---') {
$in_entry = 1;
%cur = ();
@cur_output = ();
} elsif ($line eq '--- END ---' && $in_entry) {
$cur{output} = join("\n", @cur_output) if @cur_output;
push @entries, { %cur } if $cur{date};
$in_entry = 0;
} elsif ($in_entry) {
if ($line =~ /^Date:\s+(.+)$/) { $cur{date} = $1; }
elsif ($line =~ /^Action:\s+(.+)$/) { $cur{action} = $1; }
elsif ($line =~ /^Details:\s+(.+)$/) { $cur{details} = $1; }
elsif ($line =~ /^Status:\s+(.+)$/) { $cur{status} = $1; }
else { push @cur_output, $line; }
}
}
close $fh;
}
# Show newest first
@entries = reverse @entries;
if (!@entries) {
print qq{<div class="alert alert-info">No activity entries found.</div>\n};
print GnizaWHM::UI::page_footer();
return;
}
# Pagination
my $per_page = 25;
my $total = scalar @entries;
my $page = int($form->{'page'} // 1);
$page = 1 if $page < 1;
my $total_pages = int(($total + $per_page - 1) / $per_page);
$page = $total_pages if $page > $total_pages;
my $start = ($page - 1) * $per_page;
my $end = $start + $per_page - 1;
$end = $#entries if $end > $#entries;
my @page_entries = @entries[$start .. $end];
for my $e (@page_entries) {
my $status_badge = ($e->{status} // '') eq 'Error' ? 'badge-error' : 'badge-success';
my $esc_date = GnizaWHM::UI::esc($e->{date} // '');
my $esc_action = GnizaWHM::UI::esc($e->{action} // '');
my $esc_details = GnizaWHM::UI::esc($e->{details} // '');
my $esc_status = GnizaWHM::UI::esc($e->{status} // '');
print qq{<div class="card bg-base-100 border border-base-300 mb-3">\n};
print qq{<div class="card-body py-3 px-4">\n};
print qq{<div class="flex flex-wrap gap-3 items-center text-sm mb-1">\n};
print qq{ <span>$esc_date</span>\n};
print qq{ <span class="badge badge-info badge-sm">$esc_action</span>\n};
print qq{ <span class="badge $status_badge badge-sm">$esc_status</span>\n};
print qq{</div>\n};
print qq{<div class="text-sm text-base-content/70">$esc_details</div>\n};
if ($e->{output} && $e->{output} ne '') {
print qq{<details class="mt-2">\n};
print qq{<summary class="cursor-pointer text-sm font-medium">Command Output</summary>\n};
print qq{<pre class="bg-base-200 rounded p-3 mt-1 text-xs font-mono overflow-x-auto leading-relaxed">};
for my $oline (split /\n/, $e->{output}) {
my $esc = GnizaWHM::UI::esc($oline);
if ($oline =~ /\[ERROR\]/) {
print qq{<span class="text-error font-bold">$esc</span>\n};
} elsif ($oline =~ /\[WARN\]/) {
print qq{<span class="text-warning">$esc</span>\n};
} else {
print "$esc\n";
}
}
print qq{</pre>\n};
print qq{</details>\n};
}
print qq{</div>\n</div>\n};
}
# Pagination controls
if ($total_pages > 1) {
my $base = 'logs.cgi?file=' . _uri_escape($filename);
print qq{<div class="flex items-center justify-center gap-2 mt-4">\n};
if ($page > 1) {
my $prev = $page - 1;
print qq{ <button type="button" class="btn btn-sm" onclick="location.href='$base&page=$prev'">&laquo; Prev</button>\n};
}
print qq{ <span class="text-sm">Page $page of $total_pages ($total entries)</span>\n};
if ($page < $total_pages) {
my $next = $page + 1;
print qq{ <button type="button" class="btn btn-sm" onclick="location.href='$base&page=$next'">Next &raquo;</button>\n};
}
print qq{</div>\n};
}
print GnizaWHM::UI::page_footer();
}
# ── Helpers ─────────────────────────────────────────────────── # ── Helpers ───────────────────────────────────────────────────
sub _valid_log_filename { sub _valid_log_filename {
@@ -320,9 +466,26 @@ sub _valid_log_filename {
return 0 unless defined $name && $name ne ''; return 0 unless defined $name && $name ne '';
return 1 if $name =~ /^gniza-\d{8}-\d{6}\.log$/; return 1 if $name =~ /^gniza-\d{8}-\d{6}\.log$/;
return 1 if $name =~ /^cron-[a-zA-Z0-9_-]+\.log$/; return 1 if $name =~ /^cron-[a-zA-Z0-9_-]+\.log$/;
return 1 if $name =~ /^cpanel-[a-z][a-z0-9_-]*\.log$/;
return 0; return 0;
} }
sub _detect_activity_status {
my ($path) = @_;
return 'Success' unless -f $path;
my $has_error = 0;
if (open my $fh, '<', $path) {
while (my $line = <$fh>) {
if ($line =~ /^Status:\s+Error$/) {
$has_error = 1;
last;
}
}
close $fh;
}
return $has_error ? 'Error' : 'Success';
}
sub _detect_log_type { sub _detect_log_type {
my ($path) = @_; my ($path) = @_;
if (open my $fh, '<', $path) { if (open my $fh, '<', $path) {