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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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