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
|
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"
|
||||||
|
|||||||
@@ -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"
|
||||||
return 1
|
mkdir -p "$REMOTE_BASE" || {
|
||||||
|
log_error "Failed to create remote base directory: $REMOTE_BASE"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
s3|gdrive)
|
s3|gdrive)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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:")
|
||||||
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 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()
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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:")
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
rc, stdout, stderr = await run_cli("schedule", "install")
|
||||||
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")
|
|
||||||
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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"):
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
Reference in New Issue
Block a user