mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-10-08 22:46:41 +00:00
Co-authored-by: MattiaC46 <52442052+MattiaC46@users.noreply.github.com>
604 lines
24 KiB
Bash
604 lines
24 KiB
Bash
#!/bin/bash
|
|
|
|
# ==========================================================
|
|
# ProxMenux - VM/CT Backup Jobs Manager
|
|
# ==========================================================
|
|
# Author : MattiaC46 (PR #421), MacRimi
|
|
# Copyright : (c) 2024 MacRimi
|
|
# License : GPL-3.0
|
|
# https://github.com/MacRimi/ProxMenux/blob/main/LICENSE
|
|
# Version : 1.0
|
|
# ==========================================================
|
|
# Description:
|
|
# View, create, edit, enable/disable, delete and run on-demand
|
|
# the PVE backup jobs normally found under
|
|
# Datacenter > Backup
|
|
# This is a thin wrapper around the official `pvesh` API
|
|
# (/cluster/backup), so jobs created/edited here show up exactly
|
|
# the same in the native PVE web UI and vice versa.
|
|
# ==========================================================
|
|
|
|
# Configuration ============================================
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
LOCAL_SCRIPTS_LOCAL="$(cd "$SCRIPT_DIR/.." && pwd)"
|
|
LOCAL_SCRIPTS_DEFAULT="/usr/local/share/proxmenux/scripts"
|
|
LOCAL_SCRIPTS="$LOCAL_SCRIPTS_DEFAULT"
|
|
BASE_DIR="/usr/local/share/proxmenux"
|
|
UTILS_FILE="$LOCAL_SCRIPTS/utils.sh"
|
|
|
|
if [[ -f "$LOCAL_SCRIPTS_LOCAL/utils.sh" ]]; then
|
|
LOCAL_SCRIPTS="$LOCAL_SCRIPTS_LOCAL"
|
|
UTILS_FILE="$LOCAL_SCRIPTS/utils.sh"
|
|
elif [[ ! -f "$UTILS_FILE" ]]; then
|
|
UTILS_FILE="$BASE_DIR/utils.sh"
|
|
fi
|
|
|
|
# shellcheck source=/dev/null
|
|
if [[ -f "$UTILS_FILE" ]]; then
|
|
source "$UTILS_FILE"
|
|
else
|
|
echo "Error: $UTILS_FILE not found"
|
|
exit 1
|
|
fi
|
|
|
|
load_language
|
|
initialize_cache
|
|
|
|
BACKTITLE="ProxMenux"
|
|
UI_W=84
|
|
UI_H=22
|
|
UI_LIST_H=12
|
|
|
|
function header() {
|
|
show_proxmenux_logo
|
|
msg_title "$(translate "VM/CT Backup Jobs (Datacenter > Backup)")"
|
|
}
|
|
|
|
# ── Sanity checks ──────────────────────────────────────────
|
|
|
|
if ! command -v pvesh >/dev/null 2>&1; then
|
|
dialog --backtitle "$BACKTITLE" --title "$(translate "Error")" \
|
|
--msgbox "$(translate "pvesh not found. This must run on a Proxmox VE node.")" 8 60
|
|
exit 1
|
|
fi
|
|
|
|
if ! command -v jq >/dev/null 2>&1; then
|
|
dialog --backtitle "$BACKTITLE" --title "$(translate "Error")" \
|
|
--msgbox "$(translate "jq is required for this menu and is not installed.")" 8 60
|
|
exit 1
|
|
fi
|
|
|
|
THIS_NODE=$(hostname)
|
|
|
|
# ── Helpers ────────────────────────────────────────────────
|
|
|
|
# Lists storages that accept "backup" content, one per line: id<TAB>type
|
|
list_backup_storages() {
|
|
pvesh get /storage --output-format json 2>/dev/null \
|
|
| jq -r '.[] | select(.content // "" | contains("backup")) | "\(.storage)\t\(.type)"'
|
|
}
|
|
|
|
# Lists VMs and CTs on the whole cluster, one per line: vmid<TAB>name<TAB>kind
|
|
list_guests() {
|
|
{
|
|
pvesh get /cluster/resources --type vm --output-format json 2>/dev/null \
|
|
| jq -r '.[] | select(.type=="qemu" or .type=="lxc") | "\(.vmid)\t\(.name // "-")\t\(.type)"'
|
|
} | sort -n
|
|
}
|
|
|
|
# Host backups of ProxMenux that run with a Proxmox job, comma separated.
|
|
attached_host_backups() {
|
|
local job_id="$1" file found=()
|
|
for file in /var/lib/proxmenux/backup-jobs/*.env; do
|
|
[[ -f "$file" ]] || continue
|
|
grep -qxF "PVE_PARENT_JOB=${job_id}" "$file" 2>/dev/null && found+=("$(basename "$file" .env)")
|
|
done
|
|
local IFS=,
|
|
echo "${found[*]}"
|
|
}
|
|
|
|
# Pretty one-line summary of a job, used in the main list
|
|
job_summary_line() {
|
|
local j="$1"
|
|
local vmid storage schedule mode enabled all
|
|
vmid=$(jq -r '.vmid // empty' <<<"$j")
|
|
all=$(jq -r '.all // 0' <<<"$j")
|
|
storage=$(jq -r '.storage // "-"' <<<"$j")
|
|
schedule=$(jq -r '.schedule // "-"' <<<"$j")
|
|
mode=$(jq -r '.mode // "snapshot"' <<<"$j")
|
|
enabled=$(jq -r '.enabled // 1' <<<"$j")
|
|
|
|
local target
|
|
if [[ "$all" == "1" || "$all" == "true" ]]; then
|
|
target="$(translate "all guests")"
|
|
elif [[ -n "$vmid" ]]; then
|
|
target="VM/CT: $vmid"
|
|
else
|
|
target="$(translate "(no guests selected)")"
|
|
fi
|
|
|
|
local state
|
|
if [[ "$enabled" == "0" || "$enabled" == "false" ]]; then
|
|
state="[$(translate "disabled")]"
|
|
else
|
|
state=""
|
|
fi
|
|
|
|
echo "$schedule | $storage | $mode | $target $state"
|
|
}
|
|
|
|
# ── Guest picker (checklist) ──────────────────────────────
|
|
# Sets GUEST_SELECTION to "ALL" or a comma-separated vmid list.
|
|
# Returns 1 on cancel.
|
|
select_guests() {
|
|
local pick
|
|
pick=$(dialog --backtitle "$BACKTITLE" --title "$(translate "Select Guests")" \
|
|
--menu "\n$(translate "Which guests should this job back up?")" 12 70 2 \
|
|
"ALL" "$(translate "All guests on the cluster")" \
|
|
"PICK" "$(translate "Choose specific VMs/CTs")" \
|
|
3>&1 1>&2 2>&3)
|
|
[[ $? -ne 0 ]] && return 1
|
|
|
|
if [[ "$pick" == "ALL" ]]; then
|
|
GUEST_SELECTION="ALL"
|
|
return 0
|
|
fi
|
|
|
|
local guests=()
|
|
while IFS=$'\t' read -r vmid name kind; do
|
|
[[ -z "$vmid" ]] && continue
|
|
guests+=("$vmid" "$name ($kind)" "off")
|
|
done < <(list_guests)
|
|
|
|
if [[ ${#guests[@]} -eq 0 ]]; then
|
|
dialog --backtitle "$BACKTITLE" --title "$(translate "Error")" \
|
|
--msgbox "$(translate "No VMs or CTs found on this cluster.")" 8 60
|
|
return 1
|
|
fi
|
|
|
|
local raw
|
|
raw=$(dialog --backtitle "$BACKTITLE" --title "$(translate "Select Guests")" \
|
|
--checklist "\n$(translate "Mark the VMs/CTs to include in this job:")" 20 70 12 \
|
|
"${guests[@]}" \
|
|
3>&1 1>&2 2>&3)
|
|
[[ $? -ne 0 ]] && return 1
|
|
|
|
GUEST_SELECTION=$(echo "$raw" | tr -d '"' | tr -s ' ' ',' | sed 's/^,//;s/,$//')
|
|
if [[ -z "$GUEST_SELECTION" ]]; then
|
|
dialog --backtitle "$BACKTITLE" --title "$(translate "Error")" \
|
|
--msgbox "$(translate "No guest selected.")" 8 60
|
|
return 1
|
|
fi
|
|
return 0
|
|
}
|
|
|
|
# ── Storage picker ─────────────────────────────────────────
|
|
# Sets JOB_STORAGE. Returns 1 on cancel.
|
|
select_storage() {
|
|
local items=()
|
|
while IFS=$'\t' read -r sid stype; do
|
|
[[ -z "$sid" ]] && continue
|
|
items+=("$sid" "$stype")
|
|
done < <(list_backup_storages)
|
|
|
|
if [[ ${#items[@]} -eq 0 ]]; then
|
|
dialog --backtitle "$BACKTITLE" --title "$(translate "Error")" \
|
|
--msgbox "$(translate "No storage with 'backup' content enabled was found.\n\nAdd one from Datacenter > Storage first (PBS, or a directory/NFS with the Backup content type).")" 10 70
|
|
return 1
|
|
fi
|
|
|
|
JOB_STORAGE=$(dialog --backtitle "$BACKTITLE" --title "$(translate "Target Storage")" \
|
|
--menu "\n$(translate "Select the storage that will receive the backups:")" "$UI_H" "$UI_W" "$UI_LIST_H" \
|
|
"${items[@]}" \
|
|
3>&1 1>&2 2>&3)
|
|
[[ $? -ne 0 || -z "$JOB_STORAGE" ]] && return 1
|
|
return 0
|
|
}
|
|
|
|
# ── Schedule picker ─────────────────────────────────────────
|
|
# Sets JOB_SCHEDULE (systemd calendar-event syntax). Returns 1 on cancel.
|
|
select_schedule() {
|
|
local pick
|
|
pick=$(dialog --backtitle "$BACKTITLE" --title "$(translate "Schedule")" \
|
|
--menu "\n$(translate "Choose a schedule (systemd calendar-event syntax):")" 18 74 7 \
|
|
"daily-02" "$(translate "Daily at 02:00")" \
|
|
"daily-03" "$(translate "Daily at 03:00")" \
|
|
"weekly-sun" "$(translate "Weekly, Sunday 02:00")" \
|
|
"every-6h" "$(translate "Every 6 hours")" \
|
|
"hourly" "$(translate "Every hour")" \
|
|
"custom" "$(translate "Custom (type it myself)")" \
|
|
3>&1 1>&2 2>&3)
|
|
[[ $? -ne 0 ]] && return 1
|
|
|
|
case "$pick" in
|
|
daily-02) JOB_SCHEDULE="02:00" ;;
|
|
daily-03) JOB_SCHEDULE="03:00" ;;
|
|
weekly-sun) JOB_SCHEDULE="sun 02:00" ;;
|
|
every-6h) JOB_SCHEDULE="00/6:00" ;;
|
|
hourly) JOB_SCHEDULE="hourly" ;;
|
|
custom)
|
|
JOB_SCHEDULE=$(dialog --backtitle "$BACKTITLE" --title "$(translate "Custom Schedule")" \
|
|
--inputbox "\n$(translate "Enter a systemd calendar event, e.g.:")\n 'mon..fri 01:30'\n '*-*-1 03:00' ($(translate "first of every month")))" \
|
|
12 70 "02:00" 3>&1 1>&2 2>&3)
|
|
[[ $? -ne 0 || -z "$JOB_SCHEDULE" ]] && return 1
|
|
;;
|
|
esac
|
|
return 0
|
|
}
|
|
|
|
# ── Mode / compression / retention pickers ─────────────────
|
|
|
|
select_mode() {
|
|
JOB_MODE=$(dialog --backtitle "$BACKTITLE" --title "$(translate "Backup Mode")" \
|
|
--radiolist "\n$(translate "Select the backup mode:")" 14 74 3 \
|
|
"snapshot" "$(translate "Snapshot - no downtime (recommended)")" on \
|
|
"suspend" "$(translate "Suspend - pauses the guest during backup")" off \
|
|
"stop" "$(translate "Stop - shuts the guest down during backup")" off \
|
|
3>&1 1>&2 2>&3)
|
|
[[ $? -ne 0 || -z "$JOB_MODE" ]] && return 1
|
|
return 0
|
|
}
|
|
|
|
select_compress() {
|
|
JOB_COMPRESS=$(dialog --backtitle "$BACKTITLE" --title "$(translate "Compression")" \
|
|
--radiolist "\n$(translate "Select compression:")" 14 74 4 \
|
|
"zstd" "$(translate "ZSTD - fast, good ratio (recommended)")" on \
|
|
"gzip" "$(translate "GZIP - slower, widely compatible")" off \
|
|
"lzo" "$(translate "LZO - fastest, weaker ratio")" off \
|
|
"0" "$(translate "None")" off \
|
|
3>&1 1>&2 2>&3)
|
|
[[ $? -ne 0 || -z "$JOB_COMPRESS" ]] && return 1
|
|
return 0
|
|
}
|
|
|
|
input_retention() {
|
|
local default="${1:-keep-last=3}"
|
|
JOB_PRUNE=$(dialog --backtitle "$BACKTITLE" --title "$(translate "Retention")" \
|
|
--inputbox "\n$(translate "Retention policy (prune-backups syntax).")\n$(translate "Examples:")\n keep-last=3\n keep-daily=7,keep-weekly=4,keep-monthly=6\n\n$(translate "Leave empty to keep every backup (not recommended).")" \
|
|
14 74 "$default" 3>&1 1>&2 2>&3)
|
|
[[ $? -ne 0 ]] && return 1
|
|
return 0
|
|
}
|
|
|
|
input_notes() {
|
|
local default="${1:-{{guestname}}}"
|
|
JOB_NOTES=$(dialog --backtitle "$BACKTITLE" --title "$(translate "Notes Template")" \
|
|
--inputbox "\n$(translate "Notes template shown next to each backup (optional).")\n$(translate "Placeholders: {{guestname}} {{node}} {{vmid}}")" \
|
|
12 74 "$default" 3>&1 1>&2 2>&3)
|
|
[[ $? -ne 0 ]] && return 1
|
|
return 0
|
|
}
|
|
|
|
# ── Create ───────────────────────────────────────────────
|
|
|
|
create_job_wizard() {
|
|
header
|
|
GUEST_SELECTION=""; JOB_STORAGE=""; JOB_SCHEDULE=""; JOB_MODE=""; JOB_COMPRESS=""; JOB_PRUNE=""; JOB_NOTES=""
|
|
|
|
select_guests || return 1
|
|
select_storage || return 1
|
|
select_schedule || return 1
|
|
select_mode || return 1
|
|
select_compress || return 1
|
|
input_retention "keep-last=3" || return 1
|
|
input_notes "{{guestname}}" || return 1
|
|
|
|
local job_id
|
|
job_id=$(dialog --backtitle "$BACKTITLE" --title "$(translate "Job ID (optional)")" \
|
|
--inputbox "\n$(translate "Short name for this job (letters, numbers, hyphens).")\n$(translate "Leave empty to auto-generate one.")" \
|
|
10 70 "" 3>&1 1>&2 2>&3)
|
|
[[ $? -ne 0 ]] && return 1
|
|
|
|
local args=(create /cluster/backup --storage "$JOB_STORAGE" --schedule "$JOB_SCHEDULE" \
|
|
--mode "$JOB_MODE" --compress "$JOB_COMPRESS" --enabled 1)
|
|
[[ -n "$job_id" ]] && args+=(--id "$job_id")
|
|
if [[ "$GUEST_SELECTION" == "ALL" ]]; then
|
|
args+=(--all 1)
|
|
else
|
|
args+=(--vmid "$GUEST_SELECTION")
|
|
fi
|
|
[[ -n "$JOB_PRUNE" ]] && args+=(--prune-backups "$JOB_PRUNE")
|
|
[[ -n "$JOB_NOTES" ]] && args+=(--notes-template "$JOB_NOTES")
|
|
|
|
header
|
|
msg_info "$(translate "Creating backup job...")"
|
|
local out
|
|
if out=$(pvesh "${args[@]}" 2>&1); then
|
|
msg_ok "$(translate "Backup job created successfully.")"
|
|
else
|
|
msg_error "$(translate "Failed to create the job.")"
|
|
echo "$out"
|
|
fi
|
|
echo
|
|
read -n 1 -s -r -p "$(translate "Press any key to continue...")"
|
|
}
|
|
|
|
# ── Edit ─────────────────────────────────────────────────
|
|
|
|
edit_job_wizard() {
|
|
local job_id="$1"
|
|
local current
|
|
current=$(pvesh get "/cluster/backup/$job_id" --output-format json 2>/dev/null)
|
|
if [[ -z "$current" ]]; then
|
|
dialog --backtitle "$BACKTITLE" --title "$(translate "Error")" \
|
|
--msgbox "$(translate "Could not read this job.")" 8 60
|
|
return 1
|
|
fi
|
|
|
|
local cur_vmid cur_all cur_storage cur_schedule cur_mode cur_compress cur_prune cur_notes
|
|
cur_vmid=$(jq -r '.vmid // empty' <<<"$current")
|
|
cur_all=$(jq -r '.all // 0' <<<"$current")
|
|
cur_storage=$(jq -r '.storage // empty' <<<"$current")
|
|
cur_schedule=$(jq -r '.schedule // empty' <<<"$current")
|
|
cur_mode=$(jq -r '.mode // "snapshot"' <<<"$current")
|
|
cur_compress=$(jq -r '.compress // "zstd"' <<<"$current")
|
|
cur_prune=$(jq -r '."prune-backups" // empty | if type == "object" then to_entries | map("\(.key)=\(.value)") | join(",") else . end' <<<"$current")
|
|
cur_notes=$(jq -r '."notes-template" // empty' <<<"$current")
|
|
|
|
local field
|
|
while true; do
|
|
field=$(dialog --backtitle "$BACKTITLE" --title "$(translate "Edit Job") $job_id" \
|
|
--menu "\n$(translate "What do you want to change?")" 20 74 8 \
|
|
"guests" "$(translate "Guests") ($( [[ "$cur_all" == "1" ]] && echo "all" || echo "$cur_vmid" ))" \
|
|
"storage" "$(translate "Storage") ($cur_storage)" \
|
|
"schedule" "$(translate "Schedule") ($cur_schedule)" \
|
|
"mode" "$(translate "Mode") ($cur_mode)" \
|
|
"compress" "$(translate "Compression") ($cur_compress)" \
|
|
"prune" "$(translate "Retention") ($cur_prune)" \
|
|
"notes" "$(translate "Notes template")" \
|
|
"SAVE" "$(translate ">> Save and return <<")" \
|
|
3>&1 1>&2 2>&3)
|
|
[[ $? -ne 0 ]] && return 1
|
|
|
|
case "$field" in
|
|
guests)
|
|
if select_guests; then
|
|
if [[ "$GUEST_SELECTION" == "ALL" ]]; then
|
|
cur_all=1; cur_vmid=""
|
|
else
|
|
cur_all=0; cur_vmid="$GUEST_SELECTION"
|
|
fi
|
|
fi
|
|
;;
|
|
storage)
|
|
if select_storage; then
|
|
local attached
|
|
attached=$(attached_host_backups "$job_id")
|
|
if [[ -n "$attached" && "$JOB_STORAGE" != "$cur_storage" ]]; then
|
|
dialog --backtitle "$BACKTITLE" --title "$(translate "Storage")" \
|
|
--msgbox "\n$(translate "These host backups run with this job and expect its storage:") $attached\n\n$(translate "With another storage they stop running.")" 12 70
|
|
fi
|
|
cur_storage="$JOB_STORAGE"
|
|
fi
|
|
;;
|
|
schedule) select_schedule && cur_schedule="$JOB_SCHEDULE" ;;
|
|
mode) select_mode && cur_mode="$JOB_MODE" ;;
|
|
compress) select_compress && cur_compress="$JOB_COMPRESS" ;;
|
|
prune) input_retention "$cur_prune" && cur_prune="$JOB_PRUNE" ;;
|
|
notes) input_notes "$cur_notes" && cur_notes="$JOB_NOTES" ;;
|
|
SAVE)
|
|
local args=(set "/cluster/backup/$job_id" \
|
|
--storage "$cur_storage" --schedule "$cur_schedule" \
|
|
--mode "$cur_mode" --compress "$cur_compress")
|
|
if [[ "$cur_all" == "1" ]]; then
|
|
args+=(--all 1 --delete vmid)
|
|
else
|
|
args+=(--vmid "$cur_vmid" --delete all)
|
|
fi
|
|
if [[ -n "$cur_prune" ]]; then
|
|
args+=(--prune-backups "$cur_prune")
|
|
else
|
|
args+=(--delete prune-backups)
|
|
fi
|
|
if [[ -n "$cur_notes" ]]; then
|
|
args+=(--notes-template "$cur_notes")
|
|
else
|
|
args+=(--delete notes-template)
|
|
fi
|
|
|
|
header
|
|
msg_info "$(translate "Saving changes...")"
|
|
local out
|
|
if out=$(pvesh "${args[@]}" 2>&1); then
|
|
msg_ok "$(translate "Job updated successfully.")"
|
|
else
|
|
msg_error "$(translate "Failed to update the job.")"
|
|
echo "$out"
|
|
fi
|
|
echo
|
|
read -n 1 -s -r -p "$(translate "Press any key to continue...")"
|
|
return 0
|
|
;;
|
|
esac
|
|
done
|
|
}
|
|
|
|
# ── Delete / Toggle / Run now ───────────────────────────────
|
|
|
|
delete_job() {
|
|
local job_id="$1"
|
|
local attached warning=""
|
|
attached=$(attached_host_backups "$job_id")
|
|
[[ -n "$attached" ]] && warning="\n\n$(translate "These host backups run with this job and will stop running:") $attached"
|
|
dialog --backtitle "$BACKTITLE" --title "$(translate "Confirm Delete")" \
|
|
--yesno "\n$(translate "Delete backup job") '$job_id'?\n\n$(translate "This only removes the schedule. Existing backup archives are NOT deleted.")${warning}" 14 70
|
|
[[ $? -ne 0 ]] && return 1
|
|
|
|
header
|
|
msg_info "$(translate "Deleting job...")"
|
|
local out
|
|
if out=$(pvesh delete "/cluster/backup/$job_id" 2>&1); then
|
|
msg_ok "$(translate "Job deleted.")"
|
|
else
|
|
msg_error "$(translate "Failed to delete the job.")"
|
|
echo "$out"
|
|
fi
|
|
echo
|
|
read -n 1 -s -r -p "$(translate "Press any key to continue...")"
|
|
}
|
|
|
|
toggle_job() {
|
|
local job_id="$1" enabled="$2"
|
|
local new=1
|
|
[[ "$enabled" != "0" && "$enabled" != "false" ]] && new=0
|
|
|
|
header
|
|
msg_info "$(translate "Updating job status...")"
|
|
local out
|
|
if out=$(pvesh set "/cluster/backup/$job_id" --enabled "$new" 2>&1); then
|
|
if [[ "$new" == "1" ]]; then
|
|
msg_ok "$(translate "Job enabled.")"
|
|
else
|
|
msg_ok "$(translate "Job disabled.")"
|
|
fi
|
|
else
|
|
msg_error "$(translate "Failed to update the job.")"
|
|
echo "$out"
|
|
fi
|
|
echo
|
|
sleep 1
|
|
}
|
|
|
|
# Runs the job now with its own settings. Each node runs its part as a
|
|
# Proxmox task, so it is listed and logged like a scheduled run.
|
|
run_job_now() {
|
|
local job_id="$1"
|
|
local current
|
|
current=$(pvesh get "/cluster/backup/$job_id" --output-format json 2>/dev/null)
|
|
[[ -z "$current" ]] && return 1
|
|
|
|
local mode
|
|
mode=$(jq -r '.mode // "snapshot"' <<<"$current")
|
|
local warning=""
|
|
[[ "$mode" == "stop" ]] && warning="\n\n$(translate "The mode of this job is stop: its guests are shut down during the backup.")"
|
|
|
|
dialog --backtitle "$BACKTITLE" --title "$(translate "Run Now")" \
|
|
--yesno "\n$(translate "Run job") '$job_id' $(translate "now, with its current settings?")\n\n$(translate "The backup runs as a Proxmox task; follow it in the task log of Proxmox.")${warning}" 14 74
|
|
[[ $? -ne 0 ]] && return 1
|
|
|
|
header
|
|
msg_info "$(translate "Starting the backup...")"
|
|
|
|
local nodes=() node
|
|
node=$(jq -r '.node // empty' <<<"$current")
|
|
if [[ -n "$node" ]]; then
|
|
nodes=("$node")
|
|
elif [[ "$(jq -r '.all // 0' <<<"$current")" =~ ^(1|true)$ || -n "$(jq -r '.pool // empty' <<<"$current")" ]]; then
|
|
mapfile -t nodes < <(pvesh get /nodes --output-format json 2>/dev/null \
|
|
| jq -r '.[] | select(.status=="online") | .node')
|
|
else
|
|
local wanted
|
|
wanted=$(jq -c '(.vmid // "") | tostring | split(",") | map(gsub(" "; ""))' <<<"$current")
|
|
mapfile -t nodes < <(pvesh get /cluster/resources --type vm --output-format json 2>/dev/null \
|
|
| jq -r --argjson wanted "$wanted" '[.[] | select((.vmid|tostring) as $id | $wanted | index($id)) | .node] | unique | .[]')
|
|
fi
|
|
|
|
if [[ ${#nodes[@]} -eq 0 ]]; then
|
|
msg_error "$(translate "No online node has a guest of this job.")"
|
|
echo
|
|
read -n 1 -s -r -p "$(translate "Press any key to continue...")"
|
|
return 1
|
|
fi
|
|
|
|
# Every option of the job that vzdump understands, as it is stored.
|
|
local run_args=()
|
|
mapfile -t run_args < <(jq -r '
|
|
del(.id, .type, .schedule, .enabled, ."next-run", .comment, ."repeat-missed", .digest, .node, .starttime, .dow)
|
|
| to_entries[] | select(.value != null and .value != "")
|
|
| "--\(.key)",
|
|
(if (.value|type) == "object" then (.value | to_entries | map("\(.key)=\(.value)") | join(","))
|
|
elif (.value|type) == "boolean" then (if .value then "1" else "0" end)
|
|
else (.value|tostring) end)' <<<"$current")
|
|
|
|
stop_spinner
|
|
local out
|
|
for node in "${nodes[@]}"; do
|
|
if out=$(pvesh create "/nodes/$node/vzdump" "${run_args[@]}" 2>&1); then
|
|
msg_ok "$(translate "Backup started on node") $node"
|
|
echo -e "${TAB}$(grep -o 'UPID:[^"[:space:]]*' <<<"$out" | head -1)"
|
|
else
|
|
msg_error "$(translate "The backup could not be started on node") $node"
|
|
echo "$out"
|
|
fi
|
|
done
|
|
|
|
echo
|
|
read -n 1 -s -r -p "$(translate "Press any key to continue...")"
|
|
}
|
|
|
|
# ── Job list / detail menu ──────────────────────────────────
|
|
|
|
job_detail_menu() {
|
|
local job_id="$1"
|
|
while true; do
|
|
local current enabled
|
|
current=$(pvesh get "/cluster/backup/$job_id" --output-format json 2>/dev/null)
|
|
[[ -z "$current" ]] && return
|
|
enabled=$(jq -r '.enabled // 1' <<<"$current")
|
|
|
|
local toggle_label
|
|
if [[ "$enabled" == "0" || "$enabled" == "false" ]]; then
|
|
toggle_label="$(translate "Enable job")"
|
|
else
|
|
toggle_label="$(translate "Disable job")"
|
|
fi
|
|
|
|
local choice
|
|
choice=$(dialog --backtitle "$BACKTITLE" --title "$(translate "Job") $job_id" \
|
|
--menu "\n$(job_summary_line "$current")" 16 78 6 \
|
|
"run" "$(translate "Run now")" \
|
|
"edit" "$(translate "Edit")" \
|
|
"toggle" "$toggle_label" \
|
|
"delete" "$(translate "Delete")" \
|
|
"back" "$(translate "Back to job list")" \
|
|
3>&1 1>&2 2>&3)
|
|
[[ $? -ne 0 || "$choice" == "back" ]] && return
|
|
|
|
case "$choice" in
|
|
run) run_job_now "$job_id" ;;
|
|
edit) edit_job_wizard "$job_id" ;;
|
|
toggle) toggle_job "$job_id" "$enabled" ;;
|
|
delete) delete_job "$job_id"; return ;;
|
|
esac
|
|
done
|
|
}
|
|
|
|
list_jobs_menu() {
|
|
while true; do
|
|
local jobs_json
|
|
jobs_json=$(pvesh get /cluster/backup --output-format json 2>/dev/null)
|
|
|
|
local count
|
|
count=$(jq 'length' <<<"${jobs_json:-[]}" 2>/dev/null)
|
|
[[ -z "$count" ]] && count=0
|
|
|
|
local items=()
|
|
if [[ "$count" -gt 0 ]]; then
|
|
while IFS=$'\t' read -r jid jsummary; do
|
|
[[ -z "$jid" ]] && continue
|
|
items+=("$jid" "$jsummary")
|
|
done < <(jq -r '.[] | [.id, (.schedule // "-")+" | "+(.storage // "-")+" | "+(.vmid // (if .all==1 then "all" else "-" end))] | @tsv' <<<"$jobs_json")
|
|
fi
|
|
|
|
items+=("NEW" "$(translate ">> Create a new backup job <<")")
|
|
items+=("BACK" "$(translate "Return")")
|
|
|
|
header
|
|
local choice
|
|
choice=$(dialog --backtitle "$BACKTITLE" --title "$(translate "Backup Jobs") ($THIS_NODE)" \
|
|
--menu "\n$(translate "Select a job to view/edit, or create a new one:")" 22 86 12 \
|
|
"${items[@]}" \
|
|
3>&1 1>&2 2>&3)
|
|
[[ $? -ne 0 || "$choice" == "BACK" ]] && exit 0
|
|
|
|
if [[ "$choice" == "NEW" ]]; then
|
|
create_job_wizard
|
|
else
|
|
job_detail_menu "$choice"
|
|
fi
|
|
done
|
|
}
|
|
|
|
# ── Entry point ──────────────────────────────────────────────
|
|
list_jobs_menu
|