Replace all Target/Remote terminology with Source/Destination and add search to file browsers
- Rename all user-facing strings: Target→Source, Remote→Destination across TUI, docs, web dashboard, bash scripts, config examples, and README - Add go-to-path search input to both FolderPicker and RemoteFolderPicker Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
10
README.md
10
README.md
@@ -1,6 +1,6 @@
|
|||||||
# gniza - Linux Backup Manager
|
# gniza - Linux Backup Manager
|
||||||
|
|
||||||
A generic Linux backup tool with a Python Textual TUI, web GUI, and CLI interface. Define named backup targets (sets of directories), configure remote destinations (SSH, local, S3, Google Drive), and run incremental backups with rsync `--link-dest` deduplication.
|
A generic Linux backup tool with a Python Textual TUI, web GUI, and CLI interface. Define named backup sources (sets of directories), configure backup destinations (SSH, local, S3, Google Drive), and run incremental backups with rsync `--link-dest` deduplication.
|
||||||
|
|
||||||
```
|
```
|
||||||
▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓
|
▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓
|
||||||
@@ -24,12 +24,12 @@ A generic Linux backup tool with a Python Textual TUI, web GUI, and CLI interfac
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Target-based backups** - Define named profiles with sets of directories to back up
|
- **Source-based backups** - Define named profiles with sets of directories to back up
|
||||||
- **Include/exclude filters** - Rsync include or exclude patterns per target (comma-separated)
|
- **Include/exclude filters** - Rsync include or exclude patterns per source (comma-separated)
|
||||||
- **MySQL backup** - Dump all or selected databases alongside directory backups
|
- **MySQL backup** - Dump all or selected databases alongside directory backups
|
||||||
- **Multiple remote types** - SSH, local (USB/NFS), S3, Google Drive
|
- **Multiple destination types** - SSH, local (USB/NFS), S3, Google Drive
|
||||||
- **Incremental snapshots** - rsync `--link-dest` for space-efficient deduplication
|
- **Incremental snapshots** - rsync `--link-dest` for space-efficient deduplication
|
||||||
- **Disk space safety** - Abort backup if remote disk usage exceeds configurable threshold (default 95%)
|
- **Disk space safety** - Abort backup if destination disk usage exceeds configurable threshold (default 95%)
|
||||||
- **Textual TUI** - Beautiful terminal UI powered by [Textual](https://textual.textualize.io/)
|
- **Textual TUI** - Beautiful terminal UI powered by [Textual](https://textual.textualize.io/)
|
||||||
- **Web dashboard** - Access the full TUI from any browser with HTTP Basic Auth
|
- **Web dashboard** - Access the full TUI from any browser with HTTP Basic Auth
|
||||||
- **CLI interface** - Scriptable commands for automation and cron
|
- **CLI interface** - Scriptable commands for automation and cron
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ BACKUP_MODE="full"
|
|||||||
# Default bandwidth limit in KB/s (0 = unlimited)
|
# Default bandwidth limit in KB/s (0 = unlimited)
|
||||||
BWLIMIT=0
|
BWLIMIT=0
|
||||||
|
|
||||||
# Default retention: number of snapshots to keep per target
|
# Default retention: number of snapshots to keep per source
|
||||||
RETENTION_COUNT=30
|
RETENTION_COUNT=30
|
||||||
|
|
||||||
# Logging
|
# Logging
|
||||||
@@ -33,7 +33,7 @@ SSH_RETRIES=3
|
|||||||
# Extra rsync options (careful: validated for safe characters)
|
# Extra rsync options (careful: validated for safe characters)
|
||||||
RSYNC_EXTRA_OPTS=""
|
RSYNC_EXTRA_OPTS=""
|
||||||
|
|
||||||
# Disk usage threshold (%). Backups abort if remote disk usage >= this value.
|
# Disk usage threshold (%). Backups abort if destination disk usage >= this value.
|
||||||
# Set to 0 to disable the check.
|
# Set to 0 to disable the check.
|
||||||
DISK_USAGE_THRESHOLD=95
|
DISK_USAGE_THRESHOLD=95
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# gniza — Remote Configuration
|
# gniza — Destination Configuration
|
||||||
# Copy to remotes.d/<name>.conf
|
# Copy to remotes.d/<name>.conf
|
||||||
|
|
||||||
# Remote type: ssh, local, s3, gdrive
|
# Destination type: ssh, local, s3, gdrive
|
||||||
REMOTE_TYPE="ssh"
|
REMOTE_TYPE="ssh"
|
||||||
|
|
||||||
# SSH settings
|
# SSH settings
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# gniza — Target Configuration
|
# gniza — Source Configuration
|
||||||
# Copy to targets.d/<name>.conf
|
# Copy to targets.d/<name>.conf
|
||||||
|
|
||||||
TARGET_NAME="example"
|
TARGET_NAME="example"
|
||||||
@@ -10,7 +10,7 @@ TARGET_RETENTION=""
|
|||||||
TARGET_PRE_HOOK=""
|
TARGET_PRE_HOOK=""
|
||||||
TARGET_POST_HOOK=""
|
TARGET_POST_HOOK=""
|
||||||
TARGET_ENABLED="yes"
|
TARGET_ENABLED="yes"
|
||||||
# Remote Source (pull files from a remote before backing up)
|
# Source Server (pull files from a source server before backing up)
|
||||||
#TARGET_SOURCE_TYPE="local" # local | ssh | s3 | gdrive
|
#TARGET_SOURCE_TYPE="local" # local | ssh | s3 | gdrive
|
||||||
# SSH source
|
# SSH source
|
||||||
#TARGET_SOURCE_HOST=""
|
#TARGET_SOURCE_HOST=""
|
||||||
|
|||||||
@@ -356,13 +356,13 @@ backup_all_targets() {
|
|||||||
echo "============================================"
|
echo "============================================"
|
||||||
echo "Timestamp: $(timestamp)"
|
echo "Timestamp: $(timestamp)"
|
||||||
echo "Duration: $(human_duration $duration)"
|
echo "Duration: $(human_duration $duration)"
|
||||||
echo "Remotes: $(echo "$remotes" | tr '\n' ' ')"
|
echo "Destinations: $(echo "$remotes" | tr '\n' ' ')"
|
||||||
echo "Total: $total"
|
echo "Total: $total"
|
||||||
echo "Succeeded: ${C_GREEN}${succeeded}${C_RESET}"
|
echo "Succeeded: ${C_GREEN}${succeeded}${C_RESET}"
|
||||||
if (( failed > 0 )); then
|
if (( failed > 0 )); then
|
||||||
echo "Failed: ${C_RED}${failed}${C_RESET}"
|
echo "Failed: ${C_RED}${failed}${C_RESET}"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Failed targets:"
|
echo "Failed sources:"
|
||||||
echo "$failed_targets"
|
echo "$failed_targets"
|
||||||
else
|
else
|
||||||
echo "Failed: 0"
|
echo "Failed: 0"
|
||||||
|
|||||||
@@ -165,7 +165,7 @@ send_backup_report() {
|
|||||||
|
|
||||||
if [[ -n "$failed_targets" ]]; then
|
if [[ -n "$failed_targets" ]]; then
|
||||||
body+=""$'\n'
|
body+=""$'\n'
|
||||||
body+="Failed targets:"$'\n'
|
body+="Failed sources:"$'\n'
|
||||||
body+="$failed_targets"$'\n'
|
body+="$failed_targets"$'\n'
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
110
tui/docs.py
110
tui/docs.py
@@ -2,105 +2,105 @@ SCREEN_DOCS = {
|
|||||||
"backup-screen": (
|
"backup-screen": (
|
||||||
"[bold]Backup Screen[/bold]\n"
|
"[bold]Backup Screen[/bold]\n"
|
||||||
"\n"
|
"\n"
|
||||||
"Run backups of configured targets to remote destinations.\n"
|
"Run backups of configured sources to backup destinations.\n"
|
||||||
"\n"
|
"\n"
|
||||||
"[bold]Fields:[/bold]\n"
|
"[bold]Fields:[/bold]\n"
|
||||||
" [bold]Target[/bold] - The backup target to run. Each target defines which folders and databases to back up.\n"
|
" [bold]Source[/bold] - The backup source to run. Each source defines which folders and databases to back up.\n"
|
||||||
"\n"
|
"\n"
|
||||||
" [bold]Remote[/bold] - Where to send the backup. Choose a specific remote or 'Default (all)' to back up to every configured remote.\n"
|
" [bold]Destination[/bold] - Where to send the backup. Choose a specific destination or 'Default (all)' to back up to every configured destination.\n"
|
||||||
"\n"
|
"\n"
|
||||||
"[bold]Buttons:[/bold]\n"
|
"[bold]Buttons:[/bold]\n"
|
||||||
" [bold]Run Backup[/bold] - Back up the selected target to the selected remote.\n"
|
" [bold]Run Backup[/bold] - Back up the selected source to the selected destination.\n"
|
||||||
" [bold]Backup All[/bold] - Back up all enabled targets to all their configured remotes.\n"
|
" [bold]Backup All[/bold] - Back up all enabled sources to all their configured destinations.\n"
|
||||||
"\n"
|
"\n"
|
||||||
"[bold]Tips:[/bold]\n"
|
"[bold]Tips:[/bold]\n"
|
||||||
" - Backups run in the background. You can monitor progress on the Running Tasks screen.\n"
|
" - Backups run in the background. You can monitor progress on the Running Tasks screen.\n"
|
||||||
" - Each backup creates a timestamped snapshot on the remote, so you can restore any point in time.\n"
|
" - Each backup creates a timestamped snapshot on the destination, so you can restore any point in time.\n"
|
||||||
" - Use retention cleanup to remove old snapshots."
|
" - Use retention cleanup to remove old snapshots."
|
||||||
),
|
),
|
||||||
"restore-screen": (
|
"restore-screen": (
|
||||||
"[bold]Restore Screen[/bold]\n"
|
"[bold]Restore Screen[/bold]\n"
|
||||||
"\n"
|
"\n"
|
||||||
"Restore files and databases from a remote snapshot.\n"
|
"Restore files and databases from a destination snapshot.\n"
|
||||||
"\n"
|
"\n"
|
||||||
"[bold]Fields:[/bold]\n"
|
"[bold]Fields:[/bold]\n"
|
||||||
" [bold]Target[/bold] - The backup target to restore.\n"
|
" [bold]Source[/bold] - The backup source to restore.\n"
|
||||||
" [bold]Remote[/bold] - The remote that holds the snapshot.\n"
|
" [bold]Destination[/bold] - The destination that holds the snapshot.\n"
|
||||||
" [bold]Snapshot[/bold] - The specific point-in-time snapshot to restore from. Loaded after selecting target and remote.\n"
|
" [bold]Snapshot[/bold] - The specific point-in-time snapshot to restore from. Loaded after selecting source and destination.\n"
|
||||||
" [bold]Restore location[/bold] - Choose 'In-place' to overwrite original files, or 'Custom directory' to restore to a different path.\n"
|
" [bold]Restore location[/bold] - Choose 'In-place' to overwrite original files, or 'Custom directory' to restore to a different path.\n"
|
||||||
" [bold]Restore MySQL[/bold] - Toggle whether to restore MySQL databases (only shown if the target has MySQL backup enabled).\n"
|
" [bold]Restore MySQL[/bold] - Toggle whether to restore MySQL databases (only shown if the source has MySQL backup enabled).\n"
|
||||||
"\n"
|
"\n"
|
||||||
"[bold]Warning:[/bold]\n"
|
"[bold]Warning:[/bold]\n"
|
||||||
" In-place restore will overwrite existing files. Consider restoring to a custom directory first to verify the contents."
|
" In-place restore will overwrite existing files. Consider restoring to a custom directory first to verify the contents."
|
||||||
),
|
),
|
||||||
"targets-screen": (
|
"targets-screen": (
|
||||||
"[bold]Targets Screen[/bold]\n"
|
"[bold]Sources Screen[/bold]\n"
|
||||||
"\n"
|
"\n"
|
||||||
"List and manage backup targets. A target defines what to back up: folders, file patterns, and optionally MySQL databases.\n"
|
"List and manage backup sources. A source defines what to back up: folders, file patterns, and optionally MySQL databases.\n"
|
||||||
"\n"
|
"\n"
|
||||||
"[bold]Table columns:[/bold]\n"
|
"[bold]Table columns:[/bold]\n"
|
||||||
" [bold]Name[/bold] - Unique target identifier.\n"
|
" [bold]Name[/bold] - Unique source identifier.\n"
|
||||||
" [bold]Folders[/bold] - Comma-separated paths to back up.\n"
|
" [bold]Folders[/bold] - Comma-separated paths to back up.\n"
|
||||||
" [bold]Enabled[/bold] - Whether the target is active.\n"
|
" [bold]Enabled[/bold] - Whether the source is active.\n"
|
||||||
"\n"
|
"\n"
|
||||||
"[bold]Buttons:[/bold]\n"
|
"[bold]Buttons:[/bold]\n"
|
||||||
" [bold]Add[/bold] - Create a new target.\n"
|
" [bold]Add[/bold] - Create a new source.\n"
|
||||||
" [bold]Edit[/bold] - Modify the selected target.\n"
|
" [bold]Edit[/bold] - Modify the selected source.\n"
|
||||||
" [bold]Delete[/bold] - Remove the selected target.\n"
|
" [bold]Delete[/bold] - Remove the selected source.\n"
|
||||||
"\n"
|
"\n"
|
||||||
"[bold]Tips:[/bold]\n"
|
"[bold]Tips:[/bold]\n"
|
||||||
" - You need at least one target and one remote before you can run backups.\n"
|
" - You need at least one source and one destination before you can run backups.\n"
|
||||||
" - Disable a target to skip it during 'Backup All' without deleting it."
|
" - Disable a source to skip it during 'Backup All' without deleting it."
|
||||||
),
|
),
|
||||||
"target-edit": (
|
"target-edit": (
|
||||||
"[bold]Target Editor[/bold]\n"
|
"[bold]Source Editor[/bold]\n"
|
||||||
"\n"
|
"\n"
|
||||||
"Add or edit a backup target configuration.\n"
|
"Add or edit a backup source configuration.\n"
|
||||||
"\n"
|
"\n"
|
||||||
"[bold]Fields:[/bold]\n"
|
"[bold]Fields:[/bold]\n"
|
||||||
" [bold]Name[/bold] - Unique identifier (letters, digits, dash, underscore; max 32 chars).\n"
|
" [bold]Name[/bold] - Unique identifier (letters, digits, dash, underscore; max 32 chars).\n"
|
||||||
" [bold]Folders[/bold] - Comma-separated paths to back up. Use Browse to pick folders interactively.\n"
|
" [bold]Folders[/bold] - Comma-separated paths to back up. Use Browse to pick folders interactively.\n"
|
||||||
" [bold]Include/Exclude[/bold] - Glob patterns to filter files (e.g. *.conf, *.log).\n"
|
" [bold]Include/Exclude[/bold] - Glob patterns to filter files (e.g. *.conf, *.log).\n"
|
||||||
" [bold]Remote override[/bold] - Force this target to a specific remote instead of the default.\n"
|
" [bold]Destination override[/bold] - Force this source to a specific destination instead of the default.\n"
|
||||||
" [bold]Retention override[/bold] - Custom snapshot count.\n"
|
" [bold]Retention override[/bold] - Custom snapshot count.\n"
|
||||||
" [bold]Pre/Post hooks[/bold] - Shell commands to run before/after the backup.\n"
|
" [bold]Pre/Post hooks[/bold] - Shell commands to run before/after the backup.\n"
|
||||||
"\n"
|
"\n"
|
||||||
"[bold]Source section:[/bold]\n"
|
"[bold]Source section:[/bold]\n"
|
||||||
" Set Source Type to pull files from a remote server instead of backing up local folders.\n"
|
" Set Source Type to pull files from a source server instead of backing up local folders.\n"
|
||||||
" [bold]Local[/bold] - Default. Back up folders on this machine.\n"
|
" [bold]Local[/bold] - Default. Back up folders on this machine.\n"
|
||||||
" [bold]SSH[/bold] - Pull files from a remote server via SSH/rsync before backing up.\n"
|
" [bold]SSH[/bold] - Pull files from a source server via SSH/rsync before backing up.\n"
|
||||||
" [bold]S3[/bold] - Pull files from an S3-compatible bucket.\n"
|
" [bold]S3[/bold] - Pull files from an S3-compatible bucket.\n"
|
||||||
" [bold]Google Drive[/bold] - Pull files from Google Drive via service account.\n"
|
" [bold]Google Drive[/bold] - Pull files from Google Drive via service account.\n"
|
||||||
" When using a remote source, specify remote paths in the Folders field instead of local paths.\n"
|
" When using a source server, specify remote paths in the Folders field instead of local paths.\n"
|
||||||
"\n"
|
"\n"
|
||||||
"[bold]MySQL section:[/bold]\n"
|
"[bold]MySQL section:[/bold]\n"
|
||||||
" Enable MySQL to dump databases alongside files. Choose 'All databases' or select specific ones. Leave user/password empty for socket auth."
|
" Enable MySQL to dump databases alongside files. Choose 'All databases' or select specific ones. Leave user/password empty for socket auth."
|
||||||
),
|
),
|
||||||
"remotes-screen": (
|
"remotes-screen": (
|
||||||
"[bold]Remotes Screen[/bold]\n"
|
"[bold]Destinations Screen[/bold]\n"
|
||||||
"\n"
|
"\n"
|
||||||
"List and manage remote backup destinations.\n"
|
"List and manage backup destinations.\n"
|
||||||
"\n"
|
"\n"
|
||||||
"[bold]Table columns:[/bold]\n"
|
"[bold]Table columns:[/bold]\n"
|
||||||
" [bold]Name[/bold] - Unique remote identifier.\n"
|
" [bold]Name[/bold] - Unique destination identifier.\n"
|
||||||
" [bold]Type[/bold] - Connection type (SSH, Local, S3, Google Drive).\n"
|
" [bold]Type[/bold] - Connection type (SSH, Local, S3, Google Drive).\n"
|
||||||
" [bold]Host/Path[/bold] - Connection details.\n"
|
" [bold]Host/Path[/bold] - Connection details.\n"
|
||||||
" [bold]Disk[/bold] - Available space (loaded in background).\n"
|
" [bold]Disk[/bold] - Available space (loaded in background).\n"
|
||||||
"\n"
|
"\n"
|
||||||
"[bold]Buttons:[/bold]\n"
|
"[bold]Buttons:[/bold]\n"
|
||||||
" [bold]Add[/bold] - Create a new remote.\n"
|
" [bold]Add[/bold] - Create a new destination.\n"
|
||||||
" [bold]Edit[/bold] - Modify the selected remote.\n"
|
" [bold]Edit[/bold] - Modify the selected destination.\n"
|
||||||
" [bold]Test[/bold] - Verify connectivity to the remote.\n"
|
" [bold]Test[/bold] - Verify connectivity to the destination.\n"
|
||||||
" [bold]Delete[/bold] - Remove the selected remote.\n"
|
" [bold]Delete[/bold] - Remove the selected destination.\n"
|
||||||
"\n"
|
"\n"
|
||||||
"[bold]Tips:[/bold]\n"
|
"[bold]Tips:[/bold]\n"
|
||||||
" - Always test a new remote before running backups.\n"
|
" - Always test a new destination before running backups.\n"
|
||||||
" - The Disk column shows used/total space and may take a moment to load for SSH remotes."
|
" - The Disk column shows used/total space and may take a moment to load for SSH destinations."
|
||||||
),
|
),
|
||||||
"remote-edit": (
|
"remote-edit": (
|
||||||
"[bold]Remote Editor[/bold]\n"
|
"[bold]Destination Editor[/bold]\n"
|
||||||
"\n"
|
"\n"
|
||||||
"Add or edit a remote backup destination.\n"
|
"Add or edit a backup destination.\n"
|
||||||
"\n"
|
"\n"
|
||||||
"[bold]Types:[/bold]\n"
|
"[bold]Types:[/bold]\n"
|
||||||
" [bold]SSH[/bold] - Remote server via SSH/rsync.\n"
|
" [bold]SSH[/bold] - Remote server via SSH/rsync.\n"
|
||||||
@@ -112,30 +112,30 @@ SCREEN_DOCS = {
|
|||||||
" Host, port, user, and either SSH key or password authentication.\n"
|
" Host, port, user, and either SSH key or password authentication.\n"
|
||||||
"\n"
|
"\n"
|
||||||
"[bold]Common fields:[/bold]\n"
|
"[bold]Common fields:[/bold]\n"
|
||||||
" [bold]Base path[/bold] - Root directory for backups on the remote.\n"
|
" [bold]Base path[/bold] - Root directory for backups on the destination.\n"
|
||||||
" [bold]Bandwidth limit[/bold] - Throttle transfer speed in KB/s (0 = unlimited).\n"
|
" [bold]Bandwidth limit[/bold] - Throttle transfer speed in KB/s (0 = unlimited).\n"
|
||||||
" [bold]Retention count[/bold] - Max snapshots to keep.\n"
|
" [bold]Retention count[/bold] - Max snapshots to keep.\n"
|
||||||
"\n"
|
"\n"
|
||||||
"[bold]Tips:[/bold]\n"
|
"[bold]Tips:[/bold]\n"
|
||||||
" - For SSH, ensure the remote user has write access to the base path.\n"
|
" - For SSH, ensure the destination user has write access to the base path.\n"
|
||||||
" - Use key-based auth for unattended backups."
|
" - Use key-based auth for unattended backups."
|
||||||
),
|
),
|
||||||
"snapshots-screen": (
|
"snapshots-screen": (
|
||||||
"[bold]Snapshots Browser[/bold]\n"
|
"[bold]Snapshots Browser[/bold]\n"
|
||||||
"\n"
|
"\n"
|
||||||
"Browse snapshots stored on remote destinations.\n"
|
"Browse snapshots stored on backup destinations.\n"
|
||||||
"\n"
|
"\n"
|
||||||
"[bold]Fields:[/bold]\n"
|
"[bold]Fields:[/bold]\n"
|
||||||
" [bold]Target[/bold] - The backup target.\n"
|
" [bold]Source[/bold] - The backup source.\n"
|
||||||
" [bold]Remote[/bold] - The remote holding snapshots.\n"
|
" [bold]Destination[/bold] - The destination holding snapshots.\n"
|
||||||
"\n"
|
"\n"
|
||||||
"[bold]Buttons:[/bold]\n"
|
"[bold]Buttons:[/bold]\n"
|
||||||
" [bold]Load Snapshots[/bold] - Fetch the list of available snapshots from the remote.\n"
|
" [bold]Load Snapshots[/bold] - Fetch the list of available snapshots from the destination.\n"
|
||||||
" [bold]Browse Files[/bold] - View the file tree inside the selected snapshot.\n"
|
" [bold]Browse Files[/bold] - View the file tree inside the selected snapshot.\n"
|
||||||
"\n"
|
"\n"
|
||||||
"[bold]Tips:[/bold]\n"
|
"[bold]Tips:[/bold]\n"
|
||||||
" - Each snapshot is a timestamped directory on the remote containing the backed-up files.\n"
|
" - Each snapshot is a timestamped directory on the destination containing the backed-up files.\n"
|
||||||
" - Loading may take a moment for SSH remotes.\n"
|
" - Loading may take a moment for SSH destinations.\n"
|
||||||
" - Use the Restore screen to actually restore files from a snapshot."
|
" - Use the Restore screen to actually restore files from a snapshot."
|
||||||
),
|
),
|
||||||
"retention-screen": (
|
"retention-screen": (
|
||||||
@@ -144,16 +144,16 @@ SCREEN_DOCS = {
|
|||||||
"Remove old snapshots based on retention count.\n"
|
"Remove old snapshots based on retention count.\n"
|
||||||
"\n"
|
"\n"
|
||||||
"[bold]Fields:[/bold]\n"
|
"[bold]Fields:[/bold]\n"
|
||||||
" [bold]Target[/bold] - Run cleanup for a specific target.\n"
|
" [bold]Source[/bold] - Run cleanup for a specific source.\n"
|
||||||
" [bold]Default retention count[/bold] - Number of snapshots to keep per target/remote pair.\n"
|
" [bold]Default retention count[/bold] - Number of snapshots to keep per source/destination pair.\n"
|
||||||
"\n"
|
"\n"
|
||||||
"[bold]Buttons:[/bold]\n"
|
"[bold]Buttons:[/bold]\n"
|
||||||
" [bold]Run Cleanup[/bold] - Clean old snapshots for the selected target.\n"
|
" [bold]Run Cleanup[/bold] - Clean old snapshots for the selected source.\n"
|
||||||
" [bold]Cleanup All[/bold] - Clean old snapshots for all targets.\n"
|
" [bold]Cleanup All[/bold] - Clean old snapshots for all sources.\n"
|
||||||
" [bold]Save[/bold] - Save the default retention count.\n"
|
" [bold]Save[/bold] - Save the default retention count.\n"
|
||||||
"\n"
|
"\n"
|
||||||
"[bold]How it works:[/bold]\n"
|
"[bold]How it works:[/bold]\n"
|
||||||
" Retention keeps the N most recent snapshots and deletes the rest. Per-target or per-remote overrides take priority over the default count.\n"
|
" Retention keeps the N most recent snapshots and deletes the rest. Per-source or per-destination overrides take priority over the default count.\n"
|
||||||
"\n"
|
"\n"
|
||||||
"[bold]Warning:[/bold]\n"
|
"[bold]Warning:[/bold]\n"
|
||||||
" Deleted snapshots cannot be recovered."
|
" Deleted snapshots cannot be recovered."
|
||||||
@@ -169,7 +169,7 @@ SCREEN_DOCS = {
|
|||||||
" [bold]Type[/bold] - Frequency (hourly/daily/weekly/monthly/custom).\n"
|
" [bold]Type[/bold] - Frequency (hourly/daily/weekly/monthly/custom).\n"
|
||||||
" [bold]Time[/bold] - When the backup runs.\n"
|
" [bold]Time[/bold] - When the backup runs.\n"
|
||||||
" [bold]Last/Next Run[/bold] - Timing information.\n"
|
" [bold]Last/Next Run[/bold] - Timing information.\n"
|
||||||
" [bold]Targets/Remotes[/bold] - Scope of the schedule.\n"
|
" [bold]Sources/Destinations[/bold] - Scope of the schedule.\n"
|
||||||
"\n"
|
"\n"
|
||||||
"[bold]Buttons:[/bold]\n"
|
"[bold]Buttons:[/bold]\n"
|
||||||
" [bold]Toggle Active[/bold] - Enable/disable a schedule.\n"
|
" [bold]Toggle Active[/bold] - Enable/disable a schedule.\n"
|
||||||
@@ -193,8 +193,8 @@ SCREEN_DOCS = {
|
|||||||
"\n"
|
"\n"
|
||||||
"[bold]Fields:[/bold]\n"
|
"[bold]Fields:[/bold]\n"
|
||||||
" [bold]Time[/bold] - The time of day to run (HH:MM).\n"
|
" [bold]Time[/bold] - The time of day to run (HH:MM).\n"
|
||||||
" [bold]Targets[/bold] - Which targets to back up. Leave empty to back up all targets.\n"
|
" [bold]Sources[/bold] - Which sources to back up. Leave empty to back up all sources.\n"
|
||||||
" [bold]Remotes[/bold] - Which remotes to use. Leave empty to use all remotes.\n"
|
" [bold]Destinations[/bold] - Which destinations to use. Leave empty to use all destinations.\n"
|
||||||
"\n"
|
"\n"
|
||||||
"[bold]Tips:[/bold]\n"
|
"[bold]Tips:[/bold]\n"
|
||||||
" - Daily schedules let you pick specific days of the week.\n"
|
" - Daily schedules let you pick specific days of the week.\n"
|
||||||
@@ -232,7 +232,7 @@ SCREEN_DOCS = {
|
|||||||
" [bold]Log Retention[/bold] - Days to keep log files.\n"
|
" [bold]Log Retention[/bold] - Days to keep log files.\n"
|
||||||
" [bold]Retention Count[/bold] - Default number of snapshots to keep.\n"
|
" [bold]Retention Count[/bold] - Default number of snapshots to keep.\n"
|
||||||
" [bold]Bandwidth Limit[/bold] - Default transfer speed limit in KB/s.\n"
|
" [bold]Bandwidth Limit[/bold] - Default transfer speed limit in KB/s.\n"
|
||||||
" [bold]Disk Threshold[/bold] - Warn when remote disk usage exceeds this percentage.\n"
|
" [bold]Disk Threshold[/bold] - Warn when destination disk usage exceeds this percentage.\n"
|
||||||
"\n"
|
"\n"
|
||||||
"[bold]Email Notifications:[/bold]\n"
|
"[bold]Email Notifications:[/bold]\n"
|
||||||
" Configure SMTP to receive email alerts on backup success or failure.\n"
|
" Configure SMTP to receive email alerts on backup success or failure.\n"
|
||||||
@@ -245,7 +245,7 @@ SCREEN_DOCS = {
|
|||||||
" Port, host, and API key for the web interface.\n"
|
" Port, host, and API key for the web interface.\n"
|
||||||
"\n"
|
"\n"
|
||||||
"[bold]Tips:[/bold]\n"
|
"[bold]Tips:[/bold]\n"
|
||||||
" - Per-target and per-remote settings override these defaults."
|
" - Per-source and per-destination settings override these defaults."
|
||||||
),
|
),
|
||||||
"running-tasks-screen": (
|
"running-tasks-screen": (
|
||||||
"[bold]Running Tasks[/bold]\n"
|
"[bold]Running Tasks[/bold]\n"
|
||||||
|
|||||||
@@ -225,7 +225,7 @@ HelpModal {
|
|||||||
width: 90%;
|
width: 90%;
|
||||||
max-width: 70;
|
max-width: 70;
|
||||||
height: 80%;
|
height: 80%;
|
||||||
max-height: 30;
|
max-height: 35;
|
||||||
padding: 1;
|
padding: 1;
|
||||||
background: $panel;
|
background: $panel;
|
||||||
border: thick $accent;
|
border: thick $accent;
|
||||||
@@ -237,7 +237,22 @@ HelpModal {
|
|||||||
margin: 0 0 1 0;
|
margin: 0 0 1 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
#fp-tree {
|
#fp-search-row {
|
||||||
|
height: auto;
|
||||||
|
margin: 0 0 1 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#fp-search-row Input {
|
||||||
|
width: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
#fp-search-row Button {
|
||||||
|
width: auto;
|
||||||
|
min-width: 6;
|
||||||
|
margin: 0 0 0 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#fp-tree, #fp-remote-tree {
|
||||||
height: 1fr;
|
height: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ class BackupScreen(Screen):
|
|||||||
with Vertical(id="backup-screen"):
|
with Vertical(id="backup-screen"):
|
||||||
yield Static("Backup", id="screen-title")
|
yield Static("Backup", id="screen-title")
|
||||||
if not targets:
|
if not targets:
|
||||||
yield Static("No targets configured. Add a target first.")
|
yield Static("No sources configured. Add a source first.")
|
||||||
else:
|
else:
|
||||||
yield Static("Source:")
|
yield Static("Source:")
|
||||||
yield Select(
|
yield Select(
|
||||||
@@ -71,21 +71,21 @@ class BackupScreen(Screen):
|
|||||||
elif event.button.id == "btn-backup":
|
elif event.button.id == "btn-backup":
|
||||||
target_sel = self.query_one("#backup-target", Select)
|
target_sel = self.query_one("#backup-target", Select)
|
||||||
if not isinstance(target_sel.value, str):
|
if not isinstance(target_sel.value, str):
|
||||||
self.notify("Please select a target", severity="error")
|
self.notify("Please select a source", severity="error")
|
||||||
return
|
return
|
||||||
target = str(target_sel.value)
|
target = str(target_sel.value)
|
||||||
remote_sel = self.query_one("#backup-remote", Select)
|
remote_sel = self.query_one("#backup-remote", Select)
|
||||||
remote = str(remote_sel.value) if isinstance(remote_sel.value, str) else ""
|
remote = str(remote_sel.value) if isinstance(remote_sel.value, str) else ""
|
||||||
msg = f"Run backup for target '{target}'?"
|
msg = f"Run backup for source '{target}'?"
|
||||||
if remote:
|
if remote:
|
||||||
msg += f"\nRemote: {remote}"
|
msg += f"\nDestination: {remote}"
|
||||||
self.app.push_screen(
|
self.app.push_screen(
|
||||||
ConfirmDialog(msg, "Confirm Backup"),
|
ConfirmDialog(msg, "Confirm Backup"),
|
||||||
callback=lambda ok: self._do_backup(target, remote) if ok else None,
|
callback=lambda ok: self._do_backup(target, remote) if ok else None,
|
||||||
)
|
)
|
||||||
elif event.button.id == "btn-backup-all":
|
elif event.button.id == "btn-backup-all":
|
||||||
self.app.push_screen(
|
self.app.push_screen(
|
||||||
ConfirmDialog("Backup ALL targets now?", "Confirm Backup"),
|
ConfirmDialog("Backup ALL sources now?", "Confirm Backup"),
|
||||||
callback=lambda ok: self._do_backup_all() if ok else None,
|
callback=lambda ok: self._do_backup_all() if ok else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -167,7 +167,7 @@ class RemoteEditScreen(Screen):
|
|||||||
callback=self._base_path_selected,
|
callback=self._base_path_selected,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
self.notify("Browse not available for this remote type", severity="warning")
|
self.notify("Browse not available for this destination type", severity="warning")
|
||||||
|
|
||||||
def _base_path_selected(self, path: str | None) -> None:
|
def _base_path_selected(self, path: str | None) -> None:
|
||||||
if path:
|
if path:
|
||||||
@@ -183,7 +183,7 @@ class RemoteEditScreen(Screen):
|
|||||||
self.notify("Invalid name.", severity="error")
|
self.notify("Invalid name.", severity="error")
|
||||||
return
|
return
|
||||||
if (CONFIG_DIR / "remotes.d" / f"{name}.conf").exists():
|
if (CONFIG_DIR / "remotes.d" / f"{name}.conf").exists():
|
||||||
self.notify(f"Remote '{name}' already exists.", severity="error")
|
self.notify(f"Destination '{name}' already exists.", severity="error")
|
||||||
return
|
return
|
||||||
else:
|
else:
|
||||||
name = self._edit_name
|
name = self._edit_name
|
||||||
@@ -213,12 +213,12 @@ class RemoteEditScreen(Screen):
|
|||||||
)
|
)
|
||||||
|
|
||||||
if rtype == "ssh" and not remote.host:
|
if rtype == "ssh" and not remote.host:
|
||||||
self.notify("Host is required for SSH remotes", severity="error")
|
self.notify("Host is required for SSH destinations", severity="error")
|
||||||
return
|
return
|
||||||
|
|
||||||
conf = CONFIG_DIR / "remotes.d" / f"{name}.conf"
|
conf = CONFIG_DIR / "remotes.d" / f"{name}.conf"
|
||||||
write_conf(conf, remote.to_conf())
|
write_conf(conf, remote.to_conf())
|
||||||
self.notify(f"Remote '{name}' saved.")
|
self.notify(f"Destination '{name}' saved.")
|
||||||
self.dismiss(name)
|
self.dismiss(name)
|
||||||
|
|
||||||
def action_go_back(self) -> None:
|
def action_go_back(self) -> None:
|
||||||
|
|||||||
@@ -69,22 +69,22 @@ class RemotesScreen(Screen):
|
|||||||
from tui.screens.remote_edit import RemoteEditScreen
|
from tui.screens.remote_edit import RemoteEditScreen
|
||||||
self.app.push_screen(RemoteEditScreen(name), callback=lambda _: self._refresh_table())
|
self.app.push_screen(RemoteEditScreen(name), callback=lambda _: self._refresh_table())
|
||||||
else:
|
else:
|
||||||
self.notify("Select a remote first", severity="warning")
|
self.notify("Select a destination first", severity="warning")
|
||||||
elif event.button.id == "btn-test":
|
elif event.button.id == "btn-test":
|
||||||
name = self._selected_remote()
|
name = self._selected_remote()
|
||||||
if name:
|
if name:
|
||||||
self._test_remote(name)
|
self._test_remote(name)
|
||||||
else:
|
else:
|
||||||
self.notify("Select a remote first", severity="warning")
|
self.notify("Select a destination first", severity="warning")
|
||||||
elif event.button.id == "btn-delete":
|
elif event.button.id == "btn-delete":
|
||||||
name = self._selected_remote()
|
name = self._selected_remote()
|
||||||
if name:
|
if name:
|
||||||
self.app.push_screen(
|
self.app.push_screen(
|
||||||
ConfirmDialog(f"Delete remote '{name}'? This cannot be undone.", "Delete Remote"),
|
ConfirmDialog(f"Delete destination '{name}'? This cannot be undone.", "Delete Destination"),
|
||||||
callback=lambda ok: self._delete_remote(name) if ok else None,
|
callback=lambda ok: self._delete_remote(name) if ok else None,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
self.notify("Select a remote first", severity="warning")
|
self.notify("Select a destination first", severity="warning")
|
||||||
|
|
||||||
@work
|
@work
|
||||||
async def _fetch_disk_info(self) -> None:
|
async def _fetch_disk_info(self) -> None:
|
||||||
@@ -101,7 +101,7 @@ class RemotesScreen(Screen):
|
|||||||
|
|
||||||
@work
|
@work
|
||||||
async def _test_remote(self, name: str) -> None:
|
async def _test_remote(self, name: str) -> None:
|
||||||
log_screen = OperationLog(f"Testing Remote: {name}", show_spinner=False)
|
log_screen = OperationLog(f"Testing Destination: {name}", show_spinner=False)
|
||||||
self.app.push_screen(log_screen)
|
self.app.push_screen(log_screen)
|
||||||
rc, stdout, stderr = await run_cli("remotes", "test", f"--name={name}")
|
rc, stdout, stderr = await run_cli("remotes", "test", f"--name={name}")
|
||||||
if stdout:
|
if stdout:
|
||||||
@@ -118,7 +118,7 @@ class RemotesScreen(Screen):
|
|||||||
conf = CONFIG_DIR / "remotes.d" / f"{name}.conf"
|
conf = CONFIG_DIR / "remotes.d" / f"{name}.conf"
|
||||||
if conf.is_file():
|
if conf.is_file():
|
||||||
conf.unlink()
|
conf.unlink()
|
||||||
self.notify(f"Remote '{name}' deleted.")
|
self.notify(f"Destination '{name}' deleted.")
|
||||||
self._refresh_table()
|
self._refresh_table()
|
||||||
|
|
||||||
def action_go_back(self) -> None:
|
def action_go_back(self) -> None:
|
||||||
|
|||||||
@@ -23,14 +23,14 @@ class RestoreScreen(Screen):
|
|||||||
with Vertical(id="restore-screen"):
|
with Vertical(id="restore-screen"):
|
||||||
yield Static("Restore", id="screen-title")
|
yield Static("Restore", id="screen-title")
|
||||||
if not targets or not remotes:
|
if not targets or not remotes:
|
||||||
yield Static("Both targets and remotes must be configured for restore.")
|
yield Static("Both sources and destinations must be configured for restore.")
|
||||||
else:
|
else:
|
||||||
yield Static("Source:")
|
yield Static("Source:")
|
||||||
yield Select([(t, t) for t in targets], id="restore-target", prompt="Select source")
|
yield Select([(t, t) for t in targets], id="restore-target", prompt="Select source")
|
||||||
yield Static("Destination:")
|
yield Static("Destination:")
|
||||||
yield Select([(r, r) for r in remotes], id="restore-remote", prompt="Select destination")
|
yield Select([(r, r) for r in remotes], id="restore-remote", prompt="Select destination")
|
||||||
yield Static("Snapshot:")
|
yield Static("Snapshot:")
|
||||||
yield Select([], id="restore-snapshot", prompt="Select target and remote first")
|
yield Select([], id="restore-snapshot", prompt="Select source and destination first")
|
||||||
yield Static("Restore location:")
|
yield Static("Restore location:")
|
||||||
with RadioSet(id="restore-location"):
|
with RadioSet(id="restore-location"):
|
||||||
yield RadioButton("In-place (original)", value=True)
|
yield RadioButton("In-place (original)", value=True)
|
||||||
@@ -115,10 +115,10 @@ class RestoreScreen(Screen):
|
|||||||
remote_sel = self.query_one("#restore-remote", Select)
|
remote_sel = self.query_one("#restore-remote", Select)
|
||||||
snap_sel = self.query_one("#restore-snapshot", Select)
|
snap_sel = self.query_one("#restore-snapshot", Select)
|
||||||
if not isinstance(target_sel.value, str):
|
if not isinstance(target_sel.value, str):
|
||||||
self.notify("Select a target", severity="error")
|
self.notify("Select a source", severity="error")
|
||||||
return
|
return
|
||||||
if not isinstance(remote_sel.value, str):
|
if not isinstance(remote_sel.value, str):
|
||||||
self.notify("Select a remote", severity="error")
|
self.notify("Select a destination", severity="error")
|
||||||
return
|
return
|
||||||
if not isinstance(snap_sel.value, str):
|
if not isinstance(snap_sel.value, str):
|
||||||
self.notify("Select a snapshot", severity="error")
|
self.notify("Select a snapshot", severity="error")
|
||||||
@@ -134,7 +134,7 @@ class RestoreScreen(Screen):
|
|||||||
except Exception:
|
except Exception:
|
||||||
restore_mysql = True
|
restore_mysql = True
|
||||||
skip_mysql = not restore_mysql
|
skip_mysql = not restore_mysql
|
||||||
msg = f"Restore snapshot?\n\nTarget: {target}\nRemote: {remote}\nSnapshot: {snapshot}"
|
msg = f"Restore snapshot?\n\nSource: {target}\nDestination: {remote}\nSnapshot: {snapshot}"
|
||||||
if dest:
|
if dest:
|
||||||
msg += f"\nDestination: {dest}"
|
msg += f"\nDestination: {dest}"
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ class RetentionScreen(Screen):
|
|||||||
with Vertical(id="retention-screen"):
|
with Vertical(id="retention-screen"):
|
||||||
yield Static("Retention Cleanup", id="screen-title")
|
yield Static("Retention Cleanup", id="screen-title")
|
||||||
if not targets:
|
if not targets:
|
||||||
yield Static("No targets configured.")
|
yield Static("No sources configured.")
|
||||||
else:
|
else:
|
||||||
yield Static("Source:")
|
yield Static("Source:")
|
||||||
yield Select(
|
yield Select(
|
||||||
@@ -49,7 +49,7 @@ class RetentionScreen(Screen):
|
|||||||
elif event.button.id == "btn-cleanup":
|
elif event.button.id == "btn-cleanup":
|
||||||
target_sel = self.query_one("#ret-target", Select)
|
target_sel = self.query_one("#ret-target", Select)
|
||||||
if not isinstance(target_sel.value, str):
|
if not isinstance(target_sel.value, str):
|
||||||
self.notify("Select a target first", severity="error")
|
self.notify("Select a source first", severity="error")
|
||||||
return
|
return
|
||||||
target = str(target_sel.value)
|
target = str(target_sel.value)
|
||||||
self.app.push_screen(
|
self.app.push_screen(
|
||||||
@@ -58,7 +58,7 @@ class RetentionScreen(Screen):
|
|||||||
)
|
)
|
||||||
elif event.button.id == "btn-cleanup-all":
|
elif event.button.id == "btn-cleanup-all":
|
||||||
self.app.push_screen(
|
self.app.push_screen(
|
||||||
ConfirmDialog("Run retention cleanup for ALL targets?", "Confirm"),
|
ConfirmDialog("Run retention cleanup for ALL sources?", "Confirm"),
|
||||||
callback=lambda ok: self._do_cleanup_all() if ok else None,
|
callback=lambda ok: self._do_cleanup_all() if ok else None,
|
||||||
)
|
)
|
||||||
elif event.button.id == "btn-save-count":
|
elif event.button.id == "btn-save-count":
|
||||||
@@ -82,7 +82,7 @@ class RetentionScreen(Screen):
|
|||||||
|
|
||||||
@work
|
@work
|
||||||
async def _do_cleanup_all(self) -> None:
|
async def _do_cleanup_all(self) -> None:
|
||||||
log_screen = OperationLog("Retention: All Targets", show_spinner=False)
|
log_screen = OperationLog("Retention: All Sources", show_spinner=False)
|
||||||
self.app.push_screen(log_screen)
|
self.app.push_screen(log_screen)
|
||||||
rc = await stream_cli(log_screen.write, "retention", "--all")
|
rc = await stream_cli(log_screen.write, "retention", "--all")
|
||||||
if rc == 0:
|
if rc == 0:
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ class SnapshotsScreen(Screen):
|
|||||||
with Vertical(id="snapshots-screen"):
|
with Vertical(id="snapshots-screen"):
|
||||||
yield Static("Snapshots Browser", id="screen-title")
|
yield Static("Snapshots Browser", id="screen-title")
|
||||||
if not targets or not remotes:
|
if not targets or not remotes:
|
||||||
yield Static("Targets and remotes must be configured to browse snapshots.")
|
yield Static("Sources and destinations must be configured to browse snapshots.")
|
||||||
else:
|
else:
|
||||||
yield Static("Source:")
|
yield Static("Source:")
|
||||||
yield Select([(t, t) for t in targets], id="snap-target", prompt="Select source")
|
yield Select([(t, t) for t in targets], id="snap-target", prompt="Select source")
|
||||||
@@ -77,7 +77,7 @@ class SnapshotsScreen(Screen):
|
|||||||
target_sel = self.query_one("#snap-target", Select)
|
target_sel = self.query_one("#snap-target", Select)
|
||||||
remote_sel = self.query_one("#snap-remote", Select)
|
remote_sel = self.query_one("#snap-remote", Select)
|
||||||
if not isinstance(target_sel.value, str) or not isinstance(remote_sel.value, str):
|
if not isinstance(target_sel.value, str) or not isinstance(remote_sel.value, str):
|
||||||
self.notify("Select target and remote first", severity="error")
|
self.notify("Select source and destination first", severity="error")
|
||||||
return
|
return
|
||||||
target = str(target_sel.value)
|
target = str(target_sel.value)
|
||||||
remote = str(remote_sel.value)
|
remote = str(remote_sel.value)
|
||||||
@@ -98,7 +98,7 @@ class SnapshotsScreen(Screen):
|
|||||||
target_sel = self.query_one("#snap-target", Select)
|
target_sel = self.query_one("#snap-target", Select)
|
||||||
remote_sel = self.query_one("#snap-remote", Select)
|
remote_sel = self.query_one("#snap-remote", Select)
|
||||||
if not isinstance(target_sel.value, str) or not isinstance(remote_sel.value, str):
|
if not isinstance(target_sel.value, str) or not isinstance(remote_sel.value, str):
|
||||||
self.notify("Select target and remote first", severity="error")
|
self.notify("Select source and destination first", severity="error")
|
||||||
return
|
return
|
||||||
snapshot = self._selected_snapshot()
|
snapshot = self._selected_snapshot()
|
||||||
if not snapshot:
|
if not snapshot:
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ class TargetEditScreen(Screen):
|
|||||||
yield Input(value=target.include, placeholder="*.conf,docs/", id="te-include")
|
yield Input(value=target.include, placeholder="*.conf,docs/", id="te-include")
|
||||||
yield Static("Exclude patterns:")
|
yield Static("Exclude patterns:")
|
||||||
yield Input(value=target.exclude, placeholder="*.tmp,*.log", id="te-exclude")
|
yield Input(value=target.exclude, placeholder="*.tmp,*.log", id="te-exclude")
|
||||||
yield Static("Remote override:")
|
yield Static("Destination override:")
|
||||||
yield Input(value=target.remote, placeholder="Leave empty for default", id="te-remote")
|
yield Input(value=target.remote, placeholder="Leave empty for default", id="te-remote")
|
||||||
yield Static("Retention override:")
|
yield Static("Retention override:")
|
||||||
yield Input(value=target.retention, placeholder="Leave empty for default", id="te-retention")
|
yield Input(value=target.retention, placeholder="Leave empty for default", id="te-retention")
|
||||||
|
|||||||
@@ -61,22 +61,22 @@ class TargetsScreen(Screen):
|
|||||||
from tui.screens.target_edit import TargetEditScreen
|
from tui.screens.target_edit import TargetEditScreen
|
||||||
self.app.push_screen(TargetEditScreen(name), callback=lambda _: self._refresh_table())
|
self.app.push_screen(TargetEditScreen(name), callback=lambda _: self._refresh_table())
|
||||||
else:
|
else:
|
||||||
self.notify("Select a target first", severity="warning")
|
self.notify("Select a source first", severity="warning")
|
||||||
elif event.button.id == "btn-delete":
|
elif event.button.id == "btn-delete":
|
||||||
name = self._selected_target()
|
name = self._selected_target()
|
||||||
if name:
|
if name:
|
||||||
self.app.push_screen(
|
self.app.push_screen(
|
||||||
ConfirmDialog(f"Delete target '{name}'? This cannot be undone.", "Delete Target"),
|
ConfirmDialog(f"Delete source '{name}'? This cannot be undone.", "Delete Source"),
|
||||||
callback=lambda ok: self._delete_target(name) if ok else None,
|
callback=lambda ok: self._delete_target(name) if ok else None,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
self.notify("Select a target first", severity="warning")
|
self.notify("Select a source first", severity="warning")
|
||||||
|
|
||||||
def _delete_target(self, name: str) -> None:
|
def _delete_target(self, name: str) -> None:
|
||||||
conf = CONFIG_DIR / "targets.d" / f"{name}.conf"
|
conf = CONFIG_DIR / "targets.d" / f"{name}.conf"
|
||||||
if conf.is_file():
|
if conf.is_file():
|
||||||
conf.unlink()
|
conf.unlink()
|
||||||
self.notify(f"Target '{name}' deleted.")
|
self.notify(f"Source '{name}' deleted.")
|
||||||
self._refresh_table()
|
self._refresh_table()
|
||||||
|
|
||||||
def action_go_back(self) -> None:
|
def action_go_back(self) -> None:
|
||||||
|
|||||||
@@ -16,20 +16,20 @@ class WizardScreen(Screen):
|
|||||||
yield Static(
|
yield Static(
|
||||||
"[bold]Welcome to gniza Backup Manager![/bold]\n\n"
|
"[bold]Welcome to gniza Backup Manager![/bold]\n\n"
|
||||||
"This wizard will help you set up your first backup:\n\n"
|
"This wizard will help you set up your first backup:\n\n"
|
||||||
" 1. Configure a backup destination (remote)\n"
|
" 1. Configure a backup destination\n"
|
||||||
" 2. Define what to back up (target)\n"
|
" 2. Define what to back up (source)\n"
|
||||||
" 3. Optionally run your first backup\n",
|
" 3. Optionally run your first backup\n",
|
||||||
id="wizard-welcome",
|
id="wizard-welcome",
|
||||||
markup=True,
|
markup=True,
|
||||||
)
|
)
|
||||||
if not has_remotes():
|
if not has_remotes():
|
||||||
yield Button("Step 1: Add Remote", variant="primary", id="wiz-remote")
|
yield Button("Step 1: Add Destination", variant="primary", id="wiz-remote")
|
||||||
else:
|
else:
|
||||||
yield Static("[green]Remote configured.[/green]", markup=True)
|
yield Static("[green]Destination configured.[/green]", markup=True)
|
||||||
if not has_targets():
|
if not has_targets():
|
||||||
yield Button("Step 2: Add Target", variant="primary", id="wiz-target")
|
yield Button("Step 2: Add Source", variant="primary", id="wiz-target")
|
||||||
else:
|
else:
|
||||||
yield Static("[green]Target configured.[/green]", markup=True)
|
yield Static("[green]Source configured.[/green]", markup=True)
|
||||||
yield Button("Continue to Main Menu", id="wiz-continue")
|
yield Button("Continue to Main Menu", id="wiz-continue")
|
||||||
yield Button("Skip Setup", id="wiz-skip")
|
yield Button("Skip Setup", id="wiz-skip")
|
||||||
yield Footer()
|
yield Footer()
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ class FolderPicker(ModalScreen[str | None]):
|
|||||||
def compose(self) -> ComposeResult:
|
def compose(self) -> ComposeResult:
|
||||||
with Vertical(id="folder-picker"):
|
with Vertical(id="folder-picker"):
|
||||||
yield Static(self._title, id="fp-title")
|
yield Static(self._title, id="fp-title")
|
||||||
|
with Horizontal(id="fp-search-row"):
|
||||||
|
yield Input(placeholder="Go to path (e.g. /var/www)", id="fp-search")
|
||||||
|
yield Button("Go", id="fp-go", variant="primary")
|
||||||
yield _DirOnly(self._start, id="fp-tree")
|
yield _DirOnly(self._start, id="fp-tree")
|
||||||
with Horizontal(id="fp-new-row"):
|
with Horizontal(id="fp-new-row"):
|
||||||
yield Input(placeholder="New folder name", id="fp-new-name")
|
yield Input(placeholder="New folder name", id="fp-new-name")
|
||||||
@@ -42,11 +45,30 @@ class FolderPicker(ModalScreen[str | None]):
|
|||||||
if event.button.id == "fp-select":
|
if event.button.id == "fp-select":
|
||||||
path = self._get_selected_path()
|
path = self._get_selected_path()
|
||||||
self.dismiss(str(path) if path else None)
|
self.dismiss(str(path) if path else None)
|
||||||
|
elif event.button.id == "fp-go":
|
||||||
|
self._go_to_path()
|
||||||
elif event.button.id == "fp-create":
|
elif event.button.id == "fp-create":
|
||||||
self._create_folder()
|
self._create_folder()
|
||||||
else:
|
else:
|
||||||
self.dismiss(None)
|
self.dismiss(None)
|
||||||
|
|
||||||
|
def on_input_submitted(self, event: Input.Submitted) -> None:
|
||||||
|
if event.input.id == "fp-search":
|
||||||
|
self._go_to_path()
|
||||||
|
|
||||||
|
def _go_to_path(self) -> None:
|
||||||
|
raw = self.query_one("#fp-search", Input).value.strip()
|
||||||
|
if not raw:
|
||||||
|
return
|
||||||
|
target = Path(raw).expanduser()
|
||||||
|
if not target.is_dir():
|
||||||
|
self.notify(f"Not a directory: {target}", severity="error")
|
||||||
|
return
|
||||||
|
# Replace the tree with a new root
|
||||||
|
tree = self.query_one("#fp-tree", _DirOnly)
|
||||||
|
tree.path = target
|
||||||
|
tree.reload()
|
||||||
|
|
||||||
def _create_folder(self) -> None:
|
def _create_folder(self) -> None:
|
||||||
name = self.query_one("#fp-new-name", Input).value.strip()
|
name = self.query_one("#fp-new-name", Input).value.strip()
|
||||||
if not name:
|
if not name:
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import subprocess
|
|||||||
|
|
||||||
from textual.app import ComposeResult
|
from textual.app import ComposeResult
|
||||||
from textual.screen import ModalScreen
|
from textual.screen import ModalScreen
|
||||||
from textual.widgets import Tree, Static, Button
|
from textual.widgets import Tree, Static, Button, Input
|
||||||
from textual.containers import Horizontal, Vertical
|
from textual.containers import Horizontal, Vertical
|
||||||
|
|
||||||
|
|
||||||
@@ -33,6 +33,9 @@ class RemoteFolderPicker(ModalScreen[str | None]):
|
|||||||
def compose(self) -> ComposeResult:
|
def compose(self) -> ComposeResult:
|
||||||
with Vertical(id="folder-picker"):
|
with Vertical(id="folder-picker"):
|
||||||
yield Static(f"{self._title} ({self._user}@{self._host})", id="fp-title")
|
yield Static(f"{self._title} ({self._user}@{self._host})", id="fp-title")
|
||||||
|
with Horizontal(id="fp-search-row"):
|
||||||
|
yield Input(placeholder="Go to path (e.g. /var/www)", id="fp-search")
|
||||||
|
yield Button("Go", id="fp-go", variant="primary")
|
||||||
yield Tree("/", id="fp-remote-tree")
|
yield Tree("/", id="fp-remote-tree")
|
||||||
with Horizontal(id="fp-buttons"):
|
with Horizontal(id="fp-buttons"):
|
||||||
yield Button("Select", variant="primary", id="fp-select")
|
yield Button("Select", variant="primary", id="fp-select")
|
||||||
@@ -107,8 +110,33 @@ class RemoteFolderPicker(ModalScreen[str | None]):
|
|||||||
self.dismiss(str(node.data))
|
self.dismiss(str(node.data))
|
||||||
else:
|
else:
|
||||||
self.dismiss(None)
|
self.dismiss(None)
|
||||||
|
elif event.button.id == "fp-go":
|
||||||
|
self._go_to_path()
|
||||||
else:
|
else:
|
||||||
self.dismiss(None)
|
self.dismiss(None)
|
||||||
|
|
||||||
|
def on_input_submitted(self, event: Input.Submitted) -> None:
|
||||||
|
if event.input.id == "fp-search":
|
||||||
|
self._go_to_path()
|
||||||
|
|
||||||
|
def _go_to_path(self) -> None:
|
||||||
|
raw = self.query_one("#fp-search", Input).value.strip()
|
||||||
|
if not raw:
|
||||||
|
return
|
||||||
|
path = raw if raw.startswith("/") else "/" + raw
|
||||||
|
dirs = self._list_dirs(path)
|
||||||
|
tree = self.query_one("#fp-remote-tree", Tree)
|
||||||
|
tree.clear()
|
||||||
|
tree.root.data = path
|
||||||
|
tree.root.set_label(path)
|
||||||
|
if not dirs:
|
||||||
|
self.notify(f"No subdirectories in {path}", severity="warning")
|
||||||
|
return
|
||||||
|
for d in dirs:
|
||||||
|
name = d.rstrip("/").rsplit("/", 1)[-1]
|
||||||
|
child = tree.root.add(name, data=d, allow_expand=True)
|
||||||
|
child.add_leaf("...", data=None)
|
||||||
|
tree.root.expand()
|
||||||
|
|
||||||
def action_cancel(self) -> None:
|
def action_cancel(self) -> None:
|
||||||
self.dismiss(None)
|
self.dismiss(None)
|
||||||
|
|||||||
@@ -402,14 +402,14 @@ td.name {
|
|||||||
<!-- Stats -->
|
<!-- Stats -->
|
||||||
<div class="stats">
|
<div class="stats">
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<div class="stat-label">Targets</div>
|
<div class="stat-label">Sources</div>
|
||||||
<div class="stat-value accent">{{ targets|length }}</div>
|
<div class="stat-value accent">{{ targets|length }}</div>
|
||||||
<div class="stat-detail">{{ targets|selectattr('enabled', 'equalto', 'yes')|list|length }} enabled</div>
|
<div class="stat-detail">{{ targets|selectattr('enabled', 'equalto', 'yes')|list|length }} enabled</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<div class="stat-label">Remotes</div>
|
<div class="stat-label">Destinations</div>
|
||||||
<div class="stat-value info">{{ remotes|length }}</div>
|
<div class="stat-value info">{{ remotes|length }}</div>
|
||||||
<div class="stat-detail">storage destinations</div>
|
<div class="stat-detail">backup destinations</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<div class="stat-label">Schedules</div>
|
<div class="stat-label">Schedules</div>
|
||||||
@@ -435,13 +435,13 @@ td.name {
|
|||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<div class="card-title">
|
<div class="card-title">
|
||||||
<svg class="card-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2"/></svg>
|
<svg class="card-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2"/></svg>
|
||||||
Targets
|
Sources
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
{% if targets %}
|
{% if targets %}
|
||||||
<table>
|
<table>
|
||||||
<thead><tr><th>Name</th><th>Remote</th><th>Status</th><th></th></tr></thead>
|
<thead><tr><th>Name</th><th>Destination</th><th>Status</th><th></th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for t in targets %}
|
{% for t in targets %}
|
||||||
<tr>
|
<tr>
|
||||||
@@ -464,7 +464,7 @@ td.name {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="empty">No targets configured</div>
|
<div class="empty">No sources configured</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -474,7 +474,7 @@ td.name {
|
|||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<div class="card-title">
|
<div class="card-title">
|
||||||
<svg class="card-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/><circle cx="6" cy="6" r="1" fill="currentColor"/><circle cx="6" cy="18" r="1" fill="currentColor"/></svg>
|
<svg class="card-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/><circle cx="6" cy="6" r="1" fill="currentColor"/><circle cx="6" cy="18" r="1" fill="currentColor"/></svg>
|
||||||
Remotes
|
Destinations
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
@@ -492,7 +492,7 @@ td.name {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="empty">No remotes configured</div>
|
<div class="empty">No destinations configured</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user