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:
shuki
2026-03-07 04:31:35 +02:00
parent 9be7226a35
commit 09b7dd184e
20 changed files with 183 additions and 118 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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=""

View File

@@ -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"

View File

@@ -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

View File

@@ -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"

View File

@@ -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;
}

View File

@@ -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,
)

View File

@@ -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:

View File

@@ -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:

View File

@@ -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:

View File

@@ -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:

View File

@@ -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:

View File

@@ -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")

View File

@@ -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:

View File

@@ -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()

View File

@@ -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:

View File

@@ -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)

View File

@@ -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>