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
|
||||
|
||||
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
|
||||
|
||||
- **Target-based backups** - Define named profiles with sets of directories to back up
|
||||
- **Include/exclude filters** - Rsync include or exclude patterns per target (comma-separated)
|
||||
- **Source-based backups** - Define named profiles with sets of directories to back up
|
||||
- **Include/exclude filters** - Rsync include or exclude patterns per source (comma-separated)
|
||||
- **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
|
||||
- **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/)
|
||||
- **Web dashboard** - Access the full TUI from any browser with HTTP Basic Auth
|
||||
- **CLI interface** - Scriptable commands for automation and cron
|
||||
|
||||
@@ -7,7 +7,7 @@ BACKUP_MODE="full"
|
||||
# Default bandwidth limit in KB/s (0 = unlimited)
|
||||
BWLIMIT=0
|
||||
|
||||
# Default retention: number of snapshots to keep per target
|
||||
# Default retention: number of snapshots to keep per source
|
||||
RETENTION_COUNT=30
|
||||
|
||||
# Logging
|
||||
@@ -33,7 +33,7 @@ SSH_RETRIES=3
|
||||
# Extra rsync options (careful: validated for safe characters)
|
||||
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.
|
||||
DISK_USAGE_THRESHOLD=95
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# gniza — Remote Configuration
|
||||
# gniza — Destination Configuration
|
||||
# Copy to remotes.d/<name>.conf
|
||||
|
||||
# Remote type: ssh, local, s3, gdrive
|
||||
# Destination type: ssh, local, s3, gdrive
|
||||
REMOTE_TYPE="ssh"
|
||||
|
||||
# SSH settings
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# gniza — Target Configuration
|
||||
# gniza — Source Configuration
|
||||
# Copy to targets.d/<name>.conf
|
||||
|
||||
TARGET_NAME="example"
|
||||
@@ -10,7 +10,7 @@ TARGET_RETENTION=""
|
||||
TARGET_PRE_HOOK=""
|
||||
TARGET_POST_HOOK=""
|
||||
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
|
||||
# SSH source
|
||||
#TARGET_SOURCE_HOST=""
|
||||
|
||||
@@ -356,13 +356,13 @@ backup_all_targets() {
|
||||
echo "============================================"
|
||||
echo "Timestamp: $(timestamp)"
|
||||
echo "Duration: $(human_duration $duration)"
|
||||
echo "Remotes: $(echo "$remotes" | tr '\n' ' ')"
|
||||
echo "Destinations: $(echo "$remotes" | tr '\n' ' ')"
|
||||
echo "Total: $total"
|
||||
echo "Succeeded: ${C_GREEN}${succeeded}${C_RESET}"
|
||||
if (( failed > 0 )); then
|
||||
echo "Failed: ${C_RED}${failed}${C_RESET}"
|
||||
echo ""
|
||||
echo "Failed targets:"
|
||||
echo "Failed sources:"
|
||||
echo "$failed_targets"
|
||||
else
|
||||
echo "Failed: 0"
|
||||
|
||||
@@ -165,7 +165,7 @@ send_backup_report() {
|
||||
|
||||
if [[ -n "$failed_targets" ]]; then
|
||||
body+=""$'\n'
|
||||
body+="Failed targets:"$'\n'
|
||||
body+="Failed sources:"$'\n'
|
||||
body+="$failed_targets"$'\n'
|
||||
fi
|
||||
|
||||
|
||||
110
tui/docs.py
110
tui/docs.py
@@ -2,105 +2,105 @@ SCREEN_DOCS = {
|
||||
"backup-screen": (
|
||||
"[bold]Backup Screen[/bold]\n"
|
||||
"\n"
|
||||
"Run backups of configured targets to remote destinations.\n"
|
||||
"Run backups of configured sources to backup destinations.\n"
|
||||
"\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"
|
||||
" [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"
|
||||
"[bold]Buttons:[/bold]\n"
|
||||
" [bold]Run Backup[/bold] - Back up the selected target to the selected remote.\n"
|
||||
" [bold]Backup All[/bold] - Back up all enabled targets to all their configured remotes.\n"
|
||||
" [bold]Run Backup[/bold] - Back up the selected source to the selected destination.\n"
|
||||
" [bold]Backup All[/bold] - Back up all enabled sources to all their configured destinations.\n"
|
||||
"\n"
|
||||
"[bold]Tips:[/bold]\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."
|
||||
),
|
||||
"restore-screen": (
|
||||
"[bold]Restore Screen[/bold]\n"
|
||||
"\n"
|
||||
"Restore files and databases from a remote snapshot.\n"
|
||||
"Restore files and databases from a destination snapshot.\n"
|
||||
"\n"
|
||||
"[bold]Fields:[/bold]\n"
|
||||
" [bold]Target[/bold] - The backup target to restore.\n"
|
||||
" [bold]Remote[/bold] - The remote 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]Source[/bold] - The backup source to restore.\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 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 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"
|
||||
"[bold]Warning:[/bold]\n"
|
||||
" In-place restore will overwrite existing files. Consider restoring to a custom directory first to verify the contents."
|
||||
),
|
||||
"targets-screen": (
|
||||
"[bold]Targets Screen[/bold]\n"
|
||||
"[bold]Sources Screen[/bold]\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"
|
||||
"[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]Enabled[/bold] - Whether the target is active.\n"
|
||||
" [bold]Enabled[/bold] - Whether the source is active.\n"
|
||||
"\n"
|
||||
"[bold]Buttons:[/bold]\n"
|
||||
" [bold]Add[/bold] - Create a new target.\n"
|
||||
" [bold]Edit[/bold] - Modify the selected target.\n"
|
||||
" [bold]Delete[/bold] - Remove the selected target.\n"
|
||||
" [bold]Add[/bold] - Create a new source.\n"
|
||||
" [bold]Edit[/bold] - Modify the selected source.\n"
|
||||
" [bold]Delete[/bold] - Remove the selected source.\n"
|
||||
"\n"
|
||||
"[bold]Tips:[/bold]\n"
|
||||
" - You need at least one target and one remote before you can run backups.\n"
|
||||
" - Disable a target to skip it during 'Backup All' without deleting it."
|
||||
" - You need at least one source and one destination before you can run backups.\n"
|
||||
" - Disable a source to skip it during 'Backup All' without deleting it."
|
||||
),
|
||||
"target-edit": (
|
||||
"[bold]Target Editor[/bold]\n"
|
||||
"[bold]Source Editor[/bold]\n"
|
||||
"\n"
|
||||
"Add or edit a backup target configuration.\n"
|
||||
"Add or edit a backup source configuration.\n"
|
||||
"\n"
|
||||
"[bold]Fields:[/bold]\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]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]Pre/Post hooks[/bold] - Shell commands to run before/after the backup.\n"
|
||||
"\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]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]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"
|
||||
"[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."
|
||||
),
|
||||
"remotes-screen": (
|
||||
"[bold]Remotes Screen[/bold]\n"
|
||||
"[bold]Destinations Screen[/bold]\n"
|
||||
"\n"
|
||||
"List and manage remote backup destinations.\n"
|
||||
"List and manage backup destinations.\n"
|
||||
"\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]Host/Path[/bold] - Connection details.\n"
|
||||
" [bold]Disk[/bold] - Available space (loaded in background).\n"
|
||||
"\n"
|
||||
"[bold]Buttons:[/bold]\n"
|
||||
" [bold]Add[/bold] - Create a new remote.\n"
|
||||
" [bold]Edit[/bold] - Modify the selected remote.\n"
|
||||
" [bold]Test[/bold] - Verify connectivity to the remote.\n"
|
||||
" [bold]Delete[/bold] - Remove the selected remote.\n"
|
||||
" [bold]Add[/bold] - Create a new destination.\n"
|
||||
" [bold]Edit[/bold] - Modify the selected destination.\n"
|
||||
" [bold]Test[/bold] - Verify connectivity to the destination.\n"
|
||||
" [bold]Delete[/bold] - Remove the selected destination.\n"
|
||||
"\n"
|
||||
"[bold]Tips:[/bold]\n"
|
||||
" - Always test a new remote before running backups.\n"
|
||||
" - The Disk column shows used/total space and may take a moment to load for SSH remotes."
|
||||
" - 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 destinations."
|
||||
),
|
||||
"remote-edit": (
|
||||
"[bold]Remote Editor[/bold]\n"
|
||||
"[bold]Destination Editor[/bold]\n"
|
||||
"\n"
|
||||
"Add or edit a remote backup destination.\n"
|
||||
"Add or edit a backup destination.\n"
|
||||
"\n"
|
||||
"[bold]Types:[/bold]\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"
|
||||
"\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]Retention count[/bold] - Max snapshots to keep.\n"
|
||||
"\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."
|
||||
),
|
||||
"snapshots-screen": (
|
||||
"[bold]Snapshots Browser[/bold]\n"
|
||||
"\n"
|
||||
"Browse snapshots stored on remote destinations.\n"
|
||||
"Browse snapshots stored on backup destinations.\n"
|
||||
"\n"
|
||||
"[bold]Fields:[/bold]\n"
|
||||
" [bold]Target[/bold] - The backup target.\n"
|
||||
" [bold]Remote[/bold] - The remote holding snapshots.\n"
|
||||
" [bold]Source[/bold] - The backup source.\n"
|
||||
" [bold]Destination[/bold] - The destination holding snapshots.\n"
|
||||
"\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"
|
||||
"\n"
|
||||
"[bold]Tips:[/bold]\n"
|
||||
" - Each snapshot is a timestamped directory on the remote containing the backed-up files.\n"
|
||||
" - Loading may take a moment for SSH remotes.\n"
|
||||
" - Each snapshot is a timestamped directory on the destination containing the backed-up files.\n"
|
||||
" - Loading may take a moment for SSH destinations.\n"
|
||||
" - Use the Restore screen to actually restore files from a snapshot."
|
||||
),
|
||||
"retention-screen": (
|
||||
@@ -144,16 +144,16 @@ SCREEN_DOCS = {
|
||||
"Remove old snapshots based on retention count.\n"
|
||||
"\n"
|
||||
"[bold]Fields:[/bold]\n"
|
||||
" [bold]Target[/bold] - Run cleanup for a specific target.\n"
|
||||
" [bold]Default retention count[/bold] - Number of snapshots to keep per target/remote pair.\n"
|
||||
" [bold]Source[/bold] - Run cleanup for a specific source.\n"
|
||||
" [bold]Default retention count[/bold] - Number of snapshots to keep per source/destination pair.\n"
|
||||
"\n"
|
||||
"[bold]Buttons:[/bold]\n"
|
||||
" [bold]Run Cleanup[/bold] - Clean old snapshots for the selected target.\n"
|
||||
" [bold]Cleanup All[/bold] - Clean old snapshots for all targets.\n"
|
||||
" [bold]Run Cleanup[/bold] - Clean old snapshots for the selected source.\n"
|
||||
" [bold]Cleanup All[/bold] - Clean old snapshots for all sources.\n"
|
||||
" [bold]Save[/bold] - Save the default retention count.\n"
|
||||
"\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"
|
||||
"[bold]Warning:[/bold]\n"
|
||||
" Deleted snapshots cannot be recovered."
|
||||
@@ -169,7 +169,7 @@ SCREEN_DOCS = {
|
||||
" [bold]Type[/bold] - Frequency (hourly/daily/weekly/monthly/custom).\n"
|
||||
" [bold]Time[/bold] - When the backup runs.\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"
|
||||
"[bold]Buttons:[/bold]\n"
|
||||
" [bold]Toggle Active[/bold] - Enable/disable a schedule.\n"
|
||||
@@ -193,8 +193,8 @@ SCREEN_DOCS = {
|
||||
"\n"
|
||||
"[bold]Fields:[/bold]\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]Remotes[/bold] - Which remotes to use. Leave empty to use all remotes.\n"
|
||||
" [bold]Sources[/bold] - Which sources to back up. Leave empty to back up all sources.\n"
|
||||
" [bold]Destinations[/bold] - Which destinations to use. Leave empty to use all destinations.\n"
|
||||
"\n"
|
||||
"[bold]Tips:[/bold]\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]Retention Count[/bold] - Default number of snapshots to keep.\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"
|
||||
"[bold]Email Notifications:[/bold]\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"
|
||||
"\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": (
|
||||
"[bold]Running Tasks[/bold]\n"
|
||||
|
||||
@@ -225,7 +225,7 @@ HelpModal {
|
||||
width: 90%;
|
||||
max-width: 70;
|
||||
height: 80%;
|
||||
max-height: 30;
|
||||
max-height: 35;
|
||||
padding: 1;
|
||||
background: $panel;
|
||||
border: thick $accent;
|
||||
@@ -237,7 +237,22 @@ HelpModal {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ class BackupScreen(Screen):
|
||||
with Vertical(id="backup-screen"):
|
||||
yield Static("Backup", id="screen-title")
|
||||
if not targets:
|
||||
yield Static("No targets configured. Add a target first.")
|
||||
yield Static("No sources configured. Add a source first.")
|
||||
else:
|
||||
yield Static("Source:")
|
||||
yield Select(
|
||||
@@ -71,21 +71,21 @@ class BackupScreen(Screen):
|
||||
elif event.button.id == "btn-backup":
|
||||
target_sel = self.query_one("#backup-target", Select)
|
||||
if not isinstance(target_sel.value, str):
|
||||
self.notify("Please select a target", severity="error")
|
||||
self.notify("Please select a source", severity="error")
|
||||
return
|
||||
target = str(target_sel.value)
|
||||
remote_sel = self.query_one("#backup-remote", Select)
|
||||
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:
|
||||
msg += f"\nRemote: {remote}"
|
||||
msg += f"\nDestination: {remote}"
|
||||
self.app.push_screen(
|
||||
ConfirmDialog(msg, "Confirm Backup"),
|
||||
callback=lambda ok: self._do_backup(target, remote) if ok else None,
|
||||
)
|
||||
elif event.button.id == "btn-backup-all":
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
@@ -167,7 +167,7 @@ class RemoteEditScreen(Screen):
|
||||
callback=self._base_path_selected,
|
||||
)
|
||||
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:
|
||||
if path:
|
||||
@@ -183,7 +183,7 @@ class RemoteEditScreen(Screen):
|
||||
self.notify("Invalid name.", severity="error")
|
||||
return
|
||||
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
|
||||
else:
|
||||
name = self._edit_name
|
||||
@@ -213,12 +213,12 @@ class RemoteEditScreen(Screen):
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
conf = CONFIG_DIR / "remotes.d" / f"{name}.conf"
|
||||
write_conf(conf, remote.to_conf())
|
||||
self.notify(f"Remote '{name}' saved.")
|
||||
self.notify(f"Destination '{name}' saved.")
|
||||
self.dismiss(name)
|
||||
|
||||
def action_go_back(self) -> None:
|
||||
|
||||
@@ -69,22 +69,22 @@ class RemotesScreen(Screen):
|
||||
from tui.screens.remote_edit import RemoteEditScreen
|
||||
self.app.push_screen(RemoteEditScreen(name), callback=lambda _: self._refresh_table())
|
||||
else:
|
||||
self.notify("Select a remote first", severity="warning")
|
||||
self.notify("Select a destination first", severity="warning")
|
||||
elif event.button.id == "btn-test":
|
||||
name = self._selected_remote()
|
||||
if name:
|
||||
self._test_remote(name)
|
||||
else:
|
||||
self.notify("Select a remote first", severity="warning")
|
||||
self.notify("Select a destination first", severity="warning")
|
||||
elif event.button.id == "btn-delete":
|
||||
name = self._selected_remote()
|
||||
if name:
|
||||
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,
|
||||
)
|
||||
else:
|
||||
self.notify("Select a remote first", severity="warning")
|
||||
self.notify("Select a destination first", severity="warning")
|
||||
|
||||
@work
|
||||
async def _fetch_disk_info(self) -> None:
|
||||
@@ -101,7 +101,7 @@ class RemotesScreen(Screen):
|
||||
|
||||
@work
|
||||
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)
|
||||
rc, stdout, stderr = await run_cli("remotes", "test", f"--name={name}")
|
||||
if stdout:
|
||||
@@ -118,7 +118,7 @@ class RemotesScreen(Screen):
|
||||
conf = CONFIG_DIR / "remotes.d" / f"{name}.conf"
|
||||
if conf.is_file():
|
||||
conf.unlink()
|
||||
self.notify(f"Remote '{name}' deleted.")
|
||||
self.notify(f"Destination '{name}' deleted.")
|
||||
self._refresh_table()
|
||||
|
||||
def action_go_back(self) -> None:
|
||||
|
||||
@@ -23,14 +23,14 @@ class RestoreScreen(Screen):
|
||||
with Vertical(id="restore-screen"):
|
||||
yield Static("Restore", id="screen-title")
|
||||
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:
|
||||
yield Static("Source:")
|
||||
yield Select([(t, t) for t in targets], id="restore-target", prompt="Select source")
|
||||
yield Static("Destination:")
|
||||
yield Select([(r, r) for r in remotes], id="restore-remote", prompt="Select destination")
|
||||
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:")
|
||||
with RadioSet(id="restore-location"):
|
||||
yield RadioButton("In-place (original)", value=True)
|
||||
@@ -115,10 +115,10 @@ class RestoreScreen(Screen):
|
||||
remote_sel = self.query_one("#restore-remote", Select)
|
||||
snap_sel = self.query_one("#restore-snapshot", Select)
|
||||
if not isinstance(target_sel.value, str):
|
||||
self.notify("Select a target", severity="error")
|
||||
self.notify("Select a source", severity="error")
|
||||
return
|
||||
if not isinstance(remote_sel.value, str):
|
||||
self.notify("Select a remote", severity="error")
|
||||
self.notify("Select a destination", severity="error")
|
||||
return
|
||||
if not isinstance(snap_sel.value, str):
|
||||
self.notify("Select a snapshot", severity="error")
|
||||
@@ -134,7 +134,7 @@ class RestoreScreen(Screen):
|
||||
except Exception:
|
||||
restore_mysql = True
|
||||
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:
|
||||
msg += f"\nDestination: {dest}"
|
||||
else:
|
||||
|
||||
@@ -23,7 +23,7 @@ class RetentionScreen(Screen):
|
||||
with Vertical(id="retention-screen"):
|
||||
yield Static("Retention Cleanup", id="screen-title")
|
||||
if not targets:
|
||||
yield Static("No targets configured.")
|
||||
yield Static("No sources configured.")
|
||||
else:
|
||||
yield Static("Source:")
|
||||
yield Select(
|
||||
@@ -49,7 +49,7 @@ class RetentionScreen(Screen):
|
||||
elif event.button.id == "btn-cleanup":
|
||||
target_sel = self.query_one("#ret-target", Select)
|
||||
if not isinstance(target_sel.value, str):
|
||||
self.notify("Select a target first", severity="error")
|
||||
self.notify("Select a source first", severity="error")
|
||||
return
|
||||
target = str(target_sel.value)
|
||||
self.app.push_screen(
|
||||
@@ -58,7 +58,7 @@ class RetentionScreen(Screen):
|
||||
)
|
||||
elif event.button.id == "btn-cleanup-all":
|
||||
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,
|
||||
)
|
||||
elif event.button.id == "btn-save-count":
|
||||
@@ -82,7 +82,7 @@ class RetentionScreen(Screen):
|
||||
|
||||
@work
|
||||
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)
|
||||
rc = await stream_cli(log_screen.write, "retention", "--all")
|
||||
if rc == 0:
|
||||
|
||||
@@ -34,7 +34,7 @@ class SnapshotsScreen(Screen):
|
||||
with Vertical(id="snapshots-screen"):
|
||||
yield Static("Snapshots Browser", id="screen-title")
|
||||
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:
|
||||
yield Static("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)
|
||||
remote_sel = self.query_one("#snap-remote", Select)
|
||||
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
|
||||
target = str(target_sel.value)
|
||||
remote = str(remote_sel.value)
|
||||
@@ -98,7 +98,7 @@ class SnapshotsScreen(Screen):
|
||||
target_sel = self.query_one("#snap-target", Select)
|
||||
remote_sel = self.query_one("#snap-remote", Select)
|
||||
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
|
||||
snapshot = self._selected_snapshot()
|
||||
if not snapshot:
|
||||
|
||||
@@ -81,7 +81,7 @@ class TargetEditScreen(Screen):
|
||||
yield Input(value=target.include, placeholder="*.conf,docs/", id="te-include")
|
||||
yield Static("Exclude patterns:")
|
||||
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 Static("Retention override:")
|
||||
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
|
||||
self.app.push_screen(TargetEditScreen(name), callback=lambda _: self._refresh_table())
|
||||
else:
|
||||
self.notify("Select a target first", severity="warning")
|
||||
self.notify("Select a source first", severity="warning")
|
||||
elif event.button.id == "btn-delete":
|
||||
name = self._selected_target()
|
||||
if name:
|
||||
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,
|
||||
)
|
||||
else:
|
||||
self.notify("Select a target first", severity="warning")
|
||||
self.notify("Select a source first", severity="warning")
|
||||
|
||||
def _delete_target(self, name: str) -> None:
|
||||
conf = CONFIG_DIR / "targets.d" / f"{name}.conf"
|
||||
if conf.is_file():
|
||||
conf.unlink()
|
||||
self.notify(f"Target '{name}' deleted.")
|
||||
self.notify(f"Source '{name}' deleted.")
|
||||
self._refresh_table()
|
||||
|
||||
def action_go_back(self) -> None:
|
||||
|
||||
@@ -16,20 +16,20 @@ class WizardScreen(Screen):
|
||||
yield Static(
|
||||
"[bold]Welcome to gniza Backup Manager![/bold]\n\n"
|
||||
"This wizard will help you set up your first backup:\n\n"
|
||||
" 1. Configure a backup destination (remote)\n"
|
||||
" 2. Define what to back up (target)\n"
|
||||
" 1. Configure a backup destination\n"
|
||||
" 2. Define what to back up (source)\n"
|
||||
" 3. Optionally run your first backup\n",
|
||||
id="wizard-welcome",
|
||||
markup=True,
|
||||
)
|
||||
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:
|
||||
yield Static("[green]Remote configured.[/green]", markup=True)
|
||||
yield Static("[green]Destination configured.[/green]", markup=True)
|
||||
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:
|
||||
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("Skip Setup", id="wiz-skip")
|
||||
yield Footer()
|
||||
|
||||
@@ -23,6 +23,9 @@ class FolderPicker(ModalScreen[str | None]):
|
||||
def compose(self) -> ComposeResult:
|
||||
with Vertical(id="folder-picker"):
|
||||
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")
|
||||
with Horizontal(id="fp-new-row"):
|
||||
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":
|
||||
path = self._get_selected_path()
|
||||
self.dismiss(str(path) if path else None)
|
||||
elif event.button.id == "fp-go":
|
||||
self._go_to_path()
|
||||
elif event.button.id == "fp-create":
|
||||
self._create_folder()
|
||||
else:
|
||||
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:
|
||||
name = self.query_one("#fp-new-name", Input).value.strip()
|
||||
if not name:
|
||||
|
||||
@@ -2,7 +2,7 @@ import subprocess
|
||||
|
||||
from textual.app import ComposeResult
|
||||
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
|
||||
|
||||
|
||||
@@ -33,6 +33,9 @@ class RemoteFolderPicker(ModalScreen[str | None]):
|
||||
def compose(self) -> ComposeResult:
|
||||
with Vertical(id="folder-picker"):
|
||||
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")
|
||||
with Horizontal(id="fp-buttons"):
|
||||
yield Button("Select", variant="primary", id="fp-select")
|
||||
@@ -107,8 +110,33 @@ class RemoteFolderPicker(ModalScreen[str | None]):
|
||||
self.dismiss(str(node.data))
|
||||
else:
|
||||
self.dismiss(None)
|
||||
elif event.button.id == "fp-go":
|
||||
self._go_to_path()
|
||||
else:
|
||||
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:
|
||||
self.dismiss(None)
|
||||
|
||||
@@ -402,14 +402,14 @@ td.name {
|
||||
<!-- Stats -->
|
||||
<div class="stats">
|
||||
<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-detail">{{ targets|selectattr('enabled', 'equalto', 'yes')|list|length }} enabled</div>
|
||||
</div>
|
||||
<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-detail">storage destinations</div>
|
||||
<div class="stat-detail">backup destinations</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Schedules</div>
|
||||
@@ -435,13 +435,13 @@ td.name {
|
||||
<div class="card-header">
|
||||
<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>
|
||||
Targets
|
||||
Sources
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if targets %}
|
||||
<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>
|
||||
{% for t in targets %}
|
||||
<tr>
|
||||
@@ -464,7 +464,7 @@ td.name {
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="empty">No targets configured</div>
|
||||
<div class="empty">No sources configured</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
@@ -474,7 +474,7 @@ td.name {
|
||||
<div class="card-header">
|
||||
<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>
|
||||
Remotes
|
||||
Destinations
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
@@ -492,7 +492,7 @@ td.name {
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="empty">No remotes configured</div>
|
||||
<div class="empty">No destinations configured</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user