diff --git a/bin/gniza b/bin/gniza index 034af9b..f4c4948 100755 --- a/bin/gniza +++ b/bin/gniza @@ -147,8 +147,7 @@ run_cli() { remote=$(_parse_flag "--remote" "${SUBCMD_ARGS[@]+"${SUBCMD_ARGS[@]}"}") || true _has_flag "--all" "${SUBCMD_ARGS[@]+"${SUBCMD_ARGS[@]}"}" && all=true - acquire_lock - trap release_lock EXIT + trap release_all_target_locks EXIT if [[ "$all" == "true" || -z "$target" ]]; then backup_all_targets "$remote" diff --git a/lib/backup.sh b/lib/backup.sh index fd77011..72a75b0 100644 --- a/lib/backup.sh +++ b/lib/backup.sh @@ -10,14 +10,22 @@ backup_target() { local target_name="$1" local remote_name="${2:-}" + # 0. Per-target lock: prevent same target from running twice + acquire_target_lock "$target_name" || { + log_error "Skipping target '$target_name': already running" + return 1 + } + # 1. Load and validate target load_target "$target_name" || { log_error "Failed to load target: $target_name" + release_target_lock "$target_name" return 1 } if [[ "${TARGET_ENABLED:-yes}" != "yes" ]]; then log_info "Target '$target_name' is disabled, skipping" + release_target_lock "$target_name" return 0 fi @@ -48,6 +56,7 @@ backup_target() { # 15. Restore remote globals _restore_remote_globals + release_target_lock "$target_name" return "$rc" } @@ -66,8 +75,11 @@ _backup_target_impl() { ;; local) if [[ ! -d "$REMOTE_BASE" ]]; then - log_error "Remote base directory does not exist: $REMOTE_BASE" - return 1 + log_info "Creating local remote base directory: $REMOTE_BASE" + mkdir -p "$REMOTE_BASE" || { + log_error "Failed to create remote base directory: $REMOTE_BASE" + return 1 + } fi ;; s3|gdrive) diff --git a/lib/locking.sh b/lib/locking.sh index fcc0459..6ab6705 100644 --- a/lib/locking.sh +++ b/lib/locking.sh @@ -1,10 +1,11 @@ #!/usr/bin/env bash -# gniza4linux/lib/locking.sh — flock-based concurrency control +# gniza4linux/lib/locking.sh — flock-based per-target concurrency control [[ -n "${_GNIZA4LINUX_LOCKING_LOADED:-}" ]] && return 0 _GNIZA4LINUX_LOCKING_LOADED=1 declare -g LOCK_FD="" +declare -gA _TARGET_LOCK_FDS=() acquire_lock() { local lock_file="${LOCK_FILE:-/var/run/gniza.lock}" @@ -29,3 +30,44 @@ release_lock() { log_debug "Lock released" fi } + +# Per-target lock: prevents the same target from running twice, +# but allows different targets to run concurrently. +acquire_target_lock() { + local target_name="$1" + local lock_dir + lock_dir=$(dirname "${LOCK_FILE:-/var/run/gniza.lock}") + mkdir -p "$lock_dir" || die "Cannot create lock directory: $lock_dir" + + local lock_file="${lock_dir}/gniza-target-${target_name}.lock" + local fd + exec {fd}>"$lock_file" + + if ! flock -n "$fd"; then + log_error "Target '$target_name' is already running (lock: $lock_file)" + exec {fd}>&- 2>/dev/null + return 1 + fi + + echo $$ >&"$fd" + _TARGET_LOCK_FDS["$target_name"]="$fd" + log_debug "Target lock acquired: $target_name (PID $$)" +} + +release_target_lock() { + local target_name="$1" + local fd="${_TARGET_LOCK_FDS[$target_name]:-}" + if [[ -n "$fd" ]]; then + flock -u "$fd" 2>/dev/null + exec {fd}>&- 2>/dev/null + unset '_TARGET_LOCK_FDS[$target_name]' + log_debug "Target lock released: $target_name" + fi +} + +release_all_target_locks() { + local name + for name in "${!_TARGET_LOCK_FDS[@]}"; do + release_target_lock "$name" + done +} diff --git a/tui/gniza.tcss b/tui/gniza.tcss index 8c3faf4..cde3f38 100644 --- a/tui/gniza.tcss +++ b/tui/gniza.tcss @@ -386,6 +386,22 @@ Switch { margin: 1 0 0 0; } +/* Base path browse row */ +#re-base-row { + height: auto; + margin: 0 0 1 0; +} + +#re-base-row Input { + width: 1fr; +} + +#re-base-row Button { + width: auto; + min-width: 12; + margin: 0 0 0 1; +} + /* SSH key browse row */ #re-key-row { height: auto; diff --git a/tui/screens/backup.py b/tui/screens/backup.py index 0d7f322..b0861a4 100644 --- a/tui/screens/backup.py +++ b/tui/screens/backup.py @@ -22,17 +22,17 @@ class BackupScreen(Screen): if not targets: yield Static("No targets configured. Add a target first.") else: - yield Static("Target:") + yield Static("Source:") yield Select( [(t, t) for t in targets], id="backup-target", - prompt="Select target", + prompt="Select source", ) - yield Static("Remote (optional):") + yield Static("Destination:") yield Select( [("Default (all)", "")] + [(r, r) for r in remotes], id="backup-remote", - prompt="Select remote", + prompt="Select destination", value="", ) with Horizontal(id="backup-buttons"): @@ -42,6 +42,29 @@ class BackupScreen(Screen): yield DocsPanel.for_screen("backup-screen") yield Footer() + def on_screen_resume(self) -> None: + self._refresh_selects() + + def _refresh_selects(self) -> None: + targets = list_conf_dir("targets.d") + remotes = list_conf_dir("remotes.d") + try: + ts = self.query_one("#backup-target", Select) + old_target = ts.value + ts.set_options([(t, t) for t in targets]) + if old_target in targets: + ts.value = old_target + except Exception: + pass + try: + rs = self.query_one("#backup-remote", Select) + old_remote = rs.value + rs.set_options([("Default (all)", "")] + [(r, r) for r in remotes]) + if old_remote == "" or old_remote in remotes: + rs.value = old_remote + except Exception: + pass + def on_button_pressed(self, event: Button.Pressed) -> None: if event.button.id == "btn-back": self.app.pop_screen() diff --git a/tui/screens/logs.py b/tui/screens/logs.py index c3cf1d8..9a0728d 100644 --- a/tui/screens/logs.py +++ b/tui/screens/logs.py @@ -4,6 +4,7 @@ from pathlib import Path from textual.app import ComposeResult from textual.screen import Screen from textual.widgets import Header, Footer, Static, Button, DataTable, RichLog +from textual.widgets._rich_log import Strip from tui.widgets.header import GnizaHeader as Header # noqa: F811 from textual.containers import Vertical, Horizontal @@ -11,6 +12,15 @@ from tui.config import LOG_DIR from tui.widgets import DocsPanel +class _SafeRichLog(RichLog): + """RichLog that guards against negative y in render_line (Textual bug).""" + + def render_line(self, y: int) -> Strip: + if y < 0 or not self.lines: + return Strip.blank(self.size.width) + return super().render_line(y) + + _LOG_NAME_RE = re.compile(r"gniza-(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})(\d{2})\.log") @@ -89,7 +99,7 @@ class LogsScreen(Screen): yield Button("◀ Prev", id="btn-prev-page") yield Static("", id="log-page-info") yield Button("Next ▶", id="btn-next-page") - yield RichLog(id="log-viewer", wrap=True, highlight=True) + yield _SafeRichLog(id="log-viewer", wrap=True, highlight=True) yield DocsPanel.for_screen("logs-screen") yield Footer() diff --git a/tui/screens/main_menu.py b/tui/screens/main_menu.py index fafdd14..d8733eb 100644 --- a/tui/screens/main_menu.py +++ b/tui/screens/main_menu.py @@ -30,15 +30,18 @@ LOGO = """\ """ MENU_ITEMS = [ + ("targets", "Sources"), + ("remotes", "Destinations"), + None, ("backup", "Backup"), ("restore", "Restore"), ("running_tasks", "Running Tasks"), - ("targets", "Targets"), - ("remotes", "Remotes"), + None, ("schedule", "Schedules"), - ("snapshots", "Snapshots Browser"), + ("snapshots", "Snapshots"), ("logs", "Logs"), ("settings", "Settings"), + None, ("quit", "Quit"), ] @@ -54,10 +57,12 @@ class MainMenuScreen(Screen): with Horizontal(id="main-layout"): yield Static(LOGO, id="logo", markup=True) menu_items = [] - for mid, label in MENU_ITEMS: - menu_items.append(Option(label, id=mid)) - if mid == "running_tasks": + for item in MENU_ITEMS: + if item is None: menu_items.append(None) + else: + mid, label = item + menu_items.append(Option(label, id=mid)) yield OptionList(*menu_items, id="menu-list") yield Footer() diff --git a/tui/screens/remote_edit.py b/tui/screens/remote_edit.py index 2eb33bd..5710c35 100644 --- a/tui/screens/remote_edit.py +++ b/tui/screens/remote_edit.py @@ -8,7 +8,8 @@ from textual.containers import Vertical, Horizontal from tui.config import parse_conf, write_conf, CONFIG_DIR from tui.models import Remote -from tui.widgets import FilePicker, DocsPanel +from tui.widgets import FilePicker, DocsPanel, RemoteFolderPicker +from tui.widgets.folder_picker import FolderPicker _NAME_RE = re.compile(r'^[a-zA-Z][a-zA-Z0-9_-]{0,31}$') @@ -26,7 +27,7 @@ class RemoteEditScreen(Screen): def compose(self) -> ComposeResult: yield Header(show_clock=True) - title = "Add Remote" if self._is_new else f"Edit Remote: {self._edit_name}" + title = "Add Destination" if self._is_new else f"Edit Destination: {self._edit_name}" remote = Remote() if not self._is_new: data = parse_conf(CONFIG_DIR / "remotes.d" / f"{self._edit_name}.conf") @@ -66,7 +67,9 @@ class RemoteEditScreen(Screen): yield Input(value=remote.password, placeholder="SSH password", password=True, id="re-password", classes="ssh-field ssh-password-field") # Common fields yield Static("Base path:") - yield Input(value=remote.base, placeholder="/backups", id="re-base") + with Horizontal(id="re-base-row"): + yield Input(value=remote.base, placeholder="/backups", id="re-base") + yield Button("Browse...", id="btn-browse-base") yield Static("Bandwidth limit (KB/s, 0=unlimited):") yield Input(value=remote.bwlimit, placeholder="0", id="re-bwlimit") yield Static("Retention count:") @@ -127,6 +130,8 @@ class RemoteEditScreen(Screen): FilePicker("Select SSH key file", start=str(Path.home() / ".ssh")), callback=self._key_file_selected, ) + elif event.button.id == "btn-browse-base": + self._browse_base_path() elif event.button.id == "btn-save": self._save() @@ -134,6 +139,40 @@ class RemoteEditScreen(Screen): if path: self.query_one("#re-key", Input).value = path + def _browse_base_path(self) -> None: + type_sel = self.query_one("#re-type", Select) + rtype = str(type_sel.value) if isinstance(type_sel.value, str) else "ssh" + current_base = self.query_one("#re-base", Input).value.strip() or "/" + if rtype == "local": + self.app.push_screen( + FolderPicker("Select base path", start=current_base), + callback=self._base_path_selected, + ) + elif rtype == "ssh": + host = self.query_one("#re-host", Input).value.strip() + if not host: + self.notify("Enter a host first", severity="error") + return + port = self.query_one("#re-port", Input).value.strip() or "22" + user = self.query_one("#re-user", Input).value.strip() or "root" + auth_sel = self.query_one("#re-auth", Select) + auth = str(auth_sel.value) if isinstance(auth_sel.value, str) else "key" + key = self.query_one("#re-key", Input).value.strip() if auth == "key" else "" + password = self.query_one("#re-password", Input).value if auth == "password" else "" + self.app.push_screen( + RemoteFolderPicker( + host=host, port=port, user=user, + auth_method=auth, key=key, password=password, + ), + callback=self._base_path_selected, + ) + else: + self.notify("Browse not available for this remote type", severity="warning") + + def _base_path_selected(self, path: str | None) -> None: + if path: + self.query_one("#re-base", Input).value = path + def _save(self) -> None: if self._is_new: name = self.query_one("#re-name", Input).value.strip() diff --git a/tui/screens/remotes.py b/tui/screens/remotes.py index 718ccc2..973adac 100644 --- a/tui/screens/remotes.py +++ b/tui/screens/remotes.py @@ -18,7 +18,7 @@ class RemotesScreen(Screen): yield Header(show_clock=True) with Horizontal(classes="screen-with-docs"): with Vertical(id="remotes-screen"): - yield Static("Remotes", id="screen-title") + yield Static("Destinations", id="screen-title") yield DataTable(id="remotes-table") with Horizontal(id="remotes-buttons"): yield Button("Add", variant="primary", id="btn-add") diff --git a/tui/screens/restore.py b/tui/screens/restore.py index 60242c6..6af33b1 100644 --- a/tui/screens/restore.py +++ b/tui/screens/restore.py @@ -25,10 +25,10 @@ class RestoreScreen(Screen): if not targets or not remotes: yield Static("Both targets and remotes must be configured for restore.") else: - yield Static("Target:") - yield Select([(t, t) for t in targets], id="restore-target", prompt="Select target") - yield Static("Remote:") - yield Select([(r, r) for r in remotes], id="restore-remote", prompt="Select remote") + 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 Static("Restore location:") diff --git a/tui/screens/retention.py b/tui/screens/retention.py index 1b60ae4..5fb5e66 100644 --- a/tui/screens/retention.py +++ b/tui/screens/retention.py @@ -25,11 +25,11 @@ class RetentionScreen(Screen): if not targets: yield Static("No targets configured.") else: - yield Static("Target:") + yield Static("Source:") yield Select( [(t, t) for t in targets], id="ret-target", - prompt="Select target", + prompt="Select source", ) with Horizontal(id="ret-buttons"): yield Button("Run Cleanup", variant="primary", id="btn-cleanup") diff --git a/tui/screens/running_tasks.py b/tui/screens/running_tasks.py index fc236c4..d8359b9 100644 --- a/tui/screens/running_tasks.py +++ b/tui/screens/running_tasks.py @@ -5,10 +5,20 @@ from pathlib import Path from textual.app import ComposeResult from textual.screen import Screen from textual.widgets import Header, Footer, Static, Button, DataTable, RichLog, ProgressBar +from textual.widgets._rich_log import Strip from tui.widgets.header import GnizaHeader as Header # noqa: F811 from textual.containers import Vertical, Horizontal from textual.timer import Timer + +class _SafeRichLog(RichLog): + """RichLog that guards against negative y in render_line (Textual bug).""" + + def render_line(self, y: int) -> Strip: + if y < 0 or not self.lines: + return Strip.blank(self.size.width) + return super().render_line(y) + from tui.jobs import job_manager from tui.widgets import ConfirmDialog, DocsPanel @@ -33,7 +43,7 @@ class RunningTasksScreen(Screen): yield Button("Back", id="btn-rt-back") yield Static("", id="rt-progress-label") yield ProgressBar(id="rt-progress", total=100, show_eta=False) - yield RichLog(id="rt-log-viewer", wrap=True, highlight=True) + yield _SafeRichLog(id="rt-log-viewer", wrap=True, highlight=True) yield DocsPanel.for_screen("running-tasks-screen") yield Footer() diff --git a/tui/screens/schedule.py b/tui/screens/schedule.py index ac88f66..f49f151 100644 --- a/tui/screens/schedule.py +++ b/tui/screens/schedule.py @@ -39,7 +39,7 @@ class ScheduleScreen(Screen): def _refresh_table(self) -> None: table = self.query_one("#sched-table", DataTable) table.clear(columns=True) - table.add_columns("Name", "Active", "Type", "Time", "Last Run", "Next Run", "Targets", "Remotes") + table.add_columns("Name", "Active", "Type", "Time", "Last Run", "Next Run", "Sources", "Destinations") last_run = self._get_last_run() schedules = list_conf_dir("schedules.d") for name in schedules: @@ -159,21 +159,19 @@ class ScheduleScreen(Screen): @work async def _sync_crontab(self) -> None: """Reinstall crontab with only active schedules.""" - # Check if any schedule is active - has_active = False - for name in list_conf_dir("schedules.d"): - data = parse_conf(CONFIG_DIR / "schedules.d" / f"{name}.conf") - if data.get("SCHEDULE_ACTIVE", "yes") == "yes": - has_active = True - break - if has_active: - rc, stdout, stderr = await run_cli("schedule", "install") - else: - rc, stdout, stderr = await run_cli("schedule", "remove") + # Always use install — it only writes active schedules and + # safely strips old entries. Never call remove, which could + # wipe the crontab if called during a transient state. + rc, stdout, stderr = await run_cli("schedule", "install") if rc != 0: - self.notify(f"Crontab sync failed: {stderr or stdout}", severity="error") + # install returns 1 when no valid/active schedules exist; + # in that case, remove old entries cleanly + rc2, _, stderr2 = await run_cli("schedule", "remove") + if rc2 != 0: + self.notify(f"Crontab sync failed: {stderr2 or stderr or stdout}", severity="error") + return # Warn if cron daemon is not running - if has_active and not await self._is_cron_running(): + if not await self._is_cron_running(): self.notify( "Cron daemon is not running — schedules won't execute. " "Start it with: sudo systemctl start cron", diff --git a/tui/screens/schedule_edit.py b/tui/screens/schedule_edit.py index 3b1811f..574190c 100644 --- a/tui/screens/schedule_edit.py +++ b/tui/screens/schedule_edit.py @@ -105,12 +105,12 @@ class ScheduleEditScreen(Screen): placeholder="0 2 * * *", classes="sched-cron-field", ) - yield Static("Targets (empty=all):") + yield Static("Sources (empty=all):") yield SelectionList[str]( *self._build_target_choices(), id="sched-targets", ) - yield Static("Remotes (empty=all):") + yield Static("Destinations (empty=all):") yield SelectionList[str]( *self._build_remote_choices(), id="sched-remotes", diff --git a/tui/screens/snapshots.py b/tui/screens/snapshots.py index 39c005d..5850760 100644 --- a/tui/screens/snapshots.py +++ b/tui/screens/snapshots.py @@ -36,10 +36,10 @@ class SnapshotsScreen(Screen): if not targets or not remotes: yield Static("Targets and remotes must be configured to browse snapshots.") else: - yield Static("Target:") - yield Select([(t, t) for t in targets], id="snap-target", prompt="Select target") - yield Static("Remote:") - yield Select([(r, r) for r in remotes], id="snap-remote", prompt="Select remote") + yield Static("Source:") + yield Select([(t, t) for t in targets], id="snap-target", prompt="Select source") + yield Static("Destination:") + yield Select([(r, r) for r in remotes], id="snap-remote", prompt="Select destination") yield Button("Load Snapshots", id="btn-load", variant="primary") yield DataTable(id="snap-table") with Horizontal(id="snapshots-buttons"): diff --git a/tui/screens/target_edit.py b/tui/screens/target_edit.py index c0fde27..37907f3 100644 --- a/tui/screens/target_edit.py +++ b/tui/screens/target_edit.py @@ -23,7 +23,7 @@ class TargetEditScreen(Screen): def compose(self) -> ComposeResult: yield Header(show_clock=True) - title = "Add Target" if self._is_new else f"Edit Target: {self._edit_name}" + title = "Add Source" if self._is_new else f"Edit Source: {self._edit_name}" target = Target() if not self._is_new: data = parse_conf(CONFIG_DIR / "targets.d" / f"{self._edit_name}.conf") diff --git a/tui/screens/targets.py b/tui/screens/targets.py index ba8ed4b..c540d1f 100644 --- a/tui/screens/targets.py +++ b/tui/screens/targets.py @@ -16,7 +16,7 @@ class TargetsScreen(Screen): yield Header(show_clock=True) with Horizontal(classes="screen-with-docs"): with Vertical(id="targets-screen"): - yield Static("Targets", id="screen-title") + yield Static("Sources", id="screen-title") yield DataTable(id="targets-table") with Horizontal(id="targets-buttons"): yield Button("Add", variant="primary", id="btn-add")