Add exclude paths for restore and skip-suspended/schedule enhancements
- Add --exclude flag to restore account/files commands to skip specific paths during homedir restoration (rsync --exclude / rclone --exclude) - Add exclude paths UI in WHM restore form (step 2 tag input + modal, step 3 summary, step 4 command building) - Add rclone_from_remote_filtered() for passing extra args to rclone copy - Add _build_exclude_args() helper in restore.sh - Add exclude pattern to Runner.pm allowlist - Add skip-suspended flag and schedule configuration enhancements Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -163,6 +163,43 @@ rclone_from_remote() {
|
|||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Like rclone_from_remote but passes extra args (e.g. --exclude) to rclone copy.
|
||||||
|
# Usage: rclone_from_remote_filtered <remote_subpath> <local_dir> [extra_args...]
|
||||||
|
rclone_from_remote_filtered() {
|
||||||
|
local remote_subpath="$1"
|
||||||
|
local local_dir="$2"
|
||||||
|
shift 2
|
||||||
|
local -a extra_args=("$@")
|
||||||
|
local attempt=0
|
||||||
|
local max_retries="${SSH_RETRIES:-$DEFAULT_SSH_RETRIES}"
|
||||||
|
local remote_src; remote_src=$(_rclone_remote_path "$remote_subpath")
|
||||||
|
|
||||||
|
mkdir -p "$local_dir" || {
|
||||||
|
log_error "Failed to create local dir: $local_dir"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
while (( attempt < max_retries )); do
|
||||||
|
((attempt++)) || true
|
||||||
|
log_debug "rclone copy (filtered) attempt $attempt/$max_retries: $remote_src -> $local_dir"
|
||||||
|
|
||||||
|
if _rclone_cmd copy "$remote_src" "$local_dir" "${extra_args[@]}"; then
|
||||||
|
log_debug "rclone download succeeded on attempt $attempt"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_warn "rclone download failed, attempt $attempt/$max_retries"
|
||||||
|
if (( attempt < max_retries )); then
|
||||||
|
local backoff=$(( attempt * 10 ))
|
||||||
|
log_info "Retrying in ${backoff}s..."
|
||||||
|
sleep "$backoff"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
log_error "rclone download failed after $max_retries attempts"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
# ── Snapshot Management ───────────────────────────────────────
|
# ── Snapshot Management ───────────────────────────────────────
|
||||||
|
|
||||||
rclone_list_dirs() {
|
rclone_list_dirs() {
|
||||||
|
|||||||
@@ -19,6 +19,21 @@ _rsync_download() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Helper: build --exclude args from comma-separated exclude string.
|
||||||
|
# Outputs one arg per line (--exclude then pattern).
|
||||||
|
_build_exclude_args() {
|
||||||
|
local exclude="$1"
|
||||||
|
local -a args=()
|
||||||
|
if [[ -n "$exclude" ]]; then
|
||||||
|
IFS=',' read -ra patterns <<< "$exclude"
|
||||||
|
for p in "${patterns[@]}"; do
|
||||||
|
p="${p## }"; p="${p%% }"
|
||||||
|
[[ -n "$p" ]] && args+=(--exclude "$p")
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
printf '%s\n' "${args[@]}"
|
||||||
|
}
|
||||||
|
|
||||||
# Helper: detect pkgacct base (old vs new format) for rclone or SSH
|
# Helper: detect pkgacct base (old vs new format) for rclone or SSH
|
||||||
_detect_pkgacct_base() {
|
_detect_pkgacct_base() {
|
||||||
local snap_path="$1"
|
local snap_path="$1"
|
||||||
@@ -42,9 +57,16 @@ _detect_pkgacct_base() {
|
|||||||
restore_full_account() {
|
restore_full_account() {
|
||||||
local user="$1"
|
local user="$1"
|
||||||
local timestamp="${2:-}"
|
local timestamp="${2:-}"
|
||||||
local force="${3:-false}"
|
local strategy="${3:-}"
|
||||||
|
local exclude="${4:-}"
|
||||||
local temp_dir="${TEMP_DIR:-$DEFAULT_TEMP_DIR}"
|
local temp_dir="${TEMP_DIR:-$DEFAULT_TEMP_DIR}"
|
||||||
|
|
||||||
|
# Validate strategy
|
||||||
|
if [[ -n "$strategy" ]] && [[ "$strategy" != "merge" ]] && [[ "$strategy" != "terminate" ]]; then
|
||||||
|
log_error "Invalid strategy: $strategy (must be 'merge' or 'terminate')"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
# Resolve timestamp
|
# Resolve timestamp
|
||||||
local ts; ts=$(resolve_snapshot_timestamp "$user" "$timestamp") || return 1
|
local ts; ts=$(resolve_snapshot_timestamp "$user" "$timestamp") || return 1
|
||||||
local snap_dir; snap_dir=$(get_snapshot_dir "$user")
|
local snap_dir; snap_dir=$(get_snapshot_dir "$user")
|
||||||
@@ -53,11 +75,21 @@ restore_full_account() {
|
|||||||
log_info "Restoring full account: $user from snapshot $ts"
|
log_info "Restoring full account: $user from snapshot $ts"
|
||||||
|
|
||||||
# Check if account already exists
|
# Check if account already exists
|
||||||
if account_exists "$user" && [[ "$force" != "true" ]]; then
|
if account_exists "$user" && [[ -z "$strategy" ]]; then
|
||||||
log_error "Account $user already exists. Use --force to overwrite."
|
log_error "Account $user already exists. Use --strategy=merge or --strategy=terminate to proceed."
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Terminate strategy: remove existing account first
|
||||||
|
if [[ "$strategy" == "terminate" ]] && account_exists "$user"; then
|
||||||
|
log_info "Terminating existing account $user (--strategy=terminate)..."
|
||||||
|
if ! /scripts/removeacct --keepdns "$user"; then
|
||||||
|
log_error "Failed to terminate account $user"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
log_info "Account $user terminated, proceeding with clean restore"
|
||||||
|
fi
|
||||||
|
|
||||||
local restore_dir="$temp_dir/restore-$user"
|
local restore_dir="$temp_dir/restore-$user"
|
||||||
mkdir -p "$restore_dir" || {
|
mkdir -p "$restore_dir" || {
|
||||||
log_error "Failed to create restore temp directory"
|
log_error "Failed to create restore temp directory"
|
||||||
@@ -119,7 +151,7 @@ restore_full_account() {
|
|||||||
|
|
||||||
# Run restorepkg
|
# Run restorepkg
|
||||||
log_info "Running restorepkg for $user..."
|
log_info "Running restorepkg for $user..."
|
||||||
if [[ "$force" == "true" ]] && account_exists "$user"; then
|
if [[ "$strategy" == "merge" ]] && account_exists "$user"; then
|
||||||
if ! /scripts/restorepkg --force "$restore_dir/$user"; then
|
if ! /scripts/restorepkg --force "$restore_dir/$user"; then
|
||||||
log_error "restorepkg failed for $user"
|
log_error "restorepkg failed for $user"
|
||||||
rm -rf "$restore_dir"
|
rm -rf "$restore_dir"
|
||||||
@@ -133,16 +165,32 @@ restore_full_account() {
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Build exclude args for homedir phase
|
||||||
|
local -a exclude_args=()
|
||||||
|
if [[ -n "$exclude" ]]; then
|
||||||
|
while IFS= read -r arg; do
|
||||||
|
exclude_args+=("$arg")
|
||||||
|
done < <(_build_exclude_args "$exclude")
|
||||||
|
fi
|
||||||
|
|
||||||
# Restore homedir
|
# Restore homedir
|
||||||
local homedir; homedir=$(get_account_homedir "$user")
|
local homedir; homedir=$(get_account_homedir "$user")
|
||||||
if _is_rclone_mode; then
|
if _is_rclone_mode; then
|
||||||
local snap_subpath="accounts/${user}/snapshots/${ts}"
|
local snap_subpath="accounts/${user}/snapshots/${ts}"
|
||||||
if rclone_exists "${snap_subpath}/homedir/"; then
|
if rclone_exists "${snap_subpath}/homedir/"; then
|
||||||
log_info "Restoring homedir for $user..."
|
log_info "Restoring homedir for $user..."
|
||||||
if ! rclone_from_remote "${snap_subpath}/homedir" "$homedir"; then
|
if [[ ${#exclude_args[@]} -gt 0 ]]; then
|
||||||
log_error "Failed to restore homedir for $user"
|
if ! rclone_from_remote_filtered "${snap_subpath}/homedir" "$homedir" "${exclude_args[@]}"; then
|
||||||
rm -rf "$restore_dir"
|
log_error "Failed to restore homedir for $user"
|
||||||
return 1
|
rm -rf "$restore_dir"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
if ! rclone_from_remote "${snap_subpath}/homedir" "$homedir"; then
|
||||||
|
log_error "Failed to restore homedir for $user"
|
||||||
|
rm -rf "$restore_dir"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
log_info "Fixing ownership for $homedir..."
|
log_info "Fixing ownership for $homedir..."
|
||||||
chown -R "$user":"$user" "$homedir"
|
chown -R "$user":"$user" "$homedir"
|
||||||
@@ -151,6 +199,7 @@ restore_full_account() {
|
|||||||
log_info "Restoring homedir for $user..."
|
log_info "Restoring homedir for $user..."
|
||||||
local rsync_ssh; rsync_ssh=$(build_rsync_ssh_cmd)
|
local rsync_ssh; rsync_ssh=$(build_rsync_ssh_cmd)
|
||||||
if ! rsync -aHAX --numeric-ids --rsync-path="rsync --fake-super" \
|
if ! rsync -aHAX --numeric-ids --rsync-path="rsync --fake-super" \
|
||||||
|
"${exclude_args[@]}" \
|
||||||
-e "$rsync_ssh" \
|
-e "$rsync_ssh" \
|
||||||
"${REMOTE_USER}@${REMOTE_HOST}:${snap_path}/homedir/" \
|
"${REMOTE_USER}@${REMOTE_HOST}:${snap_path}/homedir/" \
|
||||||
"$homedir/"; then
|
"$homedir/"; then
|
||||||
@@ -173,6 +222,7 @@ restore_files() {
|
|||||||
local user="$1"
|
local user="$1"
|
||||||
local subpath="${2:-}"
|
local subpath="${2:-}"
|
||||||
local timestamp="${3:-}"
|
local timestamp="${3:-}"
|
||||||
|
local exclude="${4:-}"
|
||||||
|
|
||||||
local ts; ts=$(resolve_snapshot_timestamp "$user" "$timestamp") || return 1
|
local ts; ts=$(resolve_snapshot_timestamp "$user" "$timestamp") || return 1
|
||||||
|
|
||||||
@@ -186,14 +236,29 @@ restore_files() {
|
|||||||
|
|
||||||
log_info "Restoring files for $user: ${subpath:-entire homedir} from snapshot $ts"
|
log_info "Restoring files for $user: ${subpath:-entire homedir} from snapshot $ts"
|
||||||
|
|
||||||
|
# Build exclude args
|
||||||
|
local -a exclude_args=()
|
||||||
|
if [[ -n "$exclude" ]]; then
|
||||||
|
while IFS= read -r arg; do
|
||||||
|
exclude_args+=("$arg")
|
||||||
|
done < <(_build_exclude_args "$exclude")
|
||||||
|
fi
|
||||||
|
|
||||||
if _is_rclone_mode; then
|
if _is_rclone_mode; then
|
||||||
local snap_subpath="accounts/${user}/snapshots/${ts}"
|
local snap_subpath="accounts/${user}/snapshots/${ts}"
|
||||||
local remote_sub="${snap_subpath}/homedir"
|
local remote_sub="${snap_subpath}/homedir"
|
||||||
[[ -n "$subpath" ]] && remote_sub="${snap_subpath}/homedir/${subpath}"
|
[[ -n "$subpath" ]] && remote_sub="${snap_subpath}/homedir/${subpath}"
|
||||||
|
|
||||||
if ! rclone_from_remote "$remote_sub" "$local_dest"; then
|
if [[ ${#exclude_args[@]} -gt 0 ]]; then
|
||||||
log_error "Failed to restore files for $user"
|
if ! rclone_from_remote_filtered "$remote_sub" "$local_dest" "${exclude_args[@]}"; then
|
||||||
return 1
|
log_error "Failed to restore files for $user"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
if ! rclone_from_remote "$remote_sub" "$local_dest"; then
|
||||||
|
log_error "Failed to restore files for $user"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
local snap_dir; snap_dir=$(get_snapshot_dir "$user")
|
local snap_dir; snap_dir=$(get_snapshot_dir "$user")
|
||||||
@@ -204,6 +269,7 @@ restore_files() {
|
|||||||
[[ -n "$subpath" ]] && remote_source="${snap_path}/homedir/${subpath}"
|
[[ -n "$subpath" ]] && remote_source="${snap_path}/homedir/${subpath}"
|
||||||
|
|
||||||
if ! rsync -aHAX --numeric-ids --rsync-path="rsync --fake-super" \
|
if ! rsync -aHAX --numeric-ids --rsync-path="rsync --fake-super" \
|
||||||
|
"${exclude_args[@]}" \
|
||||||
-e "$rsync_ssh" \
|
-e "$rsync_ssh" \
|
||||||
"${REMOTE_USER}@${REMOTE_HOST}:${remote_source}" \
|
"${REMOTE_USER}@${REMOTE_HOST}:${remote_source}" \
|
||||||
"$local_dest"; then
|
"$local_dest"; then
|
||||||
@@ -1348,7 +1414,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" "true"; then
|
if restore_full_account "$user" "$timestamp" "merge"; then
|
||||||
((succeeded++)) || true
|
((succeeded++)) || true
|
||||||
else
|
else
|
||||||
((failed++)) || true
|
((failed++)) || true
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -1,2 +1,2 @@
|
|||||||
<!-- Tailwind/DaisyUI class safelist for gniza WHM plugin -->
|
<!-- 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-info btn-primary btn-secondary btn-sm btn-xs card card-body card-title checkbox checkbox-sm collapse collapse-arrow collapse-content collapse-title 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.6rem] text-warning badge-info badge-neutral btn-active leading-relaxed inline-flex items-stretch w-fit bg-[#fafafa] px-5 max-h-[360px] m-0 no-underline bg-white p-2.5 animate-pulse badge-outline"></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-info btn-primary btn-secondary btn-sm btn-xs card card-body card-title checkbox checkbox-sm collapse collapse-arrow collapse-content collapse-title 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.6rem] text-warning badge-info badge-neutral btn-active leading-relaxed inline-flex items-stretch w-fit bg-[#fafafa] px-5 max-h-[360px] m-0 no-underline bg-white p-2.5 animate-pulse badge-outline btn-warning btn-circle mt-1 h-40"></div>
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ my %OPT_PATTERNS = (
|
|||||||
timestamp => qr/^\d{4}-\d{2}-\d{2}T\d{6}$/,
|
timestamp => qr/^\d{4}-\d{2}-\d{2}T\d{6}$/,
|
||||||
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_-]*$/,
|
||||||
|
strategy => qr/^(merge|terminate)$/,
|
||||||
|
exclude => qr/^[a-zA-Z0-9_.,\/@ *?\[\]-]+$/,
|
||||||
);
|
);
|
||||||
|
|
||||||
# _validate($cmd, $subcmd, \@args, \%opts)
|
# _validate($cmd, $subcmd, \@args, \%opts)
|
||||||
|
|||||||
@@ -247,6 +247,44 @@ sub handle_step2 {
|
|||||||
print qq{ </div>\n};
|
print qq{ </div>\n};
|
||||||
print qq{</div>\n};
|
print qq{</div>\n};
|
||||||
|
|
||||||
|
# Restore strategy (visible only for Full Account mode)
|
||||||
|
print qq{<div id="strategy-panel" class="flex items-center gap-3 mb-2.5">\n};
|
||||||
|
print qq{ <label class="w-44 font-medium text-sm">Restore Strategy</label>\n};
|
||||||
|
print qq{ <div class="join inline-flex items-stretch">\n};
|
||||||
|
print qq{ <input type="radio" name="strategy" class="join-item btn btn-sm m-0" aria-label="Overwrite (merge)" value="merge" checked>\n};
|
||||||
|
print qq{ <input type="radio" name="strategy" class="join-item btn btn-sm m-0" aria-label="Terminate & re-create" value="terminate">\n};
|
||||||
|
print qq{ </div>\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};
|
||||||
|
print qq{ <h3 class="card-title text-sm">Directories and Files to Exclude</h3>\n};
|
||||||
|
print qq{ <div class="flex items-center gap-2">\n};
|
||||||
|
print qq{ <input type="text" class="input input-bordered input-sm flex-1 max-w-xs" id="exclude-input" placeholder="e.g. public_html/cache">\n};
|
||||||
|
print qq{ <button type="button" class="btn btn-warning btn-sm" onclick="gnizaAddExclude()">Add Path</button>\n};
|
||||||
|
print qq{ <button type="button" class="btn btn-warning btn-sm" onclick="gnizaOpenExcludeModal()">Insert Multiple</button>\n};
|
||||||
|
print qq{ </div>\n};
|
||||||
|
print qq{ <p class="text-xs text-base-content/60">Exclude files and directories from restoration</p>\n};
|
||||||
|
print qq{ <div id="exclude-tags" class="flex flex-wrap gap-1 mt-1"></div>\n};
|
||||||
|
print qq{ <input type="hidden" id="exclude_paths" name="exclude_paths" value="">\n};
|
||||||
|
print qq{</div>\n};
|
||||||
|
print qq{</div>\n};
|
||||||
|
|
||||||
|
# Exclude modal
|
||||||
|
print qq{<dialog id="exclude-modal" class="modal">\n};
|
||||||
|
print qq{<div class="modal-box">\n};
|
||||||
|
print qq{ <h3 class="text-lg font-bold">Directories and Files to Exclude</h3>\n};
|
||||||
|
print qq{ <textarea id="exclude-textarea" class="textarea textarea-bordered w-full h-40 mt-3" placeholder="One path per line"></textarea>\n};
|
||||||
|
print qq{ <p class="text-xs text-base-content/60 mt-1">* Separated by new line</p>\n};
|
||||||
|
print qq{ <div class="modal-action">\n};
|
||||||
|
print qq{ <button type="button" class="btn btn-sm" onclick="document.getElementById('exclude-modal').close()">Cancel</button>\n};
|
||||||
|
print qq{ <button type="button" class="btn btn-warning btn-sm" onclick="gnizaExcludeModalOk()">OK</button>\n};
|
||||||
|
print qq{ </div>\n};
|
||||||
|
print qq{</div>\n};
|
||||||
|
print qq{<div class="modal-backdrop" onclick="this.closest('dialog').close()"><button type="button">close</button></div>\n};
|
||||||
|
print qq{</dialog>\n};
|
||||||
|
|
||||||
# Hidden field that always carries account type when Full is selected
|
# Hidden field that always carries account type when Full is selected
|
||||||
print qq{<input type="hidden" id="type_account_hidden" name="type_account" value="1">\n};
|
print qq{<input type="hidden" id="type_account_hidden" name="type_account" value="1">\n};
|
||||||
|
|
||||||
@@ -414,6 +452,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('strategy-panel').hidden = selective;
|
||||||
document.getElementById('type_account_hidden').disabled = selective;
|
document.getElementById('type_account_hidden').disabled = selective;
|
||||||
if (selective) {
|
if (selective) {
|
||||||
gnizaTypesChanged();
|
gnizaTypesChanged();
|
||||||
@@ -601,6 +640,61 @@ function gnizaPopulatePreview(containerId, options, type) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function gnizaAddExclude() {
|
||||||
|
var input = document.getElementById('exclude-input');
|
||||||
|
var val = input.value.trim();
|
||||||
|
if (!val) return;
|
||||||
|
gnizaAddExcludeTag(val);
|
||||||
|
input.value = '';
|
||||||
|
gnizaUpdateExcludeField();
|
||||||
|
}
|
||||||
|
|
||||||
|
function gnizaAddExcludeTag(text) {
|
||||||
|
var container = document.getElementById('exclude-tags');
|
||||||
|
// Skip duplicates
|
||||||
|
var existing = container.querySelectorAll('.badge span');
|
||||||
|
for (var i = 0; i < existing.length; i++) {
|
||||||
|
if (existing[i].textContent === text) return;
|
||||||
|
}
|
||||||
|
var badge = document.createElement('span');
|
||||||
|
badge.className = 'badge badge-warning gap-1';
|
||||||
|
var span = document.createElement('span');
|
||||||
|
span.textContent = text;
|
||||||
|
badge.appendChild(span);
|
||||||
|
var btn = document.createElement('button');
|
||||||
|
btn.type = 'button';
|
||||||
|
btn.className = 'btn btn-xs btn-ghost btn-circle';
|
||||||
|
btn.innerHTML = '\\u2715';
|
||||||
|
btn.onclick = function() { badge.remove(); gnizaUpdateExcludeField(); };
|
||||||
|
badge.appendChild(btn);
|
||||||
|
container.appendChild(badge);
|
||||||
|
}
|
||||||
|
|
||||||
|
function gnizaUpdateExcludeField() {
|
||||||
|
var tags = document.getElementById('exclude-tags').querySelectorAll('.badge span');
|
||||||
|
var vals = [];
|
||||||
|
for (var i = 0; i < tags.length; i++) {
|
||||||
|
vals.push(tags[i].textContent);
|
||||||
|
}
|
||||||
|
document.getElementById('exclude_paths').value = vals.join(',');
|
||||||
|
}
|
||||||
|
|
||||||
|
function gnizaOpenExcludeModal() {
|
||||||
|
document.getElementById('exclude-textarea').value = '';
|
||||||
|
document.getElementById('exclude-modal').showModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
function gnizaExcludeModalOk() {
|
||||||
|
var text = document.getElementById('exclude-textarea').value;
|
||||||
|
var lines = text.split('\\n');
|
||||||
|
for (var i = 0; i < lines.length; i++) {
|
||||||
|
var line = lines[i].trim();
|
||||||
|
if (line) gnizaAddExcludeTag(line);
|
||||||
|
}
|
||||||
|
gnizaUpdateExcludeField();
|
||||||
|
document.getElementById('exclude-modal').close();
|
||||||
|
}
|
||||||
|
|
||||||
gnizaModeChanged();
|
gnizaModeChanged();
|
||||||
|
|
||||||
function gnizaOpenFileBrowser() {
|
function gnizaOpenFileBrowser() {
|
||||||
@@ -765,7 +859,9 @@ sub handle_step3 {
|
|||||||
my $dbuser_names = $form->{'dbuser_names'} // '';
|
my $dbuser_names = $form->{'dbuser_names'} // '';
|
||||||
my $emails = $form->{'emails'} // '';
|
my $emails = $form->{'emails'} // '';
|
||||||
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 $strategy = $form->{'strategy'} // '';
|
||||||
|
my $exclude_paths = $form->{'exclude_paths'} // '';
|
||||||
|
|
||||||
# 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);
|
||||||
@@ -801,6 +897,12 @@ sub handle_step3 {
|
|||||||
print qq{<tr><td class="font-medium">Snapshot</td><td>$esc_timestamp</td></tr>\n};
|
print qq{<tr><td class="font-medium">Snapshot</td><td>$esc_timestamp</td></tr>\n};
|
||||||
print qq{<tr><td class="font-medium">Restore Types</td><td>$types_display</td></tr>\n};
|
print qq{<tr><td class="font-medium">Restore Types</td><td>$types_display</td></tr>\n};
|
||||||
|
|
||||||
|
if (grep { $_ eq 'account' } @selected_types) {
|
||||||
|
my %strategy_labels = (merge => 'Overwrite (merge)', terminate => 'Terminate & re-create');
|
||||||
|
my $strategy_display = GnizaWHM::UI::esc($strategy_labels{$strategy} // $strategy);
|
||||||
|
print qq{<tr><td class="font-medium">Strategy</td><td>$strategy_display</td></tr>\n};
|
||||||
|
}
|
||||||
|
|
||||||
# Show sub-field details for applicable types
|
# Show sub-field details for applicable types
|
||||||
if (grep { $_ eq 'files' } @selected_types) {
|
if (grep { $_ eq 'files' } @selected_types) {
|
||||||
my $path_display = $path ne '' ? GnizaWHM::UI::esc($path) : 'All files';
|
my $path_display = $path ne '' ? GnizaWHM::UI::esc($path) : 'All files';
|
||||||
@@ -827,6 +929,12 @@ 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 ($exclude_paths ne '') {
|
||||||
|
my $exclude_display = GnizaWHM::UI::esc($exclude_paths);
|
||||||
|
$exclude_display =~ s/,/, /g;
|
||||||
|
print qq{<tr><td class="font-medium">Exclude</td><td>$exclude_display</td></tr>\n};
|
||||||
|
}
|
||||||
|
|
||||||
print qq{</table></div>\n};
|
print qq{</table></div>\n};
|
||||||
print qq{</div>\n</div>\n};
|
print qq{</div>\n</div>\n};
|
||||||
|
|
||||||
@@ -844,6 +952,8 @@ sub handle_step3 {
|
|||||||
print qq{<input type="hidden" name="emails" value="} . GnizaWHM::UI::esc($emails) . qq{">\n};
|
print qq{<input type="hidden" name="emails" value="} . GnizaWHM::UI::esc($emails) . qq{">\n};
|
||||||
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="strategy" value="} . GnizaWHM::UI::esc($strategy) . qq{">\n};
|
||||||
|
print qq{<input type="hidden" name="exclude_paths" value="} . GnizaWHM::UI::esc($exclude_paths) . 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};
|
||||||
@@ -874,7 +984,9 @@ sub handle_step4 {
|
|||||||
my $dbuser_names = $form->{'dbuser_names'} // '';
|
my $dbuser_names = $form->{'dbuser_names'} // '';
|
||||||
my $emails = $form->{'emails'} // '';
|
my $emails = $form->{'emails'} // '';
|
||||||
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 $strategy = $form->{'strategy'} // '';
|
||||||
|
my $exclude_paths = $form->{'exclude_paths'} // '';
|
||||||
|
|
||||||
# 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);
|
||||||
@@ -895,11 +1007,14 @@ sub handle_step4 {
|
|||||||
for my $type (@selected_types) {
|
for my $type (@selected_types) {
|
||||||
my %opts = (remote => $remote);
|
my %opts = (remote => $remote);
|
||||||
$opts{timestamp} = $timestamp if $timestamp ne '';
|
$opts{timestamp} = $timestamp if $timestamp ne '';
|
||||||
|
$opts{strategy} = $strategy if $strategy ne '' && $type eq 'account';
|
||||||
|
|
||||||
if ($SIMPLE_TYPES{$type}) {
|
if ($SIMPLE_TYPES{$type}) {
|
||||||
|
$opts{exclude} = $exclude_paths if $exclude_paths ne '' && $type eq 'account';
|
||||||
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 '';
|
||||||
|
$opts{exclude} = $exclude_paths if $exclude_paths ne '';
|
||||||
push @commands, ['restore', 'files', [$account], {%opts}];
|
push @commands, ['restore', 'files', [$account], {%opts}];
|
||||||
} elsif ($type eq 'database') {
|
} elsif ($type eq 'database') {
|
||||||
my @dbs;
|
my @dbs;
|
||||||
|
|||||||
Reference in New Issue
Block a user