Rename Targets/Remotes to Sources/Destinations, add Browse to remote editor, fix RichLog crash and crontab sync

- Rename Targets → Sources, Remotes → Destinations across all screens
- Reorganize main menu with logical groupings and separators
- Add Browse button to Base path field in remote editor (local + SSH)
- Fix RichLog IndexError crash when compositor renders with y=-1
- Fix _sync_crontab accidentally wiping crontab via premature remove
- Per-target locking and auto-create local remote base directory

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
shuki
2026-03-07 04:17:04 +02:00
parent fb508d9e0b
commit 9be7226a35
17 changed files with 203 additions and 49 deletions

View File

@@ -147,8 +147,7 @@ run_cli() {
remote=$(_parse_flag "--remote" "${SUBCMD_ARGS[@]+"${SUBCMD_ARGS[@]}"}") || true remote=$(_parse_flag "--remote" "${SUBCMD_ARGS[@]+"${SUBCMD_ARGS[@]}"}") || true
_has_flag "--all" "${SUBCMD_ARGS[@]+"${SUBCMD_ARGS[@]}"}" && all=true _has_flag "--all" "${SUBCMD_ARGS[@]+"${SUBCMD_ARGS[@]}"}" && all=true
acquire_lock trap release_all_target_locks EXIT
trap release_lock EXIT
if [[ "$all" == "true" || -z "$target" ]]; then if [[ "$all" == "true" || -z "$target" ]]; then
backup_all_targets "$remote" backup_all_targets "$remote"

View File

@@ -10,14 +10,22 @@ backup_target() {
local target_name="$1" local target_name="$1"
local remote_name="${2:-}" 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 # 1. Load and validate target
load_target "$target_name" || { load_target "$target_name" || {
log_error "Failed to load target: $target_name" log_error "Failed to load target: $target_name"
release_target_lock "$target_name"
return 1 return 1
} }
if [[ "${TARGET_ENABLED:-yes}" != "yes" ]]; then if [[ "${TARGET_ENABLED:-yes}" != "yes" ]]; then
log_info "Target '$target_name' is disabled, skipping" log_info "Target '$target_name' is disabled, skipping"
release_target_lock "$target_name"
return 0 return 0
fi fi
@@ -48,6 +56,7 @@ backup_target() {
# 15. Restore remote globals # 15. Restore remote globals
_restore_remote_globals _restore_remote_globals
release_target_lock "$target_name"
return "$rc" return "$rc"
} }
@@ -66,8 +75,11 @@ _backup_target_impl() {
;; ;;
local) local)
if [[ ! -d "$REMOTE_BASE" ]]; then if [[ ! -d "$REMOTE_BASE" ]]; then
log_error "Remote base directory does not exist: $REMOTE_BASE" 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 return 1
}
fi fi
;; ;;
s3|gdrive) s3|gdrive)

View File

@@ -1,10 +1,11 @@
#!/usr/bin/env bash #!/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 [[ -n "${_GNIZA4LINUX_LOCKING_LOADED:-}" ]] && return 0
_GNIZA4LINUX_LOCKING_LOADED=1 _GNIZA4LINUX_LOCKING_LOADED=1
declare -g LOCK_FD="" declare -g LOCK_FD=""
declare -gA _TARGET_LOCK_FDS=()
acquire_lock() { acquire_lock() {
local lock_file="${LOCK_FILE:-/var/run/gniza.lock}" local lock_file="${LOCK_FILE:-/var/run/gniza.lock}"
@@ -29,3 +30,44 @@ release_lock() {
log_debug "Lock released" log_debug "Lock released"
fi 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
}

View File

@@ -386,6 +386,22 @@ Switch {
margin: 1 0 0 0; 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 */ /* SSH key browse row */
#re-key-row { #re-key-row {
height: auto; height: auto;

View File

@@ -22,17 +22,17 @@ class BackupScreen(Screen):
if not targets: if not targets:
yield Static("No targets configured. Add a target first.") yield Static("No targets configured. Add a target first.")
else: else:
yield Static("Target:") yield Static("Source:")
yield Select( yield Select(
[(t, t) for t in targets], [(t, t) for t in targets],
id="backup-target", id="backup-target",
prompt="Select target", prompt="Select source",
) )
yield Static("Remote (optional):") yield Static("Destination:")
yield Select( yield Select(
[("Default (all)", "")] + [(r, r) for r in remotes], [("Default (all)", "")] + [(r, r) for r in remotes],
id="backup-remote", id="backup-remote",
prompt="Select remote", prompt="Select destination",
value="", value="",
) )
with Horizontal(id="backup-buttons"): with Horizontal(id="backup-buttons"):
@@ -42,6 +42,29 @@ class BackupScreen(Screen):
yield DocsPanel.for_screen("backup-screen") yield DocsPanel.for_screen("backup-screen")
yield Footer() 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: def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "btn-back": if event.button.id == "btn-back":
self.app.pop_screen() self.app.pop_screen()

View File

@@ -4,6 +4,7 @@ from pathlib import Path
from textual.app import ComposeResult from textual.app import ComposeResult
from textual.screen import Screen from textual.screen import Screen
from textual.widgets import Header, Footer, Static, Button, DataTable, RichLog 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 tui.widgets.header import GnizaHeader as Header # noqa: F811
from textual.containers import Vertical, Horizontal from textual.containers import Vertical, Horizontal
@@ -11,6 +12,15 @@ from tui.config import LOG_DIR
from tui.widgets import DocsPanel 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") _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 Button("◀ Prev", id="btn-prev-page")
yield Static("", id="log-page-info") yield Static("", id="log-page-info")
yield Button("Next ▶", id="btn-next-page") 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 DocsPanel.for_screen("logs-screen")
yield Footer() yield Footer()

View File

@@ -30,15 +30,18 @@ LOGO = """\
""" """
MENU_ITEMS = [ MENU_ITEMS = [
("targets", "Sources"),
("remotes", "Destinations"),
None,
("backup", "Backup"), ("backup", "Backup"),
("restore", "Restore"), ("restore", "Restore"),
("running_tasks", "Running Tasks"), ("running_tasks", "Running Tasks"),
("targets", "Targets"), None,
("remotes", "Remotes"),
("schedule", "Schedules"), ("schedule", "Schedules"),
("snapshots", "Snapshots Browser"), ("snapshots", "Snapshots"),
("logs", "Logs"), ("logs", "Logs"),
("settings", "Settings"), ("settings", "Settings"),
None,
("quit", "Quit"), ("quit", "Quit"),
] ]
@@ -54,10 +57,12 @@ class MainMenuScreen(Screen):
with Horizontal(id="main-layout"): with Horizontal(id="main-layout"):
yield Static(LOGO, id="logo", markup=True) yield Static(LOGO, id="logo", markup=True)
menu_items = [] menu_items = []
for mid, label in MENU_ITEMS: for item in MENU_ITEMS:
menu_items.append(Option(label, id=mid)) if item is None:
if mid == "running_tasks":
menu_items.append(None) menu_items.append(None)
else:
mid, label = item
menu_items.append(Option(label, id=mid))
yield OptionList(*menu_items, id="menu-list") yield OptionList(*menu_items, id="menu-list")
yield Footer() yield Footer()

View File

@@ -8,7 +8,8 @@ from textual.containers import Vertical, Horizontal
from tui.config import parse_conf, write_conf, CONFIG_DIR from tui.config import parse_conf, write_conf, CONFIG_DIR
from tui.models import Remote 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}$') _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: def compose(self) -> ComposeResult:
yield Header(show_clock=True) 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() remote = Remote()
if not self._is_new: if not self._is_new:
data = parse_conf(CONFIG_DIR / "remotes.d" / f"{self._edit_name}.conf") 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") yield Input(value=remote.password, placeholder="SSH password", password=True, id="re-password", classes="ssh-field ssh-password-field")
# Common fields # Common fields
yield Static("Base path:") yield Static("Base path:")
with Horizontal(id="re-base-row"):
yield Input(value=remote.base, placeholder="/backups", id="re-base") 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 Static("Bandwidth limit (KB/s, 0=unlimited):")
yield Input(value=remote.bwlimit, placeholder="0", id="re-bwlimit") yield Input(value=remote.bwlimit, placeholder="0", id="re-bwlimit")
yield Static("Retention count:") yield Static("Retention count:")
@@ -127,6 +130,8 @@ class RemoteEditScreen(Screen):
FilePicker("Select SSH key file", start=str(Path.home() / ".ssh")), FilePicker("Select SSH key file", start=str(Path.home() / ".ssh")),
callback=self._key_file_selected, callback=self._key_file_selected,
) )
elif event.button.id == "btn-browse-base":
self._browse_base_path()
elif event.button.id == "btn-save": elif event.button.id == "btn-save":
self._save() self._save()
@@ -134,6 +139,40 @@ class RemoteEditScreen(Screen):
if path: if path:
self.query_one("#re-key", Input).value = 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: def _save(self) -> None:
if self._is_new: if self._is_new:
name = self.query_one("#re-name", Input).value.strip() name = self.query_one("#re-name", Input).value.strip()

View File

@@ -18,7 +18,7 @@ class RemotesScreen(Screen):
yield Header(show_clock=True) yield Header(show_clock=True)
with Horizontal(classes="screen-with-docs"): with Horizontal(classes="screen-with-docs"):
with Vertical(id="remotes-screen"): with Vertical(id="remotes-screen"):
yield Static("Remotes", id="screen-title") yield Static("Destinations", id="screen-title")
yield DataTable(id="remotes-table") yield DataTable(id="remotes-table")
with Horizontal(id="remotes-buttons"): with Horizontal(id="remotes-buttons"):
yield Button("Add", variant="primary", id="btn-add") yield Button("Add", variant="primary", id="btn-add")

View File

@@ -25,10 +25,10 @@ class RestoreScreen(Screen):
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 targets and remotes must be configured for restore.")
else: else:
yield Static("Target:") yield Static("Source:")
yield Select([(t, t) for t in targets], id="restore-target", prompt="Select target") yield Select([(t, t) for t in targets], id="restore-target", prompt="Select source")
yield Static("Remote:") yield Static("Destination:")
yield Select([(r, r) for r in remotes], id="restore-remote", prompt="Select remote") 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 target and remote first")
yield Static("Restore location:") yield Static("Restore location:")

View File

@@ -25,11 +25,11 @@ class RetentionScreen(Screen):
if not targets: if not targets:
yield Static("No targets configured.") yield Static("No targets configured.")
else: else:
yield Static("Target:") yield Static("Source:")
yield Select( yield Select(
[(t, t) for t in targets], [(t, t) for t in targets],
id="ret-target", id="ret-target",
prompt="Select target", prompt="Select source",
) )
with Horizontal(id="ret-buttons"): with Horizontal(id="ret-buttons"):
yield Button("Run Cleanup", variant="primary", id="btn-cleanup") yield Button("Run Cleanup", variant="primary", id="btn-cleanup")

View File

@@ -5,10 +5,20 @@ from pathlib import Path
from textual.app import ComposeResult from textual.app import ComposeResult
from textual.screen import Screen from textual.screen import Screen
from textual.widgets import Header, Footer, Static, Button, DataTable, RichLog, ProgressBar 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 tui.widgets.header import GnizaHeader as Header # noqa: F811
from textual.containers import Vertical, Horizontal from textual.containers import Vertical, Horizontal
from textual.timer import Timer 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.jobs import job_manager
from tui.widgets import ConfirmDialog, DocsPanel from tui.widgets import ConfirmDialog, DocsPanel
@@ -33,7 +43,7 @@ class RunningTasksScreen(Screen):
yield Button("Back", id="btn-rt-back") yield Button("Back", id="btn-rt-back")
yield Static("", id="rt-progress-label") yield Static("", id="rt-progress-label")
yield ProgressBar(id="rt-progress", total=100, show_eta=False) 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 DocsPanel.for_screen("running-tasks-screen")
yield Footer() yield Footer()

View File

@@ -39,7 +39,7 @@ class ScheduleScreen(Screen):
def _refresh_table(self) -> None: def _refresh_table(self) -> None:
table = self.query_one("#sched-table", DataTable) table = self.query_one("#sched-table", DataTable)
table.clear(columns=True) 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() last_run = self._get_last_run()
schedules = list_conf_dir("schedules.d") schedules = list_conf_dir("schedules.d")
for name in schedules: for name in schedules:
@@ -159,21 +159,19 @@ class ScheduleScreen(Screen):
@work @work
async def _sync_crontab(self) -> None: async def _sync_crontab(self) -> None:
"""Reinstall crontab with only active schedules.""" """Reinstall crontab with only active schedules."""
# Check if any schedule is active # Always use install — it only writes active schedules and
has_active = False # safely strips old entries. Never call remove, which could
for name in list_conf_dir("schedules.d"): # wipe the crontab if called during a transient state.
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") rc, stdout, stderr = await run_cli("schedule", "install")
else:
rc, stdout, stderr = await run_cli("schedule", "remove")
if rc != 0: 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 # 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( self.notify(
"Cron daemon is not running — schedules won't execute. " "Cron daemon is not running — schedules won't execute. "
"Start it with: sudo systemctl start cron", "Start it with: sudo systemctl start cron",

View File

@@ -105,12 +105,12 @@ class ScheduleEditScreen(Screen):
placeholder="0 2 * * *", placeholder="0 2 * * *",
classes="sched-cron-field", classes="sched-cron-field",
) )
yield Static("Targets (empty=all):") yield Static("Sources (empty=all):")
yield SelectionList[str]( yield SelectionList[str](
*self._build_target_choices(), *self._build_target_choices(),
id="sched-targets", id="sched-targets",
) )
yield Static("Remotes (empty=all):") yield Static("Destinations (empty=all):")
yield SelectionList[str]( yield SelectionList[str](
*self._build_remote_choices(), *self._build_remote_choices(),
id="sched-remotes", id="sched-remotes",

View File

@@ -36,10 +36,10 @@ class SnapshotsScreen(Screen):
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("Targets and remotes must be configured to browse snapshots.")
else: else:
yield Static("Target:") yield Static("Source:")
yield Select([(t, t) for t in targets], id="snap-target", prompt="Select target") yield Select([(t, t) for t in targets], id="snap-target", prompt="Select source")
yield Static("Remote:") yield Static("Destination:")
yield Select([(r, r) for r in remotes], id="snap-remote", prompt="Select remote") yield Select([(r, r) for r in remotes], id="snap-remote", prompt="Select destination")
yield Button("Load Snapshots", id="btn-load", variant="primary") yield Button("Load Snapshots", id="btn-load", variant="primary")
yield DataTable(id="snap-table") yield DataTable(id="snap-table")
with Horizontal(id="snapshots-buttons"): with Horizontal(id="snapshots-buttons"):

View File

@@ -23,7 +23,7 @@ class TargetEditScreen(Screen):
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
yield Header(show_clock=True) 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() target = Target()
if not self._is_new: if not self._is_new:
data = parse_conf(CONFIG_DIR / "targets.d" / f"{self._edit_name}.conf") data = parse_conf(CONFIG_DIR / "targets.d" / f"{self._edit_name}.conf")

View File

@@ -16,7 +16,7 @@ class TargetsScreen(Screen):
yield Header(show_clock=True) yield Header(show_clock=True)
with Horizontal(classes="screen-with-docs"): with Horizontal(classes="screen-with-docs"):
with Vertical(id="targets-screen"): with Vertical(id="targets-screen"):
yield Static("Targets", id="screen-title") yield Static("Sources", id="screen-title")
yield DataTable(id="targets-table") yield DataTable(id="targets-table")
with Horizontal(id="targets-buttons"): with Horizontal(id="targets-buttons"):
yield Button("Add", variant="primary", id="btn-add") yield Button("Add", variant="primary", id="btn-add")