diff --git a/lib/schedule.sh b/lib/schedule.sh index f4a43a0..0433207 100644 --- a/lib/schedule.sh +++ b/lib/schedule.sh @@ -144,7 +144,7 @@ build_cron_line() { extra_flags+=" --sysbackup" fi - echo "$cron_expr /usr/local/bin/gniza backup${extra_flags} >> /var/log/gniza/cron-${name}.log 2>&1" + echo "$cron_expr /usr/local/bin/gniza backup${extra_flags} >/dev/null 2>&1" } # ── Crontab Management ──────────────────────────────────────── diff --git a/whm/gniza-whm/index.cgi b/whm/gniza-whm/index.cgi index 44e2be6..f673746 100644 --- a/whm/gniza-whm/index.cgi +++ b/whm/gniza-whm/index.cgi @@ -11,13 +11,26 @@ use GnizaWHM::Config; use GnizaWHM::Cron; use GnizaWHM::UI; +my $form = Cpanel::Form::parseform(); + # Redirect to setup wizard if gniza is not configured unless (GnizaWHM::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; +} + print "Content-Type: text/html\r\n\r\n"; Whostmgr::HTMLInterface::defheader('GNIZA Backup Manager — Dashboard', '', '/cgi/gniza-whm/index.cgi'); @@ -26,6 +39,17 @@ print GnizaWHM::UI::page_header('GNIZA Backup Manager'); print GnizaWHM::UI::render_nav('index.cgi'); print GnizaWHM::UI::render_flash(); +# Live Status card +print qq{
\n
\n}; +print qq{

Live Status

\n}; +print qq{
\n}; +print qq{
\n}; +print qq{ \n}; +print qq{ Checking...\n}; +print qq{
\n}; +print qq{
\n}; +print qq{
\n
\n}; + # Quick links print qq{
\n}; print qq{ \n}; @@ -83,5 +107,154 @@ print qq{gniza version} . GnizaWHM:: print qq{
\n}; print qq{\n\n}; +# Inline JavaScript for live status polling +print qq{ +}; + print GnizaWHM::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/gniza.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/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 ''; + } + + # Find most recent gniza-*.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 { /^gniza-\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; +} diff --git a/whm/gniza-whm/lib/GnizaWHM/Cron.pm b/whm/gniza-whm/lib/GnizaWHM/Cron.pm index 9f18197..f9885f9 100644 --- a/whm/gniza-whm/lib/GnizaWHM/Cron.pm +++ b/whm/gniza-whm/lib/GnizaWHM/Cron.pm @@ -87,7 +87,7 @@ sub install_schedule { if (($conf->{SYSBACKUP} // '') eq 'yes') { $extra_flags .= " --sysbackup"; } - my $cmd_line = "$cron_expr $GNIZA_BIN backup${extra_flags} >> /var/log/gniza/cron-${name}.log 2>&1"; + my $cmd_line = "$cron_expr $GNIZA_BIN backup${extra_flags} >/dev/null 2>&1"; # Read current crontab, strip existing entry for this schedule, append new my $crontab = _read_crontab(); diff --git a/whm/gniza-whm/lib/GnizaWHM/Runner.pm b/whm/gniza-whm/lib/GnizaWHM/Runner.pm index 180bdd1..023d0a0 100644 --- a/whm/gniza-whm/lib/GnizaWHM/Runner.pm +++ b/whm/gniza-whm/lib/GnizaWHM/Runner.pm @@ -6,6 +6,8 @@ use strict; use warnings; use IPC::Open3; use Symbol 'gensym'; +use POSIX qw(setsid); +use File::Temp qw(tempfile); my $GNIZA_BIN = '/usr/local/bin/gniza'; @@ -47,9 +49,9 @@ my %OPT_PATTERNS = ( account => qr/^[a-z][a-z0-9_-]*$/, ); -# run($cmd, $subcmd, \@args, \%opts) -# Returns ($success, $stdout, $stderr). -sub run { +# _validate($cmd, $subcmd, \@args, \%opts) +# Returns (1, undef) on success or (0, $error_msg) on failure. +sub _validate { my ($cmd, $subcmd, $args, $opts) = @_; $args //= []; $opts //= {}; @@ -81,21 +83,61 @@ sub run { unless ($matched) { my $desc = "gniza $cmd" . (defined $subcmd ? " $subcmd" : "") . " " . join(" ", @$args); - return (0, '', "Command not allowed: $desc"); + return (0, "Command not allowed: $desc"); } # Validate options for my $key (keys %$opts) { my $pat = $OPT_PATTERNS{$key}; unless ($pat) { - return (0, '', "Unknown option: --$key"); + return (0, "Unknown option: --$key"); } unless ($opts->{$key} =~ $pat) { - return (0, '', "Invalid value for --$key: $opts->{$key}"); + return (0, "Invalid value for --$key: $opts->{$key}"); } } - # Build command + return (1, undef); +} + +# _shell_quote($str) +# Single-quote a string for safe shell embedding. +sub _shell_quote { + my ($str) = @_; + $str =~ s/'/'\\''/g; + return "'$str'"; +} + +# _build_cmd_line($cmd, $subcmd, \@args, \%opts) +# Returns a shell command string. +sub _build_cmd_line { + my ($cmd, $subcmd, $args, $opts) = @_; + $args //= []; + $opts //= {}; + + my @parts = (_shell_quote($GNIZA_BIN), _shell_quote($cmd)); + push @parts, _shell_quote($subcmd) if defined $subcmd; + push @parts, _shell_quote($_) for @$args; + for my $key (sort keys %$opts) { + push @parts, _shell_quote("--$key=$opts->{$key}"); + } + + return join(' ', @parts); +} + +# run($cmd, $subcmd, \@args, \%opts) +# Returns ($success, $stdout, $stderr). +sub run { + my ($cmd, $subcmd, $args, $opts) = @_; + $args //= []; + $opts //= {}; + + my ($valid, $err) = _validate($cmd, $subcmd, $args, $opts); + unless ($valid) { + return (0, '', $err); + } + + # Build exec args my @exec_args = ($cmd); push @exec_args, $subcmd if defined $subcmd; push @exec_args, @$args; @@ -106,6 +148,57 @@ sub run { return _exec(@exec_args); } +# run_async(\@commands) +# Each command: [$cmd, $subcmd, \@args, \%opts] +# Validates all commands upfront, then forks a detached child to run them. +# Returns ($success, $error_msg). +sub run_async { + my ($commands) = @_; + + return (0, 'No commands provided') unless $commands && @$commands; + + # Validate all commands upfront + my @cmd_lines; + for my $c (@$commands) { + my ($cmd, $subcmd, $args, $opts) = @$c; + my ($valid, $err) = _validate($cmd, $subcmd, $args, $opts); + unless ($valid) { + return (0, $err); + } + push @cmd_lines, _build_cmd_line($cmd, $subcmd, $args, $opts); + } + + # Write bash wrapper script + my ($fh, $tmpfile) = tempfile('gniza-whm-job-XXXXXXXX', DIR => '/tmp', SUFFIX => '.sh'); + print $fh "#!/bin/bash\n"; + print $fh "set -uo pipefail\n"; + for my $line (@cmd_lines) { + print $fh "$line\n"; + } + print $fh "rm -f \"\$0\"\n"; + close $fh; + chmod 0700, $tmpfile; + + # Fork and detach + my $pid = fork(); + if (!defined $pid) { + unlink $tmpfile; + return (0, "Fork failed: $!"); + } + + if ($pid == 0) { + # Child: detach from parent + setsid(); + open STDIN, '<', '/dev/null'; + open STDOUT, '>', '/dev/null'; + open STDERR, '>', '/dev/null'; + exec('/bin/bash', $tmpfile) or exit(1); + } + + # Parent: don't wait for child + return (1, ''); +} + sub _exec { my (@args) = @_; diff --git a/whm/gniza-whm/logs.cgi b/whm/gniza-whm/logs.cgi index 47305fd..5dd5198 100755 --- a/whm/gniza-whm/logs.cgi +++ b/whm/gniza-whm/logs.cgi @@ -125,6 +125,25 @@ sub show_list { print qq{\n}; } + # Auto-refresh while gniza is running + print qq{\n}; + print GnizaWHM::UI::page_footer(); } @@ -263,6 +282,26 @@ sub show_file { print qq{\n}; } + # Auto-refresh + auto-scroll while gniza is running + print qq{\n}; + print GnizaWHM::UI::page_footer(); } diff --git a/whm/gniza-whm/restore.cgi b/whm/gniza-whm/restore.cgi index f092c78..f5aa662 100644 --- a/whm/gniza-whm/restore.cgi +++ b/whm/gniza-whm/restore.cgi @@ -890,19 +890,17 @@ sub handle_step4 { exit; } - # Execute each type sequentially - my @results; + # Build commands for async execution + my @commands; for my $type (@selected_types) { my %opts = (remote => $remote); $opts{timestamp} = $timestamp if $timestamp ne ''; if ($SIMPLE_TYPES{$type}) { - my ($ok, $stdout, $stderr) = GnizaWHM::Runner::run('restore', $type, [$account], \%opts); - push @results, { type => $type, label => $TYPE_LABELS{$type} // $type, ok => $ok, stdout => $stdout // '', stderr => $stderr // '' }; + push @commands, ['restore', $type, [$account], {%opts}]; } elsif ($type eq 'files') { $opts{path} = $path if $path ne ''; - my ($ok, $stdout, $stderr) = GnizaWHM::Runner::run('restore', 'files', [$account], \%opts); - push @results, { type => $type, label => $TYPE_LABELS{$type} // $type, ok => $ok, stdout => $stdout // '', stderr => $stderr // '' }; + push @commands, ['restore', 'files', [$account], {%opts}]; } elsif ($type eq 'database') { my @dbs; if ($dbnames ne '' && $dbnames ne '__ALL__') { @@ -910,12 +908,10 @@ sub handle_step4 { } if (@dbs) { for my $db (@dbs) { - my ($ok, $stdout, $stderr) = GnizaWHM::Runner::run('restore', 'database', [$account, $db], \%opts); - push @results, { type => $type, label => "Database: $db", ok => $ok, stdout => $stdout // '', stderr => $stderr // '' }; + push @commands, ['restore', 'database', [$account, $db], {%opts}]; } } else { - my ($ok, $stdout, $stderr) = GnizaWHM::Runner::run('restore', 'database', [$account], \%opts); - push @results, { type => $type, label => 'Database: All', ok => $ok, stdout => $stdout // '', stderr => $stderr // '' }; + push @commands, ['restore', 'database', [$account], {%opts}]; } } elsif ($type eq 'dbusers') { my @dbus; @@ -924,12 +920,10 @@ sub handle_step4 { } if (@dbus) { for my $dbu (@dbus) { - my ($ok, $stdout, $stderr) = GnizaWHM::Runner::run('restore', 'dbusers', [$account, $dbu], \%opts); - push @results, { type => $type, label => "DB User: $dbu", ok => $ok, stdout => $stdout // '', stderr => $stderr // '' }; + push @commands, ['restore', 'dbusers', [$account, $dbu], {%opts}]; } } else { - my ($ok, $stdout, $stderr) = GnizaWHM::Runner::run('restore', 'dbusers', [$account], \%opts); - push @results, { type => $type, label => 'Database Users: All', ok => $ok, stdout => $stdout // '', stderr => $stderr // '' }; + push @commands, ['restore', 'dbusers', [$account], {%opts}]; } } elsif ($type eq 'mailbox') { my @mbs; @@ -938,12 +932,10 @@ sub handle_step4 { } if (@mbs) { for my $mb (@mbs) { - my ($ok, $stdout, $stderr) = GnizaWHM::Runner::run('restore', 'mailbox', [$account, $mb], \%opts); - push @results, { type => $type, label => "Mailbox: $mb", ok => $ok, stdout => $stdout // '', stderr => $stderr // '' }; + push @commands, ['restore', 'mailbox', [$account, $mb], {%opts}]; } } else { - my ($ok, $stdout, $stderr) = GnizaWHM::Runner::run('restore', 'mailbox', [$account], \%opts); - push @results, { type => $type, label => 'Mailbox: All', ok => $ok, stdout => $stdout // '', stderr => $stderr // '' }; + push @commands, ['restore', 'mailbox', [$account], {%opts}]; } } elsif ($type eq 'domains') { my @doms; @@ -952,12 +944,10 @@ sub handle_step4 { } if (@doms) { for my $dom (@doms) { - my ($ok, $stdout, $stderr) = GnizaWHM::Runner::run('restore', 'domains', [$account, $dom], \%opts); - push @results, { type => $type, label => "Domain: $dom", ok => $ok, stdout => $stdout // '', stderr => $stderr // '' }; + push @commands, ['restore', 'domains', [$account, $dom], {%opts}]; } } else { - my ($ok, $stdout, $stderr) = GnizaWHM::Runner::run('restore', 'domains', [$account], \%opts); - push @results, { type => $type, label => 'Domains: All', ok => $ok, stdout => $stdout // '', stderr => $stderr // '' }; + push @commands, ['restore', 'domains', [$account], {%opts}]; } } elsif ($type eq 'ssl') { my @certs; @@ -966,52 +956,22 @@ sub handle_step4 { } if (@certs) { for my $cert (@certs) { - my ($ok, $stdout, $stderr) = GnizaWHM::Runner::run('restore', 'ssl', [$account, $cert], \%opts); - push @results, { type => $type, label => "SSL: $cert", ok => $ok, stdout => $stdout // '', stderr => $stderr // '' }; + push @commands, ['restore', 'ssl', [$account, $cert], {%opts}]; } } else { - my ($ok, $stdout, $stderr) = GnizaWHM::Runner::run('restore', 'ssl', [$account], \%opts); - push @results, { type => $type, label => 'SSL: All', ok => $ok, stdout => $stdout // '', stderr => $stderr // '' }; + push @commands, ['restore', 'ssl', [$account], {%opts}]; } - } else { - push @results, { type => $type, label => $TYPE_LABELS{$type} // $type, ok => 0, stdout => '', stderr => 'Invalid restore type' }; } } - print "Content-Type: text/html\r\n\r\n"; - Whostmgr::HTMLInterface::defheader('GNIZA Backup Manager — Restore', '', '/cgi/gniza-whm/restore.cgi'); - - print GnizaWHM::UI::page_header('Restore from Backup'); - print GnizaWHM::UI::render_nav('restore.cgi'); - - # Overall status - my $all_ok = !grep { !$_->{ok} } @results; - my $any_ok = grep { $_->{ok} } @results; - if ($all_ok) { - print qq{
All restore operations completed successfully.
\n}; - } elsif ($any_ok) { - print qq{
Some restore operations failed. See details below.
\n}; + my ($ok, $err) = GnizaWHM::Runner::run_async(\@commands); + if ($ok) { + GnizaWHM::UI::set_flash('success', 'Restore started in background. Watch progress below.'); + print "Status: 302 Found\r\n"; + print "Location: logs.cgi\r\n\r\n"; } else { - print qq{
All restore operations failed.
\n}; + GnizaWHM::UI::set_flash('error', "Failed to start restore: $err"); + print "Status: 302 Found\r\n"; + print "Location: restore.cgi\r\n\r\n"; } - - # Per-type output blocks - for my $r (@results) { - my $esc_label = GnizaWHM::UI::esc($r->{label}); - my $badge = $r->{ok} - ? 'OK' - : 'Failed'; - - print qq{
\n
\n}; - print qq{

$esc_label $badge

\n}; - my $output = $r->{stdout} . ($r->{stderr} =~ /\S/ ? "\n$r->{stderr}" : ''); - $output = '(no output)' unless $output =~ /\S/; - print qq{
} . GnizaWHM::UI::esc($output) . qq{
\n}; - print qq{
\n
\n}; - } - - print qq{\n}; - - print GnizaWHM::UI::page_footer(); - Whostmgr::HTMLInterface::footer(); }