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)
├── assets/
│ ├── gniza-whm.css # Built Tailwind/DaisyUI CSS (committed, ~58KB)
│ ├── gniza-logo.svg # SVG logo (embedded as data URI in page header)
│ └── src/
│ ├── input.css # Tailwind v4 entry point with DaisyUI plugin
│ ├── 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 |
|----------|-------------|
| `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_database(user, ts, dbname)` | Restore a MySQL database from snapshot |
| `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 |
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
@@ -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.
### 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)
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)
local name="${1:-}"
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"
load_config "$config_file"
@@ -305,9 +305,12 @@ cmd_restore() {
local timestamp; timestamp=$(get_opt timestamp "$@" 2>/dev/null) || timestamp=""
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
restore_full_account "$name" "$timestamp" "" "$exclude"
restore_full_account "$name" "$timestamp" "$terminate" "$exclude"
;;
files)
local name="${1:-}"

View File

@@ -57,7 +57,7 @@ _detect_pkgacct_base() {
restore_full_account() {
local user="$1"
local timestamp="${2:-}"
local _unused="${3:-}" # formerly strategy, kept for call-site compat
local terminate="${3:-}"
local exclude="${4:-}"
local temp_dir="${TEMP_DIR:-$DEFAULT_TEMP_DIR}"
@@ -127,6 +127,16 @@ restore_full_account() {
find "$mysql_dir" -name "*.sql.gz" -exec gunzip -f {} \;
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)
log_info "Running restorepkg for $user..."
local -a restorepkg_args=()
@@ -1386,7 +1396,7 @@ restore_server() {
((total++)) || true
log_info "--- Restoring account $user ($total) ---"
if restore_full_account "$user" "$timestamp" "merge"; then
if restore_full_account "$user" "$timestamp" "" ""; then
((succeeded++)) || true
else
((failed++)) || true

View File

@@ -40,6 +40,8 @@ mkdir -p "$INSTALL_DIR"
cp -r "$SOURCE_DIR/bin" "$INSTALL_DIR/"
cp -r "$SOURCE_DIR/lib" "$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
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_.\/@ -]+$/,
account => qr/^[a-z][a-z0-9_-]*$/,
exclude => qr/^[a-zA-Z0-9_.,\/@ *?\[\]-]+$/,
terminate => qr/^1$/,
);
# _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 $SSH_DIR = '/root/.ssh';
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 ─────────────────────────────────────────────
@@ -631,8 +632,19 @@ sub page_header {
# Scope :root/:host to our container so DaisyUI base styles
# (background, color, overflow, scrollbar) don't leak into WHM.
$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}
. 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 {

View File

@@ -247,6 +247,12 @@ sub handle_step2 {
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)
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};
@@ -443,6 +449,7 @@ function gnizaModeChanged() {
var mode = document.querySelector('input[name="restore_mode"]:checked').value;
var selective = mode === 'selective';
document.getElementById('selective-panel').hidden = !selective;
document.getElementById('terminate-panel').hidden = selective;
document.getElementById('type_account_hidden').disabled = selective;
if (selective) {
gnizaTypesChanged();
@@ -851,6 +858,7 @@ sub handle_step3 {
my $domain_names = $form->{'domain_names'} // '';
my $ssl_names = $form->{'ssl_names'} // '';
my $exclude_paths = $form->{'exclude_paths'} // '';
my $terminate = $form->{'terminate'} // '';
# Collect selected types from type_* checkboxes
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};
}
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 '') {
my $exclude_display = GnizaWHM::UI::esc($exclude_paths);
$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="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="terminate" value="} . GnizaWHM::UI::esc($terminate) . qq{">\n};
print GnizaWHM::UI::csrf_hidden_field();
print qq{<div class="flex items-center gap-2">\n};
@@ -968,6 +981,7 @@ sub handle_step4 {
my $domain_names = $form->{'domain_names'} // '';
my $ssl_names = $form->{'ssl_names'} // '';
my $exclude_paths = $form->{'exclude_paths'} // '';
my $terminate = $form->{'terminate'} // '';
# Collect selected types
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 '';
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}];
} elsif ($type eq 'files') {
$opts{path} = $path if $path ne '';