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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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