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:
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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:")
|
||||
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()
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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:")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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:
|
||||
# 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")
|
||||
else:
|
||||
rc, stdout, stderr = await run_cli("schedule", "remove")
|
||||
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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user