Rewrite as menuconfig-style whiptail bash TUI

Replace the Python Textual app (tui.py) with zfs-snapshot.sh, a single-file
whiptail TUI matching the house style of the sibling scripts in this folder
(sudo re-exec header, msg/yesno/run_and_show helpers, action functions, a
menuconfig-style main menu).

Keeps all prior functionality:
  - Snapshots: list / create / delete / rollback
  - Schedules: cron auto-snapshots with auto-prune (keep last N) + snapshot-now
  - Datasets: usage view
  - LXC Mounts: pct bind-mountpoint add/remove (hidden when pct is absent)
  - Replication: full/incremental zfs send | recv
  - Scrub: start / stop / schedule per pool

Cron entries are kept in a single owner file (/etc/cron.d/pve-zfs-tui);
helper scripts are generated on demand. README rewritten to match.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
yiekheng 2026-05-30 13:26:57 +08:00
parent 5eb7572fa1
commit 936db8b5ff
5 changed files with 755 additions and 1412 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
*.swp
*~

127
README.md
View File

@ -1,85 +1,78 @@
# PVE Storage Manager TUI # proxmox-zfs-snapshot-management-script
Python Textual-based TUI for managing ZFS snapshots and LXC mountpoints on Proxmox VE. An interactive, **menuconfig-style** (whiptail TUI) script to manage ZFS
snapshots and related storage tasks on a **Proxmox VE** host — snapshots,
scheduled auto-snapshots, datasets, LXC bind-mountpoints, dataset replication,
and pool scrubs — all from one keyboard-driven menu.
## Features ## Features
- **Snapshots tab** — List, create, delete, rollback ZFS snapshots across all pools - **Snapshots** — list, **create**, **delete**, and **roll back** ZFS snapshots
- **Schedules tab** — Set up auto-snapshot cron jobs per dataset with auto-pruning (keep last N) (rollback uses `zfs rollback -r`, with a clear destructive-action warning).
- **Datasets tab** — View all ZFS datasets with usage info - **Schedules** — cron-driven **auto-snapshots** per dataset with auto-pruning
- **LXC Mounts tab** — View, add, and remove mountpoints for LXC containers (keep last *N*). Includes a *"snapshot now"* action that reuses the same
helper. Frequency presets (hourly … weekly) plus a custom cron expression.
- **Datasets** — view all ZFS datasets with `used` / `available` / mountpoint.
- **LXC Mounts** — view, **add**, and **remove** bind-mountpoints on LXC
containers via `pct` (auto-picks the next free `mpN` slot).
- **Replication**`zfs send | recv` a dataset to another dataset, choosing
**full** vs **incremental** automatically based on the latest common snapshot.
- **Scrub** — start / stop a scrub and **schedule** periodic scrubs per pool.
## Installation All cron entries this tool manages live in a **single owner file**
(`/etc/cron.d/pve-zfs-tui`), rewritten in full on every change — your other
cron jobs are never touched. Two small helper scripts are installed on demand:
Run on **Proxmox host as root**. | Path | Purpose |
|------|---------|
| `/usr/local/bin/pve-zfs-snapshot.sh` | snapshot + prune (run by snapshot cron) |
| `/usr/local/bin/pve-zfs-replicate.sh` | full/incremental replication (manual or cron) |
| `/etc/pve-zfs-tui/replication.tsv` | configured replication source→target pairs |
### 1. Install dependencies ## Requirements
- **Proxmox VE** host (Debian-based) with ZFS — provides `zfs`, `zpool`, and
(for the LXC menu) `pct`. The non-Proxmox menus work on any ZFS-on-Linux box;
the **LXC Mounts** menu is hidden when `pct` is absent.
- `root` — the script re-execs itself with `sudo` automatically.
- `whiptail` (installed automatically if missing).
## Usage
```bash ```bash
apt update chmod +x zfs-snapshot.sh
apt install -y python3-pip ./zfs-snapshot.sh
pip3 install textual --break-system-packages
``` ```
### 2. Copy the script The main menu lets you drill into each area; every destructive action
(delete, rollback, schedule removal, mount removal) asks for confirmation,
and every command shows its output and exit code in a scrollable box.
```bash ### Scheduling an auto-snapshot
# Copy pve_tui.py to the Proxmox host, then:
chmod +x /root/pve_tui.py 1. **Schedules → Add / update a schedule**.
2. Pick the dataset, choose a frequency (or enter a custom cron expression),
and set **keep last N**.
3. The schedule is written to `/etc/cron.d/pve-zfs-tui`, e.g.:
```cron
# Snapshot schedules (auto-prune keeps last N)
0 3 * * * root /usr/local/bin/pve-zfs-snapshot.sh RAID1_1TB/data 7
``` ```
### 3. Run On each run it creates `DATASET@auto-YYYYMMDD-HHMMSS` and destroys the
oldest `auto-*` snapshots beyond the keep limit. Manual snapshots (any name
not starting with `auto-`) are **never** auto-pruned.
```bash ## Safety notes
python3 /root/pve_tui.py
```
### Optional: make it a command - **Rollback** destroys all newer snapshots and changes since the chosen
snapshot. The TUI warns before confirming.
- **LXC mount changes** may require the container to be stopped (Proxmox warns).
- Removing a **replication target** only forgets the pair — it does *not*
destroy the target dataset.
- The cron file is owned entirely by this tool; edit schedules via the menus.
```bash ## License
cp /root/pve_tui.py /usr/local/bin/pve-tui
chmod +x /usr/local/bin/pve-tui
```
Then just run `pve-tui` from anywhere. MIT
## Keybindings
| Key | Action |
|-----|--------|
| `q` | Quit |
| `r` | Refresh current view |
| `n` | New snapshot |
| `d` | Delete selected snapshot |
| `s` | Add schedule |
| `Tab` / arrow keys | Navigate tabs |
| `↑` / `↓` | Move selection in table |
## How Scheduling Works
When you create a schedule, the tool:
1. Installs a helper script at `/usr/local/bin/pve-tui-snapshot.sh`
2. Adds a cron entry in root's crontab between markers:
```
# === PVE-TUI AUTO SNAPSHOT BEGIN ===
0 3 * * * /usr/local/bin/pve-tui-snapshot.sh RAID1_1TB 7
# === PVE-TUI AUTO SNAPSHOT END ===
```
3. On each run, creates `DATASET@auto-YYYYMMDD-HHMMSS` and prunes
oldest `auto-*` snapshots beyond the "keep last N" limit.
Manual snapshots (any name not starting with `auto-`) are **never** auto-pruned.
## Safety Notes
- **Rollback** destroys all newer snapshots. Always double-check before confirming.
- **LXC mount changes** may require the container to be stopped (Proxmox will warn).
- Crontab is only modified inside the marker block — your other cron entries are untouched.
- The script uses `zfs`, `pct`, and `crontab` — no third-party ZFS tools required.
## Troubleshooting
- **"No datasets found"** → check `zfs list` works as root
- **Cron not running** → check `systemctl status cron` and `/var/log/syslog`
- **Textual rendering issues** → use a modern terminal (Alacritty, iTerm2, Windows Terminal)

View File

@ -1 +0,0 @@
claude --resume 67e13bd7-317d-4f76-a3cd-524f2eab26d8

1346
tui.py

File diff suppressed because it is too large Load Diff

695
zfs-snapshot.sh Executable file
View File

@ -0,0 +1,695 @@
#!/usr/bin/env bash
#
# zfs-snapshot.sh — Interactive (menuconfig-style) ZFS storage manager for Proxmox VE
#
# A whiptail-based TUI to manage ZFS snapshots, scheduled auto-snapshots,
# datasets, LXC container mountpoints, dataset replication, and pool scrubs
# on a Proxmox VE host.
#
# * Snapshots — list / create / delete / rollback ZFS snapshots
# * Schedules — cron-driven auto-snapshots with auto-pruning (keep last N)
# * Datasets — view all ZFS datasets with usage info
# * LXC Mounts — view / add / remove bind-mountpoints on LXC containers (pct)
# * Replication — zfs send | recv to another dataset (full or incremental)
# * Scrub — start / stop / schedule pool scrubs
#
# All cron entries managed by this tool live in a single owner file
# (/etc/cron.d/pve-zfs-tui); your other cron jobs are never touched.
#
# Usage: ./zfs-snapshot.sh (re-execs itself with sudo automatically)
#
set -euo pipefail
APP_TITLE="Proxmox ZFS Manager"
# ---------------------------------------------------------------------------
# Paths owned by this tool
# ---------------------------------------------------------------------------
CRON_FILE="/etc/cron.d/pve-zfs-tui"
SNAPSHOT_SCRIPT="/usr/local/bin/pve-zfs-snapshot.sh"
REPLICATION_SCRIPT="/usr/local/bin/pve-zfs-replicate.sh"
REPLICATION_CONFIG="/etc/pve-zfs-tui/replication.tsv"
# Single-owner cron header. Entries below are rewritten in full on every change.
CRON_HEADER='# Auto-generated by zfs-snapshot.sh — do not edit manually.
# Manage entries via the Schedules / Scrub menus.
SHELL=/bin/bash
PATH=/usr/sbin:/usr/bin:/sbin:/bin
MAILTO=""'
# ---------------------------------------------------------------------------
# Privilege handling: re-exec under sudo so zfs/zpool/pct run as root.
# ---------------------------------------------------------------------------
if [[ ${EUID} -ne 0 ]]; then
echo "Root privileges are required. Re-running with sudo..."
exec sudo "$0" "$@"
fi
# ---------------------------------------------------------------------------
# Ensure whiptail is available (part of the 'whiptail' package on Debian).
# ---------------------------------------------------------------------------
if ! command -v whiptail >/dev/null 2>&1; then
echo "Installing whiptail (required for the TUI)..."
apt-get update -qq || true
apt-get install -y whiptail
fi
# ---------------------------------------------------------------------------
# Generic helpers
# ---------------------------------------------------------------------------
msg() { whiptail --title "$APP_TITLE" --msgbox "$1" "${2:-12}" "${3:-72}"; }
yesno(){ whiptail --title "$APP_TITLE" --yesno "$1" "${2:-12}" "${3:-72}"; }
# Show arbitrary text in a scrollable box.
show() {
local title="$1" body="$2"
whiptail --title "$title" --scrolltext --msgbox "${body:-(no output)}" 24 90
}
# Run a command, capturing output, and show the result in a scrollable box.
run_and_show() {
local title="$1"; shift
local out rc
set +e
out="$("$@" 2>&1)"; rc=$?
set -e
whiptail --title "$title" --scrolltext --msgbox \
"$(printf 'Command: %s\nExit code: %s\n\n%s' "$*" "$rc" "${out:-(no output)}")" \
22 90
return $rc
}
# Free-text input box. Echoes the entered value, or empty on cancel.
ask() {
local prompt="$1" default="${2:-}"
whiptail --title "$APP_TITLE" --inputbox "$prompt" 11 78 "$default" 3>&1 1>&2 2>&3
}
# Pick one item from a newline-separated list (passed on stdin). The visible
# tag IS the value. Echoes the chosen value, or empty on cancel / empty list.
# pick "Prompt text" < <(some_command_producing_lines)
pick() {
local prompt="$1"
local -a items=()
local line
while IFS= read -r line; do
[[ -z "$line" ]] && continue
items+=("$line" "")
done
if [[ ${#items[@]} -eq 0 ]]; then
msg "Nothing to choose from.\n\n$prompt" 10 70
return 1
fi
whiptail --title "$APP_TITLE" --notags --menu "$prompt" 22 86 12 \
"${items[@]}" 3>&1 1>&2 2>&3
}
# Timestamp used for snapshot names.
ts() { date +%Y%m%d-%H%M%S; }
# ---------------------------------------------------------------------------
# ZFS primitives
# ---------------------------------------------------------------------------
list_pools() { zpool list -H -o name 2>/dev/null; }
list_datasets() { zfs list -H -o name,used,avail,mountpoint 2>/dev/null; }
list_dataset_names() { zfs list -H -o name 2>/dev/null; }
# All snapshots, one per line: name<TAB>used<TAB>refer<TAB>creation
list_snapshots() {
zfs list -t snapshot -H -o name,used,refer,creation 2>/dev/null
}
# Short snapshot names (without the dataset@ prefix) for one exact dataset,
# oldest first.
dataset_snapshots() {
local ds="$1"
zfs list -H -t snapshot -o name -s creation "$ds" 2>/dev/null \
| awk -F'@' -v d="$ds" '$1==d {print $2}'
}
latest_snapshot() { dataset_snapshots "$1" | tail -n1; }
dataset_exists() { zfs list -H "$1" >/dev/null 2>&1; }
# Latest snapshot short-name present on BOTH source and target.
latest_common_snapshot() {
local src="$1" tgt="$2" s
local tgt_snaps; tgt_snaps="$(dataset_snapshots "$tgt")"
while IFS= read -r s; do
[[ -z "$s" ]] && continue
if grep -qxF "$s" <<<"$tgt_snaps"; then echo "$s"; fi
done < <(dataset_snapshots "$src") | tail -n1
}
# ---------------------------------------------------------------------------
# Snapshots menu
# ---------------------------------------------------------------------------
view_snapshots() {
local body
body="$(list_snapshots | awk -F'\t' \
'BEGIN{printf "%-48s %8s %8s %s\n","SNAPSHOT","USED","REFER","CREATED"}
{printf "%-48s %8s %8s %s\n",$1,$2,$3,$4}')"
[[ -z "$body" ]] && body="(no snapshots found)"
show "ZFS Snapshots" "$body"
}
create_snapshot() {
local ds name
ds="$(pick "Select a dataset to snapshot:" < <(list_dataset_names))" || return 0
[[ -z "$ds" ]] && return 0
name="$(ask "Snapshot name for $ds:" "manual-$(ts)")" || return 0
[[ -z "$name" ]] && { msg "No name entered." 8 50; return 0; }
run_and_show "zfs snapshot $ds@$name" zfs snapshot "$ds@$name"
}
delete_snapshot() {
local snap
snap="$(pick "Select a snapshot to DESTROY:" < <(list_snapshots | cut -f1))" || return 0
[[ -z "$snap" ]] && return 0
yesno "Destroy snapshot:\n\n $snap\n\nThis cannot be undone." || return 0
run_and_show "zfs destroy $snap" zfs destroy "$snap"
}
rollback_snapshot() {
local snap
snap="$(pick "Select a snapshot to ROLL BACK to:" < <(list_snapshots | cut -f1))" || return 0
[[ -z "$snap" ]] && return 0
yesno "ROLL BACK to:\n\n $snap\n\nThis DESTROYS all newer snapshots and ALL changes\nmade since then. Continue?" 13 72 || return 0
run_and_show "zfs rollback -r $snap" zfs rollback -r "$snap"
}
menu_snapshots() {
local choice
while true; do
choice=$(whiptail --title "$APP_TITLE — Snapshots" --notags \
--menu "Manage ZFS snapshots:" 18 72 7 \
view "1) View all snapshots" \
create "2) Create a snapshot" \
delete "3) Delete a snapshot" \
rollback "4) Roll back to a snapshot" \
back "5) Back" \
3>&1 1>&2 2>&3) || break
case "$choice" in
view) view_snapshots ;;
create) create_snapshot ;;
delete) delete_snapshot ;;
rollback) rollback_snapshot ;;
back) break ;;
esac
done
}
# ---------------------------------------------------------------------------
# Cron-backed schedules (snapshots + scrubs share one owner file)
# ---------------------------------------------------------------------------
# Emit existing snapshot schedules as: dataset<TAB>cron<TAB>keep
parse_snapshot_schedules() {
[[ -f "$CRON_FILE" ]] || return 0
awk -v s="$SNAPSHOT_SCRIPT" '
/^[[:space:]]*#/ {next} /^[[:space:]]*$/ {next} $1 ~ /=/ {next}
{ idx=0; for (i=1;i<=NF;i++) if ($i==s) {idx=i; break}
if (idx) printf "%s\t%s %s %s %s %s\t%s\n", $(idx+1),$1,$2,$3,$4,$5,$(idx+2) }
' "$CRON_FILE"
}
# Emit existing scrub schedules as: pool<TAB>cron
parse_scrub_schedules() {
[[ -f "$CRON_FILE" ]] || return 0
awk '
/^[[:space:]]*#/ {next} /^[[:space:]]*$/ {next} $1 ~ /=/ {next}
{ idx=0; for (i=1;i<=NF;i++) if ($i=="scrub") {idx=i; break}
if (idx && $(idx-1) ~ /zpool/) printf "%s\t%s %s %s %s %s\n", $(idx+1),$1,$2,$3,$4,$5 }
' "$CRON_FILE"
}
# Rewrite the whole cron file from the two TSV blobs ($1 snaps, $2 scrubs).
write_cron_file() {
local snaps="$1" scrubs="$2" ds cron keep pool
mkdir -p "$(dirname "$CRON_FILE")"
{
printf '%s\n\n' "$CRON_HEADER"
if [[ -n "$snaps" ]]; then
echo "# Snapshot schedules (auto-prune keeps last N)"
while IFS=$'\t' read -r ds cron keep; do
[[ -z "$ds" ]] && continue
printf '%s root %s %s %s\n' "$cron" "$SNAPSHOT_SCRIPT" "$ds" "$keep"
done <<<"$snaps"
echo
fi
if [[ -n "$scrubs" ]]; then
echo "# Scrub schedules"
while IFS=$'\t' read -r pool cron; do
[[ -z "$pool" ]] && continue
printf '%s root /usr/sbin/zpool scrub %s\n' "$cron" "$pool"
done <<<"$scrubs"
echo
fi
} >"$CRON_FILE"
chmod 644 "$CRON_FILE"
}
# Install the standalone snapshot+prune helper invoked by cron.
install_snapshot_script() {
cat >"$SNAPSHOT_SCRIPT" <<'EOF'
#!/bin/bash
# Auto-generated by zfs-snapshot.sh. Creates an auto-* snapshot and prunes
# the oldest auto-* snapshots beyond the keep count. Usage: <dataset> [keep]
DATASET="$1"; KEEP="${2:-7}"; PREFIX="auto"
[ -z "$DATASET" ] && { echo "Usage: $0 <dataset> [keep_count]"; exit 1; }
TS=$(date +%Y%m%d-%H%M%S)
/usr/sbin/zfs snapshot "${DATASET}@${PREFIX}-${TS}"
SNAPS=$(/usr/sbin/zfs list -H -t snapshot -o name -s creation "$DATASET" | grep "@${PREFIX}-")
COUNT=$(echo "$SNAPS" | grep -c . )
if [ "$COUNT" -gt "$KEEP" ]; then
DEL=$((COUNT - KEEP))
echo "$SNAPS" | head -n "$DEL" | while read -r S; do
/usr/sbin/zfs destroy "$S"
done
fi
EOF
chmod 755 "$SNAPSHOT_SCRIPT"
}
# Cron-frequency presets shared by snapshot/scrub scheduling. Echoes a chosen
# 5-field cron expression, or empty on cancel.
pick_cron() {
local kind="$1" choice custom
if [[ "$kind" == "scrub" ]]; then
choice=$(whiptail --title "$APP_TITLE" --notags \
--menu "How often should the scrub run?" 16 72 5 \
"0 3 1 * *" "Monthly (1st @ 3am)" \
"0 3 1,15 * *" "Bi-weekly (1st & 15th @ 3am)" \
"0 3 * * 0" "Weekly (Sunday 3am)" \
"custom" "Custom cron expression..." \
3>&1 1>&2 2>&3) || return 1
else
choice=$(whiptail --title "$APP_TITLE" --notags \
--menu "How often should the snapshot run?" 17 72 6 \
"0 * * * *" "Hourly" \
"0 */6 * * *" "Every 6 hours" \
"0 0 * * *" "Daily at midnight" \
"0 3 * * *" "Daily at 3am" \
"0 2 * * 0" "Weekly (Sunday 2am)" \
"custom" "Custom cron expression..." \
3>&1 1>&2 2>&3) || return 1
fi
if [[ "$choice" == "custom" ]]; then
custom="$(ask "Enter a 5-field cron expression (min hour dom mon dow):" "0 3 * * *")" || return 1
[[ -z "$custom" ]] && return 1
echo "$custom"
else
echo "$choice"
fi
}
view_schedules() {
local body
body="$(parse_snapshot_schedules | awk -F'\t' \
'BEGIN{printf "%-40s %-16s %s\n","DATASET","CRON","KEEP"}
{printf "%-40s %-16s %s\n",$1,$2,$3}')"
[[ -z "$body" ]] && body="(no snapshot schedules configured)"
show "Snapshot Schedules" "$body"
}
add_schedule() {
local ds cron keep snaps scrubs
ds="$(pick "Schedule auto-snapshots for which dataset:" < <(list_dataset_names))" || return 0
[[ -z "$ds" ]] && return 0
cron="$(pick_cron snapshot)" || return 0
keep="$(ask "Keep how many auto snapshots for $ds?" "7")" || return 0
[[ "$keep" =~ ^[0-9]+$ ]] || keep=7
install_snapshot_script
snaps="$(parse_snapshot_schedules | grep -vP "^${ds}\t" || true)"
snaps="$(printf '%s\n%s\t%s\t%s' "$snaps" "$ds" "$cron" "$keep")"
scrubs="$(parse_scrub_schedules)"
write_cron_file "$snaps" "$scrubs"
msg "Schedule saved for:\n $ds\n\nCron: $cron Keep: $keep\n\nWritten to $CRON_FILE" 13 72
}
snapshot_now() {
local row ds keep
row="$(pick "Take an auto-snapshot now for which scheduled dataset:" \
< <(parse_snapshot_schedules | cut -f1))" || return 0
[[ -z "$row" ]] && return 0
ds="$row"
keep="$(parse_snapshot_schedules | awk -F'\t' -v d="$ds" '$1==d {print $3; exit}')"
[[ "$keep" =~ ^[0-9]+$ ]] || keep=7
install_snapshot_script
run_and_show "snapshot now: $ds (keep $keep)" "$SNAPSHOT_SCRIPT" "$ds" "$keep"
}
remove_schedule() {
local ds snaps scrubs
ds="$(pick "Remove the snapshot schedule for which dataset:" \
< <(parse_snapshot_schedules | cut -f1))" || return 0
[[ -z "$ds" ]] && return 0
yesno "Remove auto-snapshot schedule for:\n $ds ?" || return 0
snaps="$(parse_snapshot_schedules | grep -vP "^${ds}\t" || true)"
scrubs="$(parse_scrub_schedules)"
write_cron_file "$snaps" "$scrubs"
msg "Removed schedule for $ds." 8 60
}
menu_schedules() {
local choice
while true; do
choice=$(whiptail --title "$APP_TITLE — Schedules" --notags \
--menu "Auto-snapshot schedules (cron):" 18 74 7 \
view "1) View schedules" \
add "2) Add / update a schedule" \
now "3) Snapshot a scheduled dataset NOW" \
remove "4) Remove a schedule" \
back "5) Back" \
3>&1 1>&2 2>&3) || break
case "$choice" in
view) view_schedules ;;
add) add_schedule ;;
now) snapshot_now ;;
remove) remove_schedule ;;
back) break ;;
esac
done
}
# ---------------------------------------------------------------------------
# Datasets menu
# ---------------------------------------------------------------------------
view_datasets() {
local body
body="$(list_datasets | awk -F'\t' \
'BEGIN{printf "%-40s %8s %8s %s\n","NAME","USED","AVAIL","MOUNTPOINT"}
{printf "%-40s %8s %8s %s\n",$1,$2,$3,$4}')"
[[ -z "$body" ]] && body="(no datasets found — is 'zfs list' working as root?)"
show "ZFS Datasets" "$body"
}
# ---------------------------------------------------------------------------
# LXC mountpoints menu (Proxmox pct)
# ---------------------------------------------------------------------------
have_pct() { command -v pct >/dev/null 2>&1; }
# Container IDs, one per line.
list_lxc_ids() { pct list 2>/dev/null | awk 'NR>1 {print $1}'; }
# Mountpoints for one container: slot<TAB>hostpath<TAB>ctpath
lxc_mountpoints() {
local ctid="$1"
pct config "$ctid" 2>/dev/null | awk '
/^mp[0-9]+:/ {
key=$1; sub(":","",key)
line=$0; sub(/^mp[0-9]+:[[:space:]]*/,"",line)
n=split(line,a,","); host=a[1]; target=""
for (i=2;i<=n;i++) if (a[i] ~ /^mp=/) { target=substr(a[i],4) }
printf "%s\t%s\t%s\n", key, host, target
}'
}
view_mounts() {
local body="" ctid line
while IFS= read -r ctid; do
[[ -z "$ctid" ]] && continue
while IFS=$'\t' read -r slot host target; do
[[ -z "$slot" ]] && continue
body+="$(printf 'CT %-6s %-6s %-30s -> %s' "$ctid" "$slot" "$host" "$target")"$'\n'
done < <(lxc_mountpoints "$ctid")
done < <(list_lxc_ids)
[[ -z "$body" ]] && body="(no LXC mountpoints found)"
show "LXC Mountpoints" "$body"
}
add_mount() {
local ctid host target used slot=0
ctid="$(pick "Add a mountpoint to which container:" < <(list_lxc_ids))" || return 0
[[ -z "$ctid" ]] && return 0
host="$(ask "Host path on the Proxmox host (e.g. /RAID1_1TB/MyData):")" || return 0
[[ -z "$host" ]] && return 0
target="$(ask "Mount path inside the container (e.g. /data):")" || return 0
[[ -z "$target" ]] && return 0
used="$(lxc_mountpoints "$ctid" | sed -n 's/^mp\([0-9]\+\).*/\1/p')"
while grep -qx "$slot" <<<"$used"; do slot=$((slot + 1)); done
run_and_show "pct set $ctid -mp$slot" pct set "$ctid" "-mp${slot}" "${host},mp=${target}"
}
remove_mount() {
local ctid slot
ctid="$(pick "Remove a mountpoint from which container:" < <(list_lxc_ids))" || return 0
[[ -z "$ctid" ]] && return 0
slot="$(pick "Which mountpoint slot to remove from CT $ctid:" \
< <(lxc_mountpoints "$ctid" | awk -F'\t' '{print $1" ("$2" -> "$3")"}' | sed 's/ .*//'))" || return 0
[[ -z "$slot" ]] && return 0
yesno "Remove $slot from container $ctid?\n\nThe container may need to be stopped for this to apply." || return 0
run_and_show "pct set $ctid -delete $slot" pct set "$ctid" -delete "$slot"
}
menu_mounts() {
if ! have_pct; then
msg "'pct' not found — LXC mountpoint management needs Proxmox VE.\n\nThis menu is unavailable on this host." 10 72
return 0
fi
local choice
while true; do
choice=$(whiptail --title "$APP_TITLE — LXC Mounts" --notags \
--menu "LXC container bind-mountpoints (pct):" 17 74 6 \
view "1) View mountpoints" \
add "2) Add a mountpoint" \
remove "3) Remove a mountpoint" \
back "4) Back" \
3>&1 1>&2 2>&3) || break
case "$choice" in
view) view_mounts ;;
add) add_mount ;;
remove) remove_mount ;;
back) break ;;
esac
done
}
# ---------------------------------------------------------------------------
# Replication menu (zfs send | recv)
# ---------------------------------------------------------------------------
load_replication() { [[ -f "$REPLICATION_CONFIG" ]] && cat "$REPLICATION_CONFIG"; return 0; }
save_replication() {
mkdir -p "$(dirname "$REPLICATION_CONFIG")"
printf '%s' "$1" >"$REPLICATION_CONFIG"
}
# Standalone replication helper for cron / manual use.
install_replication_script() {
cat >"$REPLICATION_SCRIPT" <<'EOF'
#!/bin/bash
# Auto-generated by zfs-snapshot.sh. Incrementally replicates a ZFS dataset.
# Usage: pve-zfs-replicate.sh <source-dataset> <target-dataset>
set -e
SOURCE="$1"; TARGET="$2"
[ -z "$SOURCE" ] || [ -z "$TARGET" ] && { echo "Usage: $0 <source> <target>"; exit 1; }
LATEST=$(/usr/sbin/zfs list -H -t snapshot -o name -s creation "$SOURCE" | tail -n1 | awk -F@ '{print $2}')
[ -z "$LATEST" ] && { echo "No snapshots on $SOURCE"; exit 1; }
if /usr/sbin/zfs list -H "$TARGET" >/dev/null 2>&1; then
COMMON=""
for s in $(/usr/sbin/zfs list -H -t snapshot -o name -s creation "$SOURCE" | awk -F@ '{print $2}' | tac); do
if /usr/sbin/zfs list -H -t snapshot "${TARGET}@${s}" >/dev/null 2>&1; then COMMON="$s"; break; fi
done
[ -z "$COMMON" ] && { echo "No common snapshot between $SOURCE and $TARGET; aborting." >&2; exit 2; }
if [ "$COMMON" = "$LATEST" ]; then echo "Up-to-date: $SOURCE@$COMMON"; exit 0; fi
/usr/sbin/zfs send -R -I "${SOURCE}@${COMMON}" "${SOURCE}@${LATEST}" | /usr/sbin/zfs receive -F "$TARGET"
echo "Incremental ${COMMON} -> ${LATEST}"
else
/usr/sbin/zfs send -R "${SOURCE}@${LATEST}" | /usr/sbin/zfs receive "$TARGET"
echo "Full -> ${LATEST}"
fi
EOF
chmod 755 "$REPLICATION_SCRIPT"
}
view_replication() {
local body="" src tgt common latest status
while IFS=$'\t' read -r src tgt; do
[[ -z "$src" ]] && continue
latest="$(latest_snapshot "$src")"; latest="${latest:--}"
common="$(latest_common_snapshot "$src" "$tgt")"; common="${common:--}"
if ! dataset_exists "$tgt"; then status="target missing (full send needed)"
elif [[ "$common" == "$latest" && "$common" != "-" ]]; then status="up-to-date"
elif [[ "$common" == "-" ]]; then status="no common snap (mismatch)"
else status="incremental pending"; fi
body+="$(printf '%-30s -> %-30s [%s]' "$src" "$tgt" "$status")"$'\n'
done < <(load_replication)
[[ -z "$body" ]] && body="(no replication targets configured)"
show "Replication Targets" "$body"
}
add_replication() {
local src tgt cfg
src="$(pick "Replicate which source dataset:" < <(list_dataset_names))" || return 0
[[ -z "$src" ]] && return 0
tgt="$(ask "Target dataset (must be on an imported pool):" "backup/${src}")" || return 0
[[ -z "$tgt" ]] && return 0
cfg="$(load_replication)"
if grep -qxF "$(printf '%s\t%s' "$src" "$tgt")" <<<"$cfg"; then
msg "That target is already configured." 8 60; return 0
fi
cfg="$(printf '%s%s\t%s\n' "${cfg:+$cfg$'\n'}" "$src" "$tgt")"
save_replication "$cfg"
install_replication_script
msg "Added replication target:\n $src -> $tgt" 9 72
}
run_replication() {
local pair src tgt
pair="$(pick "Replicate which target now:" \
< <(load_replication | awk -F'\t' '{print $1" -> "$2}'))" || return 0
[[ -z "$pair" ]] && return 0
src="${pair%% -> *}"; tgt="${pair##* -> }"
install_replication_script
msg "Replicating $src -> $tgt ...\n\nThis may take a while; press OK to start." 9 72
run_and_show "replicate $src -> $tgt" "$REPLICATION_SCRIPT" "$src" "$tgt"
}
remove_replication() {
local pair src tgt cfg
pair="$(pick "Remove which replication target:" \
< <(load_replication | awk -F'\t' '{print $1" -> "$2}'))" || return 0
[[ -z "$pair" ]] && return 0
src="${pair%% -> *}"; tgt="${pair##* -> }"
yesno "Remove replication target?\n $src -> $tgt\n\n(The target dataset itself is NOT destroyed.)" || return 0
cfg="$(load_replication | grep -vxF "$(printf '%s\t%s' "$src" "$tgt")" || true)"
save_replication "$cfg"
msg "Removed: $src -> $tgt" 8 60
}
menu_replication() {
local choice
while true; do
choice=$(whiptail --title "$APP_TITLE — Replication" --notags \
--menu "Replicate datasets via zfs send | recv:" 18 74 7 \
view "1) View targets & status" \
add "2) Add a target" \
run "3) Replicate a target NOW" \
remove "4) Remove a target" \
back "5) Back" \
3>&1 1>&2 2>&3) || break
case "$choice" in
view) view_replication ;;
add) add_replication ;;
run) run_replication ;;
remove) remove_replication ;;
back) break ;;
esac
done
}
# ---------------------------------------------------------------------------
# Scrub menu
# ---------------------------------------------------------------------------
view_scrub() {
local body="" pool sched line
while IFS= read -r pool; do
[[ -z "$pool" ]] && continue
sched="$(parse_scrub_schedules | awk -F'\t' -v p="$pool" '$1==p {print $2; exit}')"
body+="$(printf '===== %s (schedule: %s) =====' "$pool" "${sched:-none}")"$'\n'
body+="$(zpool status "$pool" 2>&1 | grep -E '^[[:space:]]*(state|scan|errors):' )"$'\n\n'
done < <(list_pools)
[[ -z "$body" ]] && body="(no pools found)"
show "Pool Scrub Status" "$body"
}
scrub_start() {
local pool
pool="$(pick "Start a scrub on which pool:" < <(list_pools))" || return 0
[[ -z "$pool" ]] && return 0
run_and_show "zpool scrub $pool" zpool scrub "$pool"
}
scrub_stop() {
local pool
pool="$(pick "Stop the running scrub on which pool:" < <(list_pools))" || return 0
[[ -z "$pool" ]] && return 0
run_and_show "zpool scrub -s $pool" zpool scrub -s "$pool"
}
scrub_schedule() {
local pool cron snaps scrubs
pool="$(pick "Schedule scrubs for which pool:" < <(list_pools))" || return 0
[[ -z "$pool" ]] && return 0
cron="$(pick_cron scrub)" || return 0
snaps="$(parse_snapshot_schedules)"
scrubs="$(parse_scrub_schedules | grep -vP "^${pool}\t" || true)"
scrubs="$(printf '%s\n%s\t%s' "$scrubs" "$pool" "$cron")"
write_cron_file "$snaps" "$scrubs"
msg "Scrub scheduled for $pool.\n\nCron: $cron\nWritten to $CRON_FILE" 11 72
}
scrub_unschedule() {
local pool snaps scrubs
pool="$(pick "Remove the scrub schedule for which pool:" \
< <(parse_scrub_schedules | cut -f1))" || return 0
[[ -z "$pool" ]] && return 0
yesno "Remove scrub schedule for pool $pool?" || return 0
snaps="$(parse_snapshot_schedules)"
scrubs="$(parse_scrub_schedules | grep -vP "^${pool}\t" || true)"
write_cron_file "$snaps" "$scrubs"
msg "Removed scrub schedule for $pool." 8 60
}
menu_scrub() {
local choice
while true; do
choice=$(whiptail --title "$APP_TITLE — Scrub" --notags \
--menu "Pool scrub management:" 18 72 8 \
view "1) View scrub status" \
start "2) Start a scrub" \
stop "3) Stop a running scrub" \
schedule "4) Schedule periodic scrubs" \
unschedule "5) Remove a scrub schedule" \
back "6) Back" \
3>&1 1>&2 2>&3) || break
case "$choice" in
view) view_scrub ;;
start) scrub_start ;;
stop) scrub_stop ;;
schedule) scrub_schedule ;;
unschedule) scrub_unschedule ;;
back) break ;;
esac
done
}
# ---------------------------------------------------------------------------
# Main menu loop
# ---------------------------------------------------------------------------
main_menu() {
local choice
while true; do
choice=$(whiptail --title "$APP_TITLE" --notags \
--menu "Select an area (host: $(hostname))" 20 74 9 \
snapshots "1) Snapshots — list / create / delete / rollback" \
schedules "2) Schedules — cron auto-snapshots (keep last N)" \
datasets "3) Datasets — view ZFS datasets & usage" \
mounts "4) LXC Mounts — manage container bind-mounts" \
replication "5) Replication — zfs send | recv to another dataset" \
scrub "6) Scrub — start / stop / schedule pool scrubs" \
quit "7) Quit" \
3>&1 1>&2 2>&3) || break
case "$choice" in
snapshots) menu_snapshots ;;
schedules) menu_schedules ;;
datasets) view_datasets ;;
mounts) menu_mounts ;;
replication) menu_replication ;;
scrub) menu_scrub ;;
quit) break ;;
esac
done
clear
echo "Done."
}
main_menu