Add Logs tab to WHM plugin for viewing activity logs
Read-only log viewer with file list (sorted by mtime), per-file viewer with level-based coloring (ERROR/WARN/INFO/DEBUG), level filter buttons, cron log truncation (last 500 lines default), and path traversal protection. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -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-primary btn-secondary btn-sm btn-xs card card-body card-title checkbox checkbox-sm 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.7rem]"></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-primary btn-secondary btn-sm btn-xs card card-body card-title checkbox checkbox-sm 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.7rem] text-warning badge-info badge-neutral btn-active leading-relaxed"></div>
|
||||
|
||||
@@ -40,6 +40,7 @@ my @NAV_ITEMS = (
|
||||
{ url => 'remotes.cgi', label => 'Remotes' },
|
||||
{ url => 'schedules.cgi', label => 'Schedules' },
|
||||
{ url => 'restore.cgi', label => 'Restore' },
|
||||
{ url => 'logs.cgi', label => 'Logs' },
|
||||
{ url => 'settings.cgi', label => 'Settings' },
|
||||
);
|
||||
|
||||
|
||||
277
whm/gniza-whm/logs.cgi
Executable file
277
whm/gniza-whm/logs.cgi
Executable file
@@ -0,0 +1,277 @@
|
||||
#!/usr/local/cpanel/3rdparty/bin/perl
|
||||
# gniza WHM Plugin — Activity Logs
|
||||
# View backup and cron log files (read-only)
|
||||
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::UI;
|
||||
|
||||
my $form = Cpanel::Form::parseform();
|
||||
|
||||
# Determine log directory from config
|
||||
my $log_dir = '/var/log/gniza';
|
||||
my $main_conf = '/etc/gniza/gniza.conf';
|
||||
if (-f $main_conf) {
|
||||
my $cfg = GnizaWHM::Config::parse($main_conf, 'main');
|
||||
$log_dir = $cfg->{LOG_DIR} if $cfg->{LOG_DIR} && $cfg->{LOG_DIR} ne '';
|
||||
}
|
||||
|
||||
my $file = $form->{'file'} // '';
|
||||
|
||||
if ($file ne '') {
|
||||
show_file($file);
|
||||
} else {
|
||||
show_list();
|
||||
}
|
||||
|
||||
exit;
|
||||
|
||||
# ── File List View ────────────────────────────────────────────
|
||||
|
||||
sub show_list {
|
||||
print "Content-Type: text/html\r\n\r\n";
|
||||
Whostmgr::HTMLInterface::defheader('gniza — Logs');
|
||||
print GnizaWHM::UI::page_header('gniza Backup Manager');
|
||||
print GnizaWHM::UI::render_nav('logs.cgi');
|
||||
print GnizaWHM::UI::render_flash();
|
||||
|
||||
unless (-d $log_dir) {
|
||||
print qq{<div class="alert alert-info mb-4">No log directory found at <code>}
|
||||
. GnizaWHM::UI::esc($log_dir)
|
||||
. qq{</code>.</div>\n};
|
||||
print GnizaWHM::UI::page_footer();
|
||||
return;
|
||||
}
|
||||
|
||||
# Read log files
|
||||
my @files;
|
||||
if (opendir my $dh, $log_dir) {
|
||||
while (my $entry = readdir $dh) {
|
||||
next unless _valid_log_filename($entry);
|
||||
my $path = "$log_dir/$entry";
|
||||
next unless -f $path;
|
||||
my @stat = stat($path);
|
||||
push @files, {
|
||||
name => $entry,
|
||||
size => $stat[7] // 0,
|
||||
mtime => $stat[9] // 0,
|
||||
type => ($entry =~ /^cron-/) ? 'Cron' : 'Backup',
|
||||
};
|
||||
}
|
||||
closedir $dh;
|
||||
}
|
||||
|
||||
# Sort by mtime descending
|
||||
@files = sort { $b->{mtime} <=> $a->{mtime} } @files;
|
||||
|
||||
if (!@files) {
|
||||
print qq{<div class="alert alert-info mb-4">No log files found.</div>\n};
|
||||
print GnizaWHM::UI::page_footer();
|
||||
return;
|
||||
}
|
||||
|
||||
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>Filename</th><th>Type</th><th>Date</th><th>Size</th><th></th></tr></thead>\n};
|
||||
print qq{<tbody>\n};
|
||||
|
||||
for my $f (@files) {
|
||||
my $esc_name = GnizaWHM::UI::esc($f->{name});
|
||||
my $badge = $f->{type} eq 'Cron' ? 'badge-neutral' : 'badge-info';
|
||||
my $date = _format_time($f->{mtime});
|
||||
my $size = _human_size($f->{size});
|
||||
my $href = 'logs.cgi?file=' . _uri_escape($f->{name});
|
||||
|
||||
print qq{<tr>\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>$date</td>\n};
|
||||
print qq{ <td>$size</td>\n};
|
||||
print qq{ <td><a href="$href" class="btn btn-ghost btn-xs">View</a></td>\n};
|
||||
print qq{</tr>\n};
|
||||
}
|
||||
|
||||
print qq{</tbody>\n</table>\n</div>\n};
|
||||
print GnizaWHM::UI::page_footer();
|
||||
}
|
||||
|
||||
# ── File View ─────────────────────────────────────────────────
|
||||
|
||||
sub show_file {
|
||||
my ($filename) = @_;
|
||||
|
||||
print "Content-Type: text/html\r\n\r\n";
|
||||
Whostmgr::HTMLInterface::defheader('gniza — Log Viewer');
|
||||
print GnizaWHM::UI::page_header('gniza Backup Manager');
|
||||
print GnizaWHM::UI::render_nav('logs.cgi');
|
||||
|
||||
# Validate filename (prevents path traversal)
|
||||
unless (_valid_log_filename($filename)) {
|
||||
print qq{<div class="alert alert-error mb-4">Invalid log filename.</div>\n};
|
||||
print qq{<p><a href="logs.cgi" class="link">← Back to logs</a></p>\n};
|
||||
print GnizaWHM::UI::page_footer();
|
||||
return;
|
||||
}
|
||||
|
||||
my $filepath = "$log_dir/$filename";
|
||||
unless (-f $filepath) {
|
||||
print qq{<div class="alert alert-error mb-4">Log file not found.</div>\n};
|
||||
print qq{<p><a href="logs.cgi" class="link">← Back to logs</a></p>\n};
|
||||
print GnizaWHM::UI::page_footer();
|
||||
return;
|
||||
}
|
||||
|
||||
# Read file
|
||||
my @lines;
|
||||
if (open my $fh, '<', $filepath) {
|
||||
@lines = <$fh>;
|
||||
close $fh;
|
||||
}
|
||||
chomp @lines;
|
||||
|
||||
my $total_lines = scalar @lines;
|
||||
my $is_cron = ($filename =~ /^cron-/);
|
||||
my $show_all = ($form->{'all'} // '') eq '1';
|
||||
my $truncated = 0;
|
||||
my $max_lines = 10_000;
|
||||
my $cron_default = 500;
|
||||
|
||||
# Apply line limits
|
||||
if ($is_cron && !$show_all && $total_lines > $cron_default) {
|
||||
@lines = @lines[-$cron_default .. -1];
|
||||
$truncated = 1;
|
||||
}
|
||||
if (@lines > $max_lines) {
|
||||
@lines = @lines[-$max_lines .. -1];
|
||||
$truncated = 1;
|
||||
}
|
||||
|
||||
# Level filter
|
||||
my $level_filter = $form->{'level'} // '';
|
||||
$level_filter =~ s/\s+//g;
|
||||
$level_filter = '' unless $level_filter =~ /^(error|warn|info|debug)$/i;
|
||||
$level_filter = uc($level_filter) if $level_filter;
|
||||
|
||||
# File info
|
||||
my @stat = stat($filepath);
|
||||
my $file_size = _human_size($stat[7] // 0);
|
||||
my $file_date = _format_time($stat[9] // 0);
|
||||
my $esc_name = GnizaWHM::UI::esc($filename);
|
||||
|
||||
# Back link
|
||||
print qq{<p class="mb-4"><a href="logs.cgi" class="link">← 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>File:</strong> <code>$esc_name</code></span>\n};
|
||||
print qq{ <span><strong>Size:</strong> $file_size</span>\n};
|
||||
print qq{ <span><strong>Lines:</strong> $total_lines</span>\n};
|
||||
print qq{ <span><strong>Date:</strong> $file_date</span>\n};
|
||||
print qq{</div>\n</div>\n</div>\n};
|
||||
|
||||
# Truncation alert
|
||||
if ($truncated) {
|
||||
my $all_href = 'logs.cgi?file=' . _uri_escape($filename) . '&all=1';
|
||||
$all_href .= '&level=' . lc($level_filter) if $level_filter;
|
||||
print qq{<div class="alert alert-warning mb-4">Showing last }
|
||||
. scalar(@lines)
|
||||
. qq{ of $total_lines lines. }
|
||||
. qq{<a href="$all_href" class="link font-bold">View all</a></div>\n};
|
||||
}
|
||||
|
||||
# Level filter buttons
|
||||
my $base_href = 'logs.cgi?file=' . _uri_escape($filename);
|
||||
$base_href .= '&all=1' if $show_all;
|
||||
|
||||
print qq{<div class="flex gap-1 mb-4">\n};
|
||||
my @levels = ('', 'error', 'warn', 'info', 'debug');
|
||||
my %level_labels = ('' => 'All', error => 'Error', warn => 'Warn', info => 'Info', debug => 'Debug');
|
||||
for my $lv (@levels) {
|
||||
my $active = '';
|
||||
if ($lv eq '' && $level_filter eq '') {
|
||||
$active = ' btn-active';
|
||||
} elsif ($lv ne '' && uc($lv) eq $level_filter) {
|
||||
$active = ' btn-active';
|
||||
}
|
||||
my $href = $base_href;
|
||||
$href .= "&level=$lv" if $lv ne '';
|
||||
my $label = $level_labels{$lv};
|
||||
print qq{ <a href="$href" class="btn btn-sm$active">$label</a>\n};
|
||||
}
|
||||
print qq{</div>\n};
|
||||
|
||||
# Filter lines by level
|
||||
my @display_lines;
|
||||
if ($level_filter) {
|
||||
@display_lines = grep { /\[$level_filter\]/ } @lines;
|
||||
} else {
|
||||
@display_lines = @lines;
|
||||
}
|
||||
|
||||
# Render log content
|
||||
if (!@display_lines) {
|
||||
print qq{<div class="alert alert-info">No matching log entries.</div>\n};
|
||||
} else {
|
||||
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 (@display_lines) {
|
||||
my $esc = GnizaWHM::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};
|
||||
}
|
||||
|
||||
print GnizaWHM::UI::page_footer();
|
||||
}
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────
|
||||
|
||||
sub _valid_log_filename {
|
||||
my ($name) = @_;
|
||||
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 0;
|
||||
}
|
||||
|
||||
sub _uri_escape {
|
||||
my $str = shift // '';
|
||||
$str =~ s/([^A-Za-z0-9\-._~])/sprintf("%%%02X", ord($1))/ge;
|
||||
return $str;
|
||||
}
|
||||
|
||||
sub _format_time {
|
||||
my ($epoch) = @_;
|
||||
return '' unless $epoch;
|
||||
my @t = localtime($epoch);
|
||||
return sprintf('%04d-%02d-%02d %02d:%02d', $t[5]+1900, $t[4]+1, $t[3], $t[2], $t[1]);
|
||||
}
|
||||
|
||||
sub _human_size {
|
||||
my ($bytes) = @_;
|
||||
return '0 B' unless $bytes;
|
||||
my @units = ('B', 'KB', 'MB', 'GB');
|
||||
my $i = 0;
|
||||
my $size = $bytes;
|
||||
while ($size >= 1024 && $i < $#units) {
|
||||
$size /= 1024;
|
||||
$i++;
|
||||
}
|
||||
return ($i == 0) ? "$size B" : sprintf('%.1f %s', $size, $units[$i]);
|
||||
}
|
||||
|
||||
1;
|
||||
Reference in New Issue
Block a user