- CLI binary: bin/gniza -> bin/gniza4cp - Install path: /usr/local/gniza4cp/ - Config path: /etc/gniza4cp/ - Log path: /var/log/gniza4cp/ - WHM plugin: gniza4cp-whm/ - cPanel plugin: cpanel/gniza4cp/ - AdminBin: Gniza4cp::Restore - Perl modules: Gniza4cpWHM::*, Gniza4cpCPanel::* - DaisyUI theme: gniza4cp - All internal references, branding, paths updated - Git remote updated to gniza4cp repo
435 lines
19 KiB
Perl
435 lines
19 KiB
Perl
#!/usr/local/cpanel/3rdparty/bin/perl
|
|
# gniza4cp WHM Plugin — Dashboard
|
|
use strict;
|
|
use warnings;
|
|
|
|
use lib '/usr/local/cpanel/whostmgr/docroot/cgi/gniza4cp-whm/lib';
|
|
|
|
use Whostmgr::HTMLInterface ();
|
|
use Cpanel::Form ();
|
|
use Gniza4cpWHM::Config;
|
|
use Gniza4cpWHM::Cron;
|
|
use Gniza4cpWHM::Runner;
|
|
use Gniza4cpWHM::UI;
|
|
|
|
my $form = Cpanel::Form::parseform();
|
|
|
|
# Redirect to setup wizard if gniza4cp is not configured
|
|
unless (Gniza4cpWHM::UI::is_configured()) {
|
|
if (($form->{'action'} // '') eq 'status') {
|
|
print "Content-Type: application/json\r\n\r\n";
|
|
print '{"running":false,"pid":"","log_file":"","lines":[]}';
|
|
exit;
|
|
}
|
|
print "Status: 302 Found\r\n";
|
|
print "Location: setup.cgi\r\n\r\n";
|
|
exit;
|
|
}
|
|
|
|
# JSON status endpoint for AJAX polling
|
|
if (($form->{'action'} // '') eq 'status') {
|
|
handle_status_json();
|
|
exit;
|
|
}
|
|
|
|
# Refresh stats endpoint
|
|
if (($form->{'action'} // '') eq 'refresh_stats' && ($ENV{'REQUEST_METHOD'} // '') eq 'POST') {
|
|
unless (Gniza4cpWHM::UI::verify_csrf_token($form->{'gniza4cp_csrf'} // '')) {
|
|
Gniza4cpWHM::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) = Gniza4cpWHM::Runner::run('stats', undef, [], {});
|
|
if ($ok) {
|
|
Gniza4cpWHM::UI::set_flash('success', 'Statistics refreshed successfully.');
|
|
} else {
|
|
Gniza4cpWHM::UI::set_flash('error', 'Failed to refresh statistics: ' . Gniza4cpWHM::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('GNIZA4CP Backup Manager — Dashboard', '', '/cgi/gniza4cp-whm/index.cgi');
|
|
|
|
print Gniza4cpWHM::UI::page_header('GNIZA4CP Backup Manager');
|
|
print Gniza4cpWHM::UI::render_nav('index.cgi');
|
|
print Gniza4cpWHM::UI::render_flash();
|
|
|
|
# Stats overview cards
|
|
{
|
|
my $log_dir = '/var/log/gniza4cp';
|
|
my $main_conf_file = '/etc/gniza4cp/gniza4cp.conf';
|
|
if (-f $main_conf_file) {
|
|
my $cfg = Gniza4cpWHM::Config::parse($main_conf_file, 'main');
|
|
$log_dir = $cfg->{LOG_DIR} if $cfg->{LOG_DIR} && $cfg->{LOG_DIR} ne '';
|
|
}
|
|
|
|
my @cpanel_accounts = Gniza4cpWHM::UI::get_cpanel_accounts();
|
|
my @remotes = Gniza4cpWHM::UI::list_remotes();
|
|
my $schedules = Gniza4cpWHM::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 + setup wizard
|
|
print qq{<div class="flex items-center gap-3 mb-4">\n};
|
|
if ($has_stats && $updated) {
|
|
my $esc_updated = Gniza4cpWHM::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{ } . Gniza4cpWHM::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 class="flex-1"></div>\n};
|
|
print qq{ <button type="button" class="btn btn-primary btn-sm" onclick="location.href='setup.cgi'">Run Setup Wizard</button>\n};
|
|
print qq{</div>\n};
|
|
|
|
# 6 stat cards in responsive grid
|
|
print qq{<div class="grid grid-cols-2 sm:grid-cols-3 gap-4 mb-6">\n};
|
|
|
|
# SVG icons for stat cards (24x24, stroke-based)
|
|
my $icon_accounts = '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>';
|
|
my $icon_backed_up = '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/><path d="m9 12 2 2 4-4"/></svg>';
|
|
my $icon_snapshots = '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>';
|
|
my $icon_remotes = '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="2" width="20" height="8" rx="2" ry="2"/><rect x="2" y="14" width="20" height="8" rx="2" ry="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg>';
|
|
my $icon_schedules = '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>';
|
|
my $icon_last_backup = '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>';
|
|
|
|
# Card 1: cPanel Accounts
|
|
my $acct_count = scalar @cpanel_accounts;
|
|
print qq{ <div class="card bg-white shadow-sm border border-base-300">\n};
|
|
print qq{ <div class="card-body p-4">\n};
|
|
print qq{ <div class="flex items-center justify-between">\n};
|
|
print qq{ <div class="text-sm text-base-content/60">cPanel Accounts</div>\n};
|
|
print qq{ <div class="text-primary/40">$icon_accounts</div>\n};
|
|
print qq{ </div>\n};
|
|
print qq{ <div class="text-xl font-bold text-primary">$acct_count</div>\n};
|
|
print qq{ <div class="text-xs text-base-content/60">on this server</div>\n};
|
|
print qq{ </div>\n};
|
|
print qq{ </div>\n};
|
|
|
|
# Card 2: Backed Up Accounts
|
|
print qq{ <div class="card bg-white shadow-sm border border-base-300">\n};
|
|
print qq{ <div class="card-body p-4">\n};
|
|
print qq{ <div class="flex items-center justify-between">\n};
|
|
print qq{ <div class="text-sm text-base-content/60">Backed Up Accounts</div>\n};
|
|
print qq{ <div class="text-secondary/40">$icon_backed_up</div>\n};
|
|
print qq{ </div>\n};
|
|
print qq{ <div class="text-xl font-bold text-secondary">$backed_up</div>\n};
|
|
my $remote_count_desc = scalar @remotes;
|
|
print qq{ <div class="text-xs text-base-content/60">across $remote_count_desc remote(s)</div>\n};
|
|
print qq{ </div>\n};
|
|
print qq{ </div>\n};
|
|
|
|
# Card 3: Total Snapshots
|
|
print qq{ <div class="card bg-white shadow-sm border border-base-300">\n};
|
|
print qq{ <div class="card-body p-4">\n};
|
|
print qq{ <div class="flex items-center justify-between">\n};
|
|
print qq{ <div class="text-sm text-base-content/60">Total Snapshots</div>\n};
|
|
print qq{ <div class="text-base-content/20">$icon_snapshots</div>\n};
|
|
print qq{ </div>\n};
|
|
print qq{ <div class="text-xl font-bold">$total_snaps</div>\n};
|
|
print qq{ <div class="text-xs text-base-content/60">across all remotes</div>\n};
|
|
print qq{ </div>\n};
|
|
print qq{ </div>\n};
|
|
|
|
# Card 4: Remotes
|
|
my $rem_count = scalar @remotes;
|
|
print qq{ <div class="card bg-white shadow-sm border border-base-300">\n};
|
|
print qq{ <div class="card-body p-4">\n};
|
|
print qq{ <div class="flex items-center justify-between">\n};
|
|
print qq{ <div class="text-sm text-base-content/60">Remotes</div>\n};
|
|
print qq{ <div class="text-primary/40">$icon_remotes</div>\n};
|
|
print qq{ </div>\n};
|
|
print qq{ <div class="text-xl font-bold text-primary">$rem_count</div>\n};
|
|
print qq{ <div class="text-xs text-base-content/60">configured destinations</div>\n};
|
|
print qq{ </div>\n};
|
|
print qq{ </div>\n};
|
|
|
|
# Card 5: Schedules
|
|
print qq{ <div class="card bg-white shadow-sm border border-base-300">\n};
|
|
print qq{ <div class="card-body p-4">\n};
|
|
print qq{ <div class="flex items-center justify-between">\n};
|
|
print qq{ <div class="text-sm text-base-content/60">Schedules</div>\n};
|
|
print qq{ <div class="text-secondary/40">$icon_schedules</div>\n};
|
|
print qq{ </div>\n};
|
|
print qq{ <div class="text-xl font-bold text-secondary">$sched_count</div>\n};
|
|
print qq{ <div class="text-xs text-base-content/60">active cron jobs</div>\n};
|
|
print qq{ </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="card bg-white shadow-sm border border-base-300">\n};
|
|
print qq{ <div class="card-body p-4">\n};
|
|
print qq{ <div class="flex items-center justify-between">\n};
|
|
print qq{ <div class="text-sm text-base-content/60">Last Backup</div>\n};
|
|
print qq{ <div class="text-base-content/20">$icon_last_backup</div>\n};
|
|
print qq{ </div>\n};
|
|
print qq{ <div class="mt-1 mb-1"><span class="badge $badge_class">$badge_text</span></div>\n};
|
|
if ($last_log) {
|
|
my $esc_log = Gniza4cpWHM::UI::esc($last_log);
|
|
print qq{ <div class="text-xs text-base-content/60">$esc_log</div>\n};
|
|
} else {
|
|
print qq{ <div class="text-xs text-base-content/60">click Refresh to collect</div>\n};
|
|
}
|
|
print qq{ </div>\n};
|
|
print qq{ </div>\n};
|
|
|
|
print qq{</div>\n};
|
|
}
|
|
|
|
# Live Status card
|
|
print qq{<div id="gniza4cp-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{<div id="gniza4cp-status-content">\n};
|
|
print qq{ <div class="flex items-center gap-2">\n};
|
|
print qq{ <span class="loading loading-spinner loading-xs"></span>\n};
|
|
print qq{ <span class="text-sm text-base-content/60">Checking...</span>\n};
|
|
print qq{ </div>\n};
|
|
print qq{</div>\n};
|
|
print qq{</div>\n</div>\n};
|
|
|
|
# Remote destinations
|
|
my @remotes = Gniza4cpWHM::UI::list_remotes();
|
|
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="flex items-center gap-3"><h2 class="card-title text-sm">Configured Remotes</h2><button type="button" class="btn btn-primary btn-sm" onclick="location.href='remotes.cgi?action=add_form'">Add New</button></div>\n};
|
|
if (@remotes) {
|
|
print qq{<div class="overflow-x-auto rounded-box border border-base-content/5 bg-base-100"><table class="table">\n};
|
|
print qq{<thead><tr><th>Name</th><th>Host</th><th>Port</th><th>Retention</th></tr></thead>\n};
|
|
print qq{<tbody>\n};
|
|
for my $name (@remotes) {
|
|
my $conf = Gniza4cpWHM::Config::parse(Gniza4cpWHM::UI::remote_conf_path($name), 'remote');
|
|
my $host = Gniza4cpWHM::UI::esc($conf->{REMOTE_HOST} // '');
|
|
my $port = Gniza4cpWHM::UI::esc($conf->{REMOTE_PORT} // '22');
|
|
my $retention = Gniza4cpWHM::UI::esc($conf->{RETENTION_COUNT} // '30');
|
|
my $esc_name = Gniza4cpWHM::UI::esc($name);
|
|
print qq{<tr class="hover"><td>$esc_name</td><td>$host</td><td>$port</td><td>$retention</td></tr>\n};
|
|
}
|
|
print qq{</tbody>\n</table></div>\n};
|
|
} else {
|
|
print qq{<p>No remotes configured. <a href="setup.cgi" class="link">Run the setup wizard</a> to add one.</p>\n};
|
|
}
|
|
print qq{</div>\n</div>\n};
|
|
|
|
# Active schedules
|
|
my $schedules = Gniza4cpWHM::Cron::get_current_schedules();
|
|
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="flex items-center gap-3"><h2 class="card-title text-sm">Active Cron Schedules</h2><button type="button" class="btn btn-primary btn-sm" onclick="location.href='schedules.cgi?action=add_form'">Add New</button></div>\n};
|
|
if (keys %$schedules) {
|
|
print qq{<div class="overflow-x-auto rounded-box border border-base-content/5 bg-base-100"><table class="table">\n};
|
|
print qq{<thead><tr><th>Schedule</th><th>Timing</th><th>Remote Destination(s)</th></tr></thead>\n};
|
|
print qq{<tbody>\n};
|
|
for my $name (sort keys %$schedules) {
|
|
my $esc_name = Gniza4cpWHM::UI::esc($name);
|
|
my ($timing, $remotes) = Gniza4cpWHM::Cron::cron_to_human($schedules->{$name});
|
|
my $esc_timing = Gniza4cpWHM::UI::esc($timing);
|
|
my $esc_remotes = Gniza4cpWHM::UI::esc($remotes);
|
|
print qq{<tr class="hover"><td>$esc_name</td><td>$esc_timing</td><td>$esc_remotes</td></tr>\n};
|
|
}
|
|
print qq{</tbody>\n</table></div>\n};
|
|
} else {
|
|
print qq{<p>No active gniza4cp cron entries.</p>\n};
|
|
}
|
|
print qq{</div>\n</div>\n};
|
|
|
|
# Overview
|
|
my $version = Gniza4cpWHM::UI::get_gniza4cp_version();
|
|
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">Overview</h2>\n};
|
|
print qq{<div class="overflow-x-auto rounded-box border border-base-content/5 bg-base-100"><table class="table">\n};
|
|
print qq{<tr><td class="font-semibold w-44">gniza4cp version</td><td>} . Gniza4cpWHM::UI::esc($version) . qq{</td></tr>\n};
|
|
print qq{</table></div>\n};
|
|
print qq{</div>\n</div>\n};
|
|
|
|
# Inline JavaScript for live status polling
|
|
print qq{<script>
|
|
(function() {
|
|
var interval = null;
|
|
var wasRunning = false;
|
|
var el = document.getElementById('gniza4cp-status-content');
|
|
|
|
function escHtml(s) {
|
|
var d = document.createElement('div');
|
|
d.appendChild(document.createTextNode(s));
|
|
return d.innerHTML;
|
|
}
|
|
|
|
function levelColor(level) {
|
|
if (level === 'ERROR') return 'text-error';
|
|
if (level === 'WARN') return 'text-warning';
|
|
if (level === 'DEBUG') return 'text-base-content/60';
|
|
return '';
|
|
}
|
|
|
|
function update(data) {
|
|
var html = '';
|
|
if (data.running) {
|
|
html += '<div class="flex items-center gap-2 mb-3">';
|
|
html += '<span class="badge badge-warning badge-sm animate-pulse">Running</span>';
|
|
html += '<span class="text-sm">PID ' + escHtml(data.pid);
|
|
if (data.log_file) html += ' — ' + escHtml(data.log_file);
|
|
html += '</span></div>';
|
|
if (data.lines && data.lines.length) {
|
|
html += '<pre class="font-mono text-xs bg-base-200 rounded-lg p-3 overflow-x-auto whitespace-pre-wrap max-h-48 overflow-y-auto">';
|
|
for (var i = 0; i < data.lines.length; i++) {
|
|
var cls = levelColor(data.lines[i].level);
|
|
html += '<span class="' + cls + '">' + escHtml(data.lines[i].text) + '</span>\\n';
|
|
}
|
|
html += '</pre>';
|
|
}
|
|
} else {
|
|
html += '<div class="flex items-center gap-2">';
|
|
html += '<span class="badge badge-success badge-sm">Idle</span>';
|
|
html += '<span class="text-sm">No backup or restore running.</span>';
|
|
html += '</div>';
|
|
}
|
|
el.innerHTML = html;
|
|
|
|
if (data.running !== wasRunning) {
|
|
clearInterval(interval);
|
|
interval = setInterval(poll, data.running ? 10000 : 60000);
|
|
wasRunning = data.running;
|
|
}
|
|
}
|
|
|
|
function poll() {
|
|
fetch('index.cgi?action=status')
|
|
.then(function(r) { return r.json(); })
|
|
.then(update)
|
|
.catch(function() {});
|
|
}
|
|
|
|
interval = setInterval(poll, 10000);
|
|
poll();
|
|
})();
|
|
</script>
|
|
};
|
|
|
|
print Gniza4cpWHM::UI::page_footer();
|
|
Whostmgr::HTMLInterface::footer();
|
|
|
|
# --- JSON status endpoint ---
|
|
sub handle_status_json {
|
|
print "Content-Type: application/json\r\n\r\n";
|
|
|
|
my $lock_file = '/var/run/gniza4cp.lock';
|
|
my $running = 0;
|
|
my $pid = '';
|
|
|
|
# Check lock file for running PID
|
|
if (-f $lock_file) {
|
|
if (open my $fh, '<', $lock_file) {
|
|
$pid = <$fh> // '';
|
|
chomp $pid;
|
|
close $fh;
|
|
if ($pid =~ /^\d+$/ && kill(0, $pid)) {
|
|
$running = 1;
|
|
} else {
|
|
$pid = '';
|
|
}
|
|
}
|
|
}
|
|
|
|
# Find LOG_DIR from main config
|
|
my $log_dir = '/var/log/gniza4cp';
|
|
my $main_conf_file = '/etc/gniza4cp/gniza4cp.conf';
|
|
if (-f $main_conf_file) {
|
|
my $cfg = Gniza4cpWHM::Config::parse($main_conf_file, 'main');
|
|
$log_dir = $cfg->{LOG_DIR} if $cfg->{LOG_DIR} && $cfg->{LOG_DIR} ne '';
|
|
}
|
|
|
|
# Find most recent gniza4cp-*.log by mtime
|
|
my $log_file = '';
|
|
my @lines;
|
|
if (opendir my $dh, $log_dir) {
|
|
my @logs = sort {
|
|
(stat("$log_dir/$b"))[9] <=> (stat("$log_dir/$a"))[9]
|
|
} grep { /^gniza4cp-\d{8}-\d{6}\.log$/ } readdir($dh);
|
|
closedir $dh;
|
|
$log_file = $logs[0] // '';
|
|
}
|
|
|
|
# Read last 15 lines if running and log exists
|
|
if ($log_file && $running) {
|
|
my $path = "$log_dir/$log_file";
|
|
if (open my $fh, '<', $path) {
|
|
my @all = <$fh>;
|
|
close $fh;
|
|
my $start = @all > 15 ? @all - 15 : 0;
|
|
@lines = @all[$start .. $#all];
|
|
chomp @lines;
|
|
}
|
|
}
|
|
|
|
# Build JSON manually (no JSON module dependency)
|
|
my $json = '{"running":' . ($running ? 'true' : 'false');
|
|
$json .= ',"pid":"' . _json_esc($pid) . '"';
|
|
$json .= ',"log_file":"' . _json_esc($log_file) . '"';
|
|
$json .= ',"lines":[';
|
|
my @json_lines;
|
|
for my $line (@lines) {
|
|
my $level = 'INFO';
|
|
if ($line =~ /\]\s+\[(\w+)\]/) {
|
|
$level = uc($1);
|
|
}
|
|
push @json_lines, '{"level":"' . _json_esc($level) . '","text":"' . _json_esc($line) . '"}';
|
|
}
|
|
$json .= join(',', @json_lines);
|
|
$json .= ']}';
|
|
|
|
print $json;
|
|
}
|
|
|
|
sub _json_esc {
|
|
my ($s) = @_;
|
|
$s //= '';
|
|
$s =~ s/\\/\\\\/g;
|
|
$s =~ s/"/\\"/g;
|
|
$s =~ s/\n/\\n/g;
|
|
$s =~ s/\r/\\r/g;
|
|
$s =~ s/\t/\\t/g;
|
|
# Escape control characters
|
|
$s =~ s/([\x00-\x1f])/sprintf("\\u%04x", ord($1))/ge;
|
|
return $s;
|
|
}
|