Add terminate-before-restore toggle, logo, and installer improvements

- Add "Terminate First" toggle to restore page (UI, Runner, CLI, lib)
- When enabled, removes existing cPanel account before restoring
- Add GNIZA Backup SVG logo to WHM plugin header (inline base64)
- Copy uninstall.sh to /usr/local/gniza/ during installation
- Update CLAUDE.md with new restore params and Runner options

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
shuki
2026-03-04 21:43:48 +02:00
parent b8858bcbc8
commit b16893086d
8 changed files with 104 additions and 8 deletions

View File

@@ -52,6 +52,7 @@ whm/
├── restore.cgi # Restore workflow — 4-step form (account → snapshot → confirm → execute) ├── restore.cgi # Restore workflow — 4-step form (account → snapshot → confirm → execute)
├── assets/ ├── assets/
│ ├── gniza-whm.css # Built Tailwind/DaisyUI CSS (committed, ~58KB) │ ├── gniza-whm.css # Built Tailwind/DaisyUI CSS (committed, ~58KB)
│ ├── gniza-logo.svg # SVG logo (embedded as data URI in page header)
│ └── src/ │ └── src/
│ ├── input.css # Tailwind v4 entry point with DaisyUI plugin │ ├── input.css # Tailwind v4 entry point with DaisyUI plugin
│ ├── safelist.html # Class safelist for Tailwind content scanner │ ├── safelist.html # Class safelist for Tailwind content scanner
@@ -384,7 +385,7 @@ All restore functions dispatch by `_is_rclone_mode` — using `rclone_from_remot
| Function | Description | | Function | Description |
|----------|-------------| |----------|-------------|
| `restore_full_account(user, ts)` | Full account restore from snapshot | | `restore_full_account(user, ts, terminate, exclude)` | Full account restore from snapshot. If `terminate=true`, removes existing account via `/scripts/removeacct` before restoring. Otherwise merges with `--force`. |
| `restore_files(user, ts, path)` | Restore specific files/directories | | `restore_files(user, ts, path)` | Restore specific files/directories |
| `restore_database(user, ts, dbname)` | Restore a MySQL database from snapshot | | `restore_database(user, ts, dbname)` | Restore a MySQL database from snapshot |
| `restore_mailbox(user, email, ts)` | Restore a mailbox (parses email → mail/domain/user path) | | `restore_mailbox(user, email, ts)` | Restore a mailbox (parses email → mail/domain/user path) |
@@ -442,7 +443,7 @@ Pattern-based command runner for safe CLI execution from the WHM UI. Each allowe
| `run($cmd, $subcmd, \@args, \%opts)` | Validate against allowlist and execute gniza CLI | | `run($cmd, $subcmd, \@args, \%opts)` | Validate against allowlist and execute gniza CLI |
Allowed commands: `restore account/files/database/mailbox/list-databases/list-mailboxes`, `list`. Allowed commands: `restore account/files/database/mailbox/list-databases/list-mailboxes`, `list`.
Named option patterns: `--remote`, `--timestamp`, `--path`, `--account`. Named option patterns: `--remote`, `--timestamp`, `--path`, `--account`, `--terminate`, `--exclude`.
### GnizaWHM::Config ### GnizaWHM::Config
@@ -525,6 +526,31 @@ _restore_remote_globals
**NEVER write custom CSS.** Always use Tailwind utility classes and DaisyUI components exclusively. All styling must be done through class attributes in HTML — no custom CSS rules, no `<style>` blocks (except the auto-generated inline delivery in `page_header()`), no CSS files other than the Tailwind build output. **NEVER write custom CSS.** Always use Tailwind utility classes and DaisyUI components exclusively. All styling must be done through class attributes in HTML — no custom CSS rules, no `<style>` blocks (except the auto-generated inline delivery in `page_header()`), no CSS files other than the Tailwind build output.
### WHM Theme & Color Palette
The WHM plugin uses a custom DaisyUI theme named `gniza` (defined in `assets/src/input.css`). Light-only, no dark mode.
| Role | OKLCH Value | Approx Color |
|------|-------------|-------------|
| **Primary** | `oklch(38.2% 0.145 259.4)` | Deep navy blue |
| **Primary content** | `oklch(100% 0 0)` | White |
| **Secondary** | `oklch(69.5% 0.169 47.8)` | Warm copper/orange |
| **Secondary content** | `oklch(100% 0 0)` | White |
| **Accent / Warning** | `oklch(86.4% 0.177 90.8)` | Soft gold/yellow |
| **Accent content / Warning content** | `oklch(30.9% 0.116 258.9)` | Dark navy |
| **Neutral / Base content** | `oklch(30.9% 0.116 258.9)` | Dark navy |
| **Neutral content** | `oklch(100% 0 0)` | White |
| **Base 100** | `transparent` | Transparent (inherits WHM background) |
| **Base 200** | `oklch(97% 0 0)` | Near-white gray |
| **Base 300** | `oklch(89.8% 0 0)` | Light gray |
| **Info** | `oklch(69% 0.083 217.5)` | Muted blue |
| **Success** | `oklch(65% 0.25 140)` | Vivid green |
| **Error** | `oklch(57.7% 0.245 27.3)` | Red |
**Typography:** `'Helvetica Neue', Helvetica, Arial, sans-serif`
**Border radius:** `0.5rem` (boxes/selectors), `0.25rem` (fields)
**Base font size:** `1.6rem` (set on the `data-theme` wrapper to match WHM's sizing)
### WHM CSS Build System (Tailwind v4 + DaisyUI v5) ### WHM CSS Build System (Tailwind v4 + DaisyUI v5)
All WHM pages use Tailwind CSS v4 with DaisyUI v5 for styling. The CSS is built from source and committed. All WHM pages use Tailwind CSS v4 with DaisyUI v5 for styling. The CSS is built from source and committed.

View File

@@ -293,7 +293,7 @@ cmd_restore() {
account) account)
local name="${1:-}" local name="${1:-}"
shift 2>/dev/null || true shift 2>/dev/null || true
[[ -z "$name" ]] && die "Usage: gniza restore account <name> [--remote=NAME] [--timestamp=TS] [--force]" [[ -z "$name" ]] && die "Usage: gniza restore account <name> [--remote=NAME] [--timestamp=TS] [--terminate]"
local config_file; config_file=$(get_opt config "$@" 2>/dev/null) || config_file="$DEFAULT_CONFIG_FILE" local config_file; config_file=$(get_opt config "$@" 2>/dev/null) || config_file="$DEFAULT_CONFIG_FILE"
load_config "$config_file" load_config "$config_file"
@@ -305,9 +305,12 @@ cmd_restore() {
local timestamp; timestamp=$(get_opt timestamp "$@" 2>/dev/null) || timestamp="" local timestamp; timestamp=$(get_opt timestamp "$@" 2>/dev/null) || timestamp=""
local exclude; exclude=$(get_opt exclude "$@" 2>/dev/null) || exclude="" local exclude; exclude=$(get_opt exclude "$@" 2>/dev/null) || exclude=""
local terminate_val; terminate_val=$(get_opt terminate "$@" 2>/dev/null) || terminate_val=""
local terminate=false
[[ "$terminate_val" == "1" ]] && terminate=true
_test_connection _test_connection
restore_full_account "$name" "$timestamp" "" "$exclude" restore_full_account "$name" "$timestamp" "$terminate" "$exclude"
;; ;;
files) files)
local name="${1:-}" local name="${1:-}"

View File

@@ -57,7 +57,7 @@ _detect_pkgacct_base() {
restore_full_account() { restore_full_account() {
local user="$1" local user="$1"
local timestamp="${2:-}" local timestamp="${2:-}"
local _unused="${3:-}" # formerly strategy, kept for call-site compat local terminate="${3:-}"
local exclude="${4:-}" local exclude="${4:-}"
local temp_dir="${TEMP_DIR:-$DEFAULT_TEMP_DIR}" local temp_dir="${TEMP_DIR:-$DEFAULT_TEMP_DIR}"
@@ -127,6 +127,16 @@ restore_full_account() {
find "$mysql_dir" -name "*.sql.gz" -exec gunzip -f {} \; find "$mysql_dir" -name "*.sql.gz" -exec gunzip -f {} \;
fi fi
# Terminate existing account if requested
if [[ "$terminate" == "true" ]] && account_exists "$user"; then
log_info "Terminating existing account $user before restore..."
if ! /scripts/removeacct --skipbw --force "$user"; then
log_error "Failed to terminate account $user"
rm -rf "$restore_dir"
return 1
fi
fi
# Run restorepkg (--force to merge into existing account if present) # Run restorepkg (--force to merge into existing account if present)
log_info "Running restorepkg for $user..." log_info "Running restorepkg for $user..."
local -a restorepkg_args=() local -a restorepkg_args=()
@@ -1386,7 +1396,7 @@ restore_server() {
((total++)) || true ((total++)) || true
log_info "--- Restoring account $user ($total) ---" log_info "--- Restoring account $user ($total) ---"
if restore_full_account "$user" "$timestamp" "merge"; then if restore_full_account "$user" "$timestamp" "" ""; then
((succeeded++)) || true ((succeeded++)) || true
else else
((failed++)) || true ((failed++)) || true

View File

@@ -40,6 +40,8 @@ mkdir -p "$INSTALL_DIR"
cp -r "$SOURCE_DIR/bin" "$INSTALL_DIR/" cp -r "$SOURCE_DIR/bin" "$INSTALL_DIR/"
cp -r "$SOURCE_DIR/lib" "$INSTALL_DIR/" cp -r "$SOURCE_DIR/lib" "$INSTALL_DIR/"
cp -r "$SOURCE_DIR/etc" "$INSTALL_DIR/" cp -r "$SOURCE_DIR/etc" "$INSTALL_DIR/"
cp "$SOURCE_DIR/scripts/uninstall.sh" "$INSTALL_DIR/uninstall.sh"
chmod +x "$INSTALL_DIR/uninstall.sh"
# Make bin executable # Make bin executable
chmod +x "$INSTALL_DIR/bin/gniza" chmod +x "$INSTALL_DIR/bin/gniza"

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 369 KiB

View File

@@ -48,6 +48,7 @@ my %OPT_PATTERNS = (
path => qr/^[a-zA-Z0-9_.\/@ -]+$/, path => qr/^[a-zA-Z0-9_.\/@ -]+$/,
account => qr/^[a-z][a-z0-9_-]*$/, account => qr/^[a-z][a-z0-9_-]*$/,
exclude => qr/^[a-zA-Z0-9_.,\/@ *?\[\]-]+$/, exclude => qr/^[a-zA-Z0-9_.,\/@ *?\[\]-]+$/,
terminate => qr/^1$/,
); );
# _validate($cmd, $subcmd, \@args, \%opts) # _validate($cmd, $subcmd, \@args, \%opts)

View File

@@ -19,6 +19,7 @@ my $REMOTE_EXAMPLE = '/usr/local/gniza/etc/remote.conf.example';
my $SCHEDULE_EXAMPLE = '/usr/local/gniza/etc/schedule.conf.example'; my $SCHEDULE_EXAMPLE = '/usr/local/gniza/etc/schedule.conf.example';
my $SSH_DIR = '/root/.ssh'; my $SSH_DIR = '/root/.ssh';
my $CSS_FILE = '/usr/local/cpanel/whostmgr/docroot/cgi/gniza-whm/assets/gniza-whm.css'; my $CSS_FILE = '/usr/local/cpanel/whostmgr/docroot/cgi/gniza-whm/assets/gniza-whm.css';
my $LOGO_FILE = '/usr/local/cpanel/whostmgr/docroot/cgi/gniza-whm/assets/gniza-logo.svg';
# ── HTML Escaping ───────────────────────────────────────────── # ── HTML Escaping ─────────────────────────────────────────────
@@ -631,8 +632,19 @@ sub page_header {
# Scope :root/:host to our container so DaisyUI base styles # Scope :root/:host to our container so DaisyUI base styles
# (background, color, overflow, scrollbar) don't leak into WHM. # (background, color, overflow, scrollbar) don't leak into WHM.
$css = _scope_to_container($css); $css = _scope_to_container($css);
# Inline logo as base64 data URI
my $logo_html = '';
if (open my $lfh, '<', $LOGO_FILE) {
local $/;
my $svg_data = <$lfh>;
close $lfh;
require MIME::Base64;
my $b64 = MIME::Base64::encode_base64($svg_data, '');
$logo_html = qq{<div class="flex items-center justify-center gap-3 mb-4"><img src="data:image/svg+xml;base64,$b64" alt="GNIZA" style="height:48px;width:auto"></div>\n};
}
return qq{<style>$css</style>\n} return qq{<style>$css</style>\n}
. qq{<div data-theme="gniza" class="font-sans text-[1.6rem]" style="padding:30px 10px 10px 10px">\n}; . qq{<div data-theme="gniza" class="font-sans text-[1.6rem]" style="padding:30px 10px 10px 10px">\n}
. $logo_html;
} }
sub _unwrap_layers { sub _unwrap_layers {

View File

@@ -247,6 +247,12 @@ sub handle_step2 {
print qq{ </div>\n}; print qq{ </div>\n};
print qq{</div>\n}; print qq{</div>\n};
# Terminate toggle (only visible in Full Account mode)
print qq{<div id="terminate-panel" class="flex items-center gap-3 mb-2.5">\n};
print qq{ <label class="w-52 font-medium text-sm whitespace-nowrap">Terminate First <span class="tooltip tooltip-top" data-tip="Remove the existing cPanel account before restoring. Results in a clean restore but causes brief downtime.">&#9432;</span></label>\n};
print qq{ <input type="checkbox" class="toggle toggle-sm toggle-error" id="terminate" name="terminate" value="1">\n};
print qq{</div>\n};
# Exclude paths (visible in both Full Account and Selective modes) # Exclude paths (visible in both Full Account and Selective modes)
print qq{<div class="card bg-white shadow-sm border border-base-300 mb-3 mt-3">\n}; print qq{<div class="card bg-white shadow-sm border border-base-300 mb-3 mt-3">\n};
print qq{<div class="card-body py-3 px-4">\n}; print qq{<div class="card-body py-3 px-4">\n};
@@ -443,6 +449,7 @@ function gnizaModeChanged() {
var mode = document.querySelector('input[name="restore_mode"]:checked').value; var mode = document.querySelector('input[name="restore_mode"]:checked').value;
var selective = mode === 'selective'; var selective = mode === 'selective';
document.getElementById('selective-panel').hidden = !selective; document.getElementById('selective-panel').hidden = !selective;
document.getElementById('terminate-panel').hidden = selective;
document.getElementById('type_account_hidden').disabled = selective; document.getElementById('type_account_hidden').disabled = selective;
if (selective) { if (selective) {
gnizaTypesChanged(); gnizaTypesChanged();
@@ -851,6 +858,7 @@ sub handle_step3 {
my $domain_names = $form->{'domain_names'} // ''; my $domain_names = $form->{'domain_names'} // '';
my $ssl_names = $form->{'ssl_names'} // ''; my $ssl_names = $form->{'ssl_names'} // '';
my $exclude_paths = $form->{'exclude_paths'} // ''; my $exclude_paths = $form->{'exclude_paths'} // '';
my $terminate = $form->{'terminate'} // '';
# Collect selected types from type_* checkboxes # Collect selected types from type_* checkboxes
my @all_type_keys = qw(account files database mailbox cron dbusers cpconfig domains ssl); my @all_type_keys = qw(account files database mailbox cron dbusers cpconfig domains ssl);
@@ -912,6 +920,10 @@ sub handle_step3 {
print qq{<tr><td class="font-medium">SSL</td><td>$ssl_display</td></tr>\n}; print qq{<tr><td class="font-medium">SSL</td><td>$ssl_display</td></tr>\n};
} }
if ($terminate eq '1' && grep { $_ eq 'account' } @selected_types) {
print qq{<tr><td class="font-medium">Terminate First</td><td class="text-error font-medium">Yes — account will be removed before restore</td></tr>\n};
}
if ($exclude_paths ne '') { if ($exclude_paths ne '') {
my $exclude_display = GnizaWHM::UI::esc($exclude_paths); my $exclude_display = GnizaWHM::UI::esc($exclude_paths);
$exclude_display =~ s/,/, /g; $exclude_display =~ s/,/, /g;
@@ -936,6 +948,7 @@ sub handle_step3 {
print qq{<input type="hidden" name="domain_names" value="} . GnizaWHM::UI::esc($domain_names) . qq{">\n}; print qq{<input type="hidden" name="domain_names" value="} . GnizaWHM::UI::esc($domain_names) . qq{">\n};
print qq{<input type="hidden" name="ssl_names" value="} . GnizaWHM::UI::esc($ssl_names) . qq{">\n}; print qq{<input type="hidden" name="ssl_names" value="} . GnizaWHM::UI::esc($ssl_names) . qq{">\n};
print qq{<input type="hidden" name="exclude_paths" value="} . GnizaWHM::UI::esc($exclude_paths) . qq{">\n}; print qq{<input type="hidden" name="exclude_paths" value="} . GnizaWHM::UI::esc($exclude_paths) . qq{">\n};
print qq{<input type="hidden" name="terminate" value="} . GnizaWHM::UI::esc($terminate) . qq{">\n};
print GnizaWHM::UI::csrf_hidden_field(); print GnizaWHM::UI::csrf_hidden_field();
print qq{<div class="flex items-center gap-2">\n}; print qq{<div class="flex items-center gap-2">\n};
@@ -968,6 +981,7 @@ sub handle_step4 {
my $domain_names = $form->{'domain_names'} // ''; my $domain_names = $form->{'domain_names'} // '';
my $ssl_names = $form->{'ssl_names'} // ''; my $ssl_names = $form->{'ssl_names'} // '';
my $exclude_paths = $form->{'exclude_paths'} // ''; my $exclude_paths = $form->{'exclude_paths'} // '';
my $terminate = $form->{'terminate'} // '';
# Collect selected types # Collect selected types
my @all_type_keys = qw(account files database mailbox cron dbusers cpconfig domains ssl); my @all_type_keys = qw(account files database mailbox cron dbusers cpconfig domains ssl);
@@ -990,7 +1004,10 @@ sub handle_step4 {
$opts{timestamp} = $timestamp if $timestamp ne ''; $opts{timestamp} = $timestamp if $timestamp ne '';
if ($SIMPLE_TYPES{$type}) { if ($SIMPLE_TYPES{$type}) {
$opts{exclude} = $exclude_paths if $exclude_paths ne '' && $type eq 'account'; if ($type eq 'account') {
$opts{exclude} = $exclude_paths if $exclude_paths ne '';
$opts{terminate} = '1' if $terminate eq '1';
}
push @commands, ['restore', $type, [$account], {%opts}]; push @commands, ['restore', $type, [$account], {%opts}];
} elsif ($type eq 'files') { } elsif ($type eq 'files') {
$opts{path} = $path if $path ne ''; $opts{path} = $path if $path ne '';