diff --git a/cpanel/admin/Gniza/Restore b/cpanel/admin/Gniza/Restore index 9e6cc30..f8f3e98 100644 --- a/cpanel/admin/Gniza/Restore +++ b/cpanel/admin/Gniza/Restore @@ -108,6 +108,64 @@ sub _run_gniza { # ── 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 { return qw( LIST_ALLOWED_REMOTES @@ -119,6 +177,8 @@ sub _actions { LIST_CRON LIST_DNS LIST_SSL + LIST_LOGS + GET_LOG RESTORE_ACCOUNT RESTORE_FILES 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 { my ($self) = @_; my @remotes = _get_filtered_remotes(); @@ -267,7 +402,12 @@ sub RESTORE_ACCOUNT { 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); + _log_activity($user, 'RESTORE_ACCOUNT', $details, + $ok ? 'OK' : 'Error', $ok ? $stdout : $stderr); return $ok ? "OK\n$stdout" : "ERROR: $stderr"; } @@ -290,7 +430,13 @@ sub RESTORE_FILES { 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); + _log_activity($user, 'RESTORE_FILES', $details, + $ok ? 'OK' : 'Error', $ok ? $stdout : $stderr); return $ok ? "OK\n$stdout" : "ERROR: $stderr"; } @@ -309,8 +455,13 @@ sub RESTORE_DATABASE { 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, "--remote=$remote", "--timestamp=$timestamp"); + _log_activity($user, 'RESTORE_DATABASE', $details, + $ok ? 'OK' : 'Error', $ok ? $stdout : $stderr); return $ok ? "OK\n$stdout" : "ERROR: $stderr"; } @@ -329,8 +480,13 @@ sub RESTORE_MAILBOX { 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, "--remote=$remote", "--timestamp=$timestamp"); + _log_activity($user, 'RESTORE_MAILBOX', $details, + $ok ? 'OK' : 'Error', $ok ? $stdout : $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: Remote not allowed" unless _is_remote_allowed($remote); + my $details = "remote=$remote snapshot=$timestamp"; + my ($ok, $stdout, $stderr) = _run_gniza('restore', 'cron', $user, "--remote=$remote", "--timestamp=$timestamp"); + _log_activity($user, 'RESTORE_CRON', $details, + $ok ? 'OK' : 'Error', $ok ? $stdout : $stderr); return $ok ? "OK\n$stdout" : "ERROR: $stderr"; } @@ -363,8 +523,13 @@ sub RESTORE_DBUSERS { 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, "--remote=$remote", "--timestamp=$timestamp"); + _log_activity($user, 'RESTORE_DBUSERS', $details, + $ok ? 'OK' : 'Error', $ok ? $stdout : $stderr); return $ok ? "OK\n$stdout" : "ERROR: $stderr"; } @@ -383,8 +548,13 @@ sub RESTORE_DOMAINS { 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, "--remote=$remote", "--timestamp=$timestamp"); + _log_activity($user, 'RESTORE_DOMAINS', $details, + $ok ? 'OK' : 'Error', $ok ? $stdout : $stderr); return $ok ? "OK\n$stdout" : "ERROR: $stderr"; } @@ -403,8 +573,13 @@ sub RESTORE_SSL { 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, "--remote=$remote", "--timestamp=$timestamp"); + _log_activity($user, 'RESTORE_SSL', $details, + $ok ? 'OK' : 'Error', $ok ? $stdout : $stderr); return $ok ? "OK\n$stdout" : "ERROR: $stderr"; } diff --git a/cpanel/gniza/assets/gniza-logo.svg b/cpanel/gniza/assets/gniza-logo.svg index cc4dc5a..1b173c6 100644 --- a/cpanel/gniza/assets/gniza-logo.svg +++ b/cpanel/gniza/assets/gniza-logo.svg @@ -1,25 +1,11 @@ - - - - - - - - - - - - - - - - - - - - - - -GNIZA Backup + + + + + + + + + diff --git a/cpanel/gniza/index.live.cgi b/cpanel/gniza/index.live.cgi index 5f80e24..364820c 100644 --- a/cpanel/gniza/index.live.cgi +++ b/cpanel/gniza/index.live.cgi @@ -20,13 +20,14 @@ use GnizaCPanel::UI; my $cpanel = Cpanel::LiveAPI->new(); print "Content-Type: text/html\r\n\r\n"; -print $cpanel->header('GNIZA Backups'); +print $cpanel->header(''); # Get allowed remotes via AdminBin my $remotes_raw = eval { Cpanel::AdminBin::Call::call('Gniza', 'Restore', 'LIST_ALLOWED_REMOTES') } // ''; my @remotes = grep { $_ ne '' } split /\n/, $remotes_raw; print GnizaCPanel::UI::page_header('GNIZA Backups'); +print GnizaCPanel::UI::render_nav('index.live.cgi'); print GnizaCPanel::UI::render_flash(); if (!@remotes) { diff --git a/cpanel/gniza/logs.live.cgi b/cpanel/gniza/logs.live.cgi new file mode 100644 index 0000000..f9394c8 --- /dev/null +++ b/cpanel/gniza/logs.live.cgi @@ -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{
} . GnizaCPanel::UI::esc($1) . qq{
\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{
No restore activity yet. Actions you perform in the Restore section will appear here.
\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{
\n}; + print qq{\n}; + print qq{\n}; + print qq{\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{\n}; + print qq{ \n}; + print qq{ \n}; + print qq{ \n}; + print qq{ \n}; + print qq{ \n}; + print qq{\n}; + } + + print qq{\n
Date (UTC)ActionDetailsStatus
$esc_date$esc_action$esc_details$esc_status
\n
\n}; + + # Pagination controls + if ($total_pages > 1) { + print qq{
\n}; + if ($page > 1) { + my $prev = $page - 1; + print qq{ \n}; + } + print qq{ Page $page of $total_pages ($total entries)\n}; + if ($page < $total_pages) { + my $next = $page + 1; + print qq{ \n}; + } + print qq{
\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{
Invalid entry.
\n}; + print qq{

← Back to activity log

\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{
} . GnizaCPanel::UI::esc($1) . qq{
\n}; + print qq{

← Back to activity log

\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{

← Back to activity log

\n}; + + # Entry info card + my $status_badge = $status eq 'Error' ? 'badge-error' : 'badge-success'; + print qq{
\n}; + print qq{
\n}; + print qq{
\n}; + print qq{ Date: } . GnizaCPanel::UI::esc($date) . qq{\n}; + print qq{ Action: } . GnizaCPanel::UI::esc($action) . qq{\n}; + print qq{ Status: } . GnizaCPanel::UI::esc($status) . qq{\n}; + print qq{
\n}; + print qq{
Details: } . GnizaCPanel::UI::esc($details) . qq{
\n}; + print qq{
\n
\n}; + + # Output section + if (@output_lines) { + print qq{

Command Output

\n}; + print qq{
};
+        for my $line (@output_lines) {
+            my $esc = GnizaCPanel::UI::esc($line);
+            if ($line =~ /\[ERROR\]/) {
+                print qq{$esc\n};
+            } elsif ($line =~ /\[WARN\]/) {
+                print qq{$esc\n};
+            } elsif ($line =~ /\[DEBUG\]/) {
+                print qq{$esc\n};
+            } else {
+                print "$esc\n";
+            }
+        }
+        print qq{
\n}; + } else { + print qq{
No output recorded for this action.
\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; diff --git a/cpanel/gniza/restore.live.cgi b/cpanel/gniza/restore.live.cgi index 9edb1c3..717f1d5 100644 --- a/cpanel/gniza/restore.live.cgi +++ b/cpanel/gniza/restore.live.cgi @@ -195,8 +195,9 @@ sub handle_step2 { } 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::render_nav('restore.live.cgi'); print GnizaCPanel::UI::render_flash(); my $esc_remote = GnizaCPanel::UI::esc($remote); @@ -901,8 +902,9 @@ sub handle_step3 { } 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::render_nav('restore.live.cgi'); print GnizaCPanel::UI::render_flash(); my $esc_remote = GnizaCPanel::UI::esc($remote); @@ -1020,8 +1022,9 @@ sub handle_step4 { } 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::render_nav('restore.live.cgi'); print qq{
\n
\n}; print qq{

Restore Results

\n}; diff --git a/scripts/install.sh b/scripts/install.sh index 5af5f6e..ec3668b 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -98,6 +98,7 @@ if [[ -d "$CPANEL_BASE" ]]; then 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/restore.live.cgi" "$CPANEL_BASE/gniza/" + cp "$SOURCE_DIR/cpanel/gniza/logs.live.cgi" "$CPANEL_BASE/gniza/" 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/assets/gniza-whm.css" "$CPANEL_BASE/gniza/assets/" diff --git a/whm/gniza-whm/logs.cgi b/whm/gniza-whm/logs.cgi index d69cfb6..0f90378 100755 --- a/whm/gniza-whm/logs.cgi +++ b/whm/gniza-whm/logs.cgi @@ -52,17 +52,27 @@ sub show_list { my @files; if (opendir my $dh, $log_dir) { 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"; next unless -f $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, { name => $entry, size => $stat[7] // 0, mtime => $stat[9] // 0, - type => _detect_log_type($path), + type => $type, status => $status, + owner => $owner, }; } closedir $dh; @@ -91,13 +101,14 @@ sub show_list { print qq{
\n}; print qq{\n}; - print qq{\n}; + print qq{\n}; print qq{\n}; for my $f (@page_files) { my $esc_name = GnizaWHM::UI::esc($f->{name}); my $badge = $f->{type} eq 'Cron' ? 'badge-neutral' : $f->{type} eq 'System' ? 'badge-warning' + : $f->{type} eq 'User' ? 'badge-secondary' : 'badge-info'; my $date = _format_time($f->{mtime}); my $size = _human_size($f->{size}); @@ -106,6 +117,8 @@ sub show_list { print qq{\n}; print qq{ \n}; print qq{ \n}; + my $esc_owner = GnizaWHM::UI::esc($f->{owner} // ''); + print qq{ \n}; my $status_badge = $f->{status} eq 'Error' ? 'badge-error' : $f->{status} eq 'Warning' ? 'badge-warning' : 'badge-success'; @@ -181,6 +194,12 @@ sub show_file { 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 my @lines; if (open my $fh, '<', $filepath) { @@ -313,6 +332,133 @@ sub show_file { 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{

← Back to logs

\n}; + + # File info card + print qq{
\n}; + print qq{
\n}; + print qq{
\n}; + print qq{ User: $esc_owner\n}; + print qq{ File: $esc_name\n}; + print qq{ Size: $file_size\n}; + print qq{ Last Modified: $file_date\n}; + print qq{
\n
\n
\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{
No activity entries found.
\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{
\n}; + print qq{
\n}; + print qq{
\n}; + print qq{ $esc_date\n}; + print qq{ $esc_action\n}; + print qq{ $esc_status\n}; + print qq{
\n}; + print qq{
$esc_details
\n}; + + if ($e->{output} && $e->{output} ne '') { + print qq{
\n}; + print qq{Command Output\n}; + print qq{
};
+            for my $oline (split /\n/, $e->{output}) {
+                my $esc = GnizaWHM::UI::esc($oline);
+                if ($oline =~ /\[ERROR\]/) {
+                    print qq{$esc\n};
+                } elsif ($oline =~ /\[WARN\]/) {
+                    print qq{$esc\n};
+                } else {
+                    print "$esc\n";
+                }
+            }
+            print qq{
\n}; + print qq{
\n}; + } + + print qq{
\n
\n}; + } + + # Pagination controls + if ($total_pages > 1) { + my $base = 'logs.cgi?file=' . _uri_escape($filename); + print qq{
\n}; + if ($page > 1) { + my $prev = $page - 1; + print qq{ \n}; + } + print qq{ Page $page of $total_pages ($total entries)\n}; + if ($page < $total_pages) { + my $next = $page + 1; + print qq{ \n}; + } + print qq{
\n}; + } + + print GnizaWHM::UI::page_footer(); +} + # ── Helpers ─────────────────────────────────────────────────── sub _valid_log_filename { @@ -320,9 +466,26 @@ sub _valid_log_filename { return 0 unless defined $name && $name ne ''; return 1 if $name =~ /^gniza-\d{8}-\d{6}\.log$/; return 1 if $name =~ /^cron-[a-zA-Z0-9_-]+\.log$/; + return 1 if $name =~ /^cpanel-[a-z][a-z0-9_-]*\.log$/; 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 { my ($path) = @_; if (open my $fh, '<', $path) {
FilenameTypeStatusDateSize
FilenameTypeOwnerStatusDateSize
$esc_name$f->{type}$esc_owner