From b28146529e79de325c1ab38bdd24bb7352fe3d5b Mon Sep 17 00:00:00 2001 From: shuki Date: Fri, 6 Mar 2026 23:38:47 +0200 Subject: [PATCH] Add contextual Help panel to all TUI screens (F1 toggle) Split each screen into two columns: existing controls on the left, a DocsPanel with Rich-markup documentation on the right (30% width). Press F1 to toggle the help panel on/off from any screen. Co-Authored-By: Claude Opus 4.6 --- tui/app.py | 9 + tui/docs.py | 328 +++++++++++++++++++++++++++++++++++ tui/gniza.tcss | 4 + tui/screens/backup.py | 48 ++--- tui/screens/logs.py | 19 +- tui/screens/remote_edit.py | 118 ++++++------- tui/screens/remotes.py | 22 +-- tui/screens/restore.py | 52 +++--- tui/screens/retention.py | 44 ++--- tui/screens/running_tasks.py | 26 +-- tui/screens/schedule.py | 54 ++++-- tui/screens/schedule_edit.py | 143 +++++++-------- tui/screens/settings.py | 115 ++++++------ tui/screens/snapshots.py | 32 ++-- tui/screens/target_edit.py | 118 ++++++------- tui/screens/targets.py | 20 ++- tui/widgets/__init__.py | 1 + tui/widgets/docs_panel.py | 28 +++ 18 files changed, 805 insertions(+), 376 deletions(-) create mode 100644 tui/docs.py create mode 100644 tui/widgets/docs_panel.py diff --git a/tui/app.py b/tui/app.py index 3dd0c14..683127c 100644 --- a/tui/app.py +++ b/tui/app.py @@ -1,4 +1,5 @@ from textual.app import App +from textual.css.query import NoMatches from tui.config import has_remotes, has_targets from tui.screens.main_menu import MainMenuScreen @@ -23,6 +24,7 @@ class GnizaApp(App): TITLE = "GNIZA - Linux Backup Manager" CSS_PATH = "gniza.tcss" + BINDINGS = [("f1", "toggle_docs", "Help")] SCREENS = { "main": MainMenuScreen, @@ -60,6 +62,13 @@ class GnizaApp(App): else: self.notify(f"{job.label} failed (exit code {message.return_code})", severity="error") + def action_toggle_docs(self) -> None: + try: + panel = self.screen.query_one("#docs-panel") + panel.display = not panel.display + except NoMatches: + pass + async def action_quit(self) -> None: if job_manager.running_count() > 0: from tui.widgets import ConfirmDialog diff --git a/tui/docs.py b/tui/docs.py new file mode 100644 index 0000000..e2ff782 --- /dev/null +++ b/tui/docs.py @@ -0,0 +1,328 @@ +SCREEN_DOCS = { + "backup-screen": ( + "[bold]Backup Screen[/bold]\n" + "\n" + "Run backups of configured targets to remote destinations.\n" + "\n" + "[bold]Fields:[/bold]\n" + " [bold]Target[/bold] - The backup target to run. Each target\n" + " defines which folders and databases to back up.\n" + "\n" + " [bold]Remote[/bold] - Where to send the backup. Choose a\n" + " specific remote or 'Default (all)' to back up to\n" + " every configured remote.\n" + "\n" + "[bold]Buttons:[/bold]\n" + " [bold]Run Backup[/bold] - Back up the selected target to\n" + " the selected remote.\n" + " [bold]Backup All[/bold] - Back up all enabled targets to\n" + " all their configured remotes.\n" + "\n" + "[bold]Tips:[/bold]\n" + " - Backups run in the background. You can monitor\n" + " progress on the Running Tasks screen.\n" + " - Each backup creates a timestamped snapshot on\n" + " the remote, so you can restore any point in time.\n" + " - Use retention cleanup to remove old snapshots." + ), + "restore-screen": ( + "[bold]Restore Screen[/bold]\n" + "\n" + "Restore files and databases from a remote snapshot.\n" + "\n" + "[bold]Fields:[/bold]\n" + " [bold]Target[/bold] - The backup target to restore.\n" + " [bold]Remote[/bold] - The remote that holds the snapshot.\n" + " [bold]Snapshot[/bold] - The specific point-in-time snapshot\n" + " to restore from. Loaded after selecting target and\n" + " remote.\n" + " [bold]Restore location[/bold] - Choose 'In-place' to\n" + " overwrite original files, or 'Custom directory' to\n" + " restore to a different path.\n" + " [bold]Restore MySQL[/bold] - Toggle whether to restore\n" + " MySQL databases (only shown if the target has MySQL\n" + " backup enabled).\n" + "\n" + "[bold]Warning:[/bold]\n" + " In-place restore will overwrite existing files.\n" + " Consider restoring to a custom directory first to\n" + " verify the contents." + ), + "targets-screen": ( + "[bold]Targets Screen[/bold]\n" + "\n" + "List and manage backup targets. A target defines what\n" + "to back up: folders, file patterns, and optionally\n" + "MySQL databases.\n" + "\n" + "[bold]Table columns:[/bold]\n" + " [bold]Name[/bold] - Unique target identifier.\n" + " [bold]Folders[/bold] - Comma-separated paths to back up.\n" + " [bold]Enabled[/bold] - Whether the target is active.\n" + "\n" + "[bold]Buttons:[/bold]\n" + " [bold]Add[/bold] - Create a new target.\n" + " [bold]Edit[/bold] - Modify the selected target.\n" + " [bold]Delete[/bold] - Remove the selected target.\n" + "\n" + "[bold]Tips:[/bold]\n" + " - You need at least one target and one remote\n" + " before you can run backups.\n" + " - Disable a target to skip it during 'Backup All'\n" + " without deleting it." + ), + "target-edit": ( + "[bold]Target Editor[/bold]\n" + "\n" + "Add or edit a backup target configuration.\n" + "\n" + "[bold]Fields:[/bold]\n" + " [bold]Name[/bold] - Unique identifier (letters, digits,\n" + " dash, underscore; max 32 chars).\n" + " [bold]Folders[/bold] - Comma-separated paths to back up.\n" + " Use Browse to pick folders interactively.\n" + " [bold]Include/Exclude[/bold] - Glob patterns to filter\n" + " files (e.g. *.conf, *.log).\n" + " [bold]Remote override[/bold] - Force this target to a\n" + " specific remote instead of the default.\n" + " [bold]Retention override[/bold] - Custom snapshot count.\n" + " [bold]Pre/Post hooks[/bold] - Shell commands to run\n" + " before/after the backup.\n" + "\n" + "[bold]MySQL section:[/bold]\n" + " Enable MySQL to dump databases alongside files.\n" + " Choose 'All databases' or select specific ones.\n" + " Leave user/password empty for socket auth." + ), + "remotes-screen": ( + "[bold]Remotes Screen[/bold]\n" + "\n" + "List and manage remote backup destinations.\n" + "\n" + "[bold]Table columns:[/bold]\n" + " [bold]Name[/bold] - Unique remote identifier.\n" + " [bold]Type[/bold] - Connection type (SSH, Local, S3,\n" + " Google Drive).\n" + " [bold]Host/Path[/bold] - Connection details.\n" + " [bold]Disk[/bold] - Available space (loaded in background).\n" + "\n" + "[bold]Buttons:[/bold]\n" + " [bold]Add[/bold] - Create a new remote.\n" + " [bold]Edit[/bold] - Modify the selected remote.\n" + " [bold]Test[/bold] - Verify connectivity to the remote.\n" + " [bold]Delete[/bold] - Remove the selected remote.\n" + "\n" + "[bold]Tips:[/bold]\n" + " - Always test a new remote before running backups.\n" + " - The Disk column shows used/total space and may\n" + " take a moment to load for SSH remotes." + ), + "remote-edit": ( + "[bold]Remote Editor[/bold]\n" + "\n" + "Add or edit a remote backup destination.\n" + "\n" + "[bold]Types:[/bold]\n" + " [bold]SSH[/bold] - Remote server via SSH/rsync.\n" + " [bold]Local[/bold] - Local directory or mounted drive.\n" + " [bold]S3[/bold] - Amazon S3 or compatible storage.\n" + " [bold]Google Drive[/bold] - Google Drive via service\n" + " account.\n" + "\n" + "[bold]SSH fields:[/bold]\n" + " Host, port, user, and either SSH key or password\n" + " authentication.\n" + "\n" + "[bold]Common fields:[/bold]\n" + " [bold]Base path[/bold] - Root directory for backups on\n" + " the remote.\n" + " [bold]Bandwidth limit[/bold] - Throttle transfer speed\n" + " in KB/s (0 = unlimited).\n" + " [bold]Retention count[/bold] - Max snapshots to keep.\n" + "\n" + "[bold]Tips:[/bold]\n" + " - For SSH, ensure the remote user has write access\n" + " to the base path.\n" + " - Use key-based auth for unattended backups." + ), + "snapshots-screen": ( + "[bold]Snapshots Browser[/bold]\n" + "\n" + "Browse snapshots stored on remote destinations.\n" + "\n" + "[bold]Fields:[/bold]\n" + " [bold]Target[/bold] - The backup target.\n" + " [bold]Remote[/bold] - The remote holding snapshots.\n" + "\n" + "[bold]Buttons:[/bold]\n" + " [bold]Load Snapshots[/bold] - Fetch the list of\n" + " available snapshots from the remote.\n" + " [bold]Browse Files[/bold] - View the file tree inside\n" + " the selected snapshot.\n" + "\n" + "[bold]Tips:[/bold]\n" + " - Each snapshot is a timestamped directory on the\n" + " remote containing the backed-up files.\n" + " - Loading may take a moment for SSH remotes.\n" + " - Use the Restore screen to actually restore files\n" + " from a snapshot." + ), + "retention-screen": ( + "[bold]Retention Cleanup[/bold]\n" + "\n" + "Remove old snapshots based on retention count.\n" + "\n" + "[bold]Fields:[/bold]\n" + " [bold]Target[/bold] - Run cleanup for a specific target.\n" + " [bold]Default retention count[/bold] - Number of\n" + " snapshots to keep per target/remote pair.\n" + "\n" + "[bold]Buttons:[/bold]\n" + " [bold]Run Cleanup[/bold] - Clean old snapshots for the\n" + " selected target.\n" + " [bold]Cleanup All[/bold] - Clean old snapshots for all\n" + " targets.\n" + " [bold]Save[/bold] - Save the default retention count.\n" + "\n" + "[bold]How it works:[/bold]\n" + " Retention keeps the N most recent snapshots and\n" + " deletes the rest. Per-target or per-remote overrides\n" + " take priority over the default count.\n" + "\n" + "[bold]Warning:[/bold]\n" + " Deleted snapshots cannot be recovered." + ), + "schedule-screen": ( + "[bold]Schedules Screen[/bold]\n" + "\n" + "Manage scheduled backup jobs via cron.\n" + "\n" + "[bold]Table columns:[/bold]\n" + " [bold]Name[/bold] - Schedule identifier.\n" + " [bold]Active[/bold] - Whether the schedule is enabled.\n" + " [bold]Type[/bold] - Frequency (hourly/daily/weekly/\n" + " monthly/custom).\n" + " [bold]Time[/bold] - When the backup runs.\n" + " [bold]Last/Next Run[/bold] - Timing information.\n" + " [bold]Targets/Remotes[/bold] - Scope of the schedule.\n" + "\n" + "[bold]Buttons:[/bold]\n" + " [bold]Toggle Active[/bold] - Enable/disable a schedule.\n" + " [bold]Show crontab[/bold] - View the installed crontab.\n" + "\n" + "[bold]Tips:[/bold]\n" + " - The cron daemon must be running for schedules to\n" + " execute. You will be warned if it is not running.\n" + " - Changes are automatically synced to crontab." + ), + "schedule-edit": ( + "[bold]Schedule Editor[/bold]\n" + "\n" + "Add or edit a scheduled backup.\n" + "\n" + "[bold]Schedule types:[/bold]\n" + " [bold]Hourly[/bold] - Run every N hours.\n" + " [bold]Daily[/bold] - Run at a set time on selected days.\n" + " [bold]Weekly[/bold] - Run once a week on a chosen day.\n" + " [bold]Monthly[/bold] - Run once a month on a chosen day.\n" + " [bold]Custom cron[/bold] - Use a raw 5-field cron\n" + " expression for full control.\n" + "\n" + "[bold]Fields:[/bold]\n" + " [bold]Time[/bold] - The time of day to run (HH:MM).\n" + " [bold]Targets[/bold] - Which targets to back up. Leave\n" + " empty to back up all targets.\n" + " [bold]Remotes[/bold] - Which remotes to use. Leave empty\n" + " to use all remotes.\n" + "\n" + "[bold]Tips:[/bold]\n" + " - Daily schedules let you pick specific days of\n" + " the week.\n" + " - Custom cron: minute hour day month weekday" + ), + "logs-screen": ( + "[bold]Logs Screen[/bold]\n" + "\n" + "View backup operation logs.\n" + "\n" + "[bold]Table columns:[/bold]\n" + " [bold]Status[/bold] - Outcome (Success, Failed,\n" + " Interrupted, Empty).\n" + " [bold]Date/Time[/bold] - When the operation ran.\n" + " [bold]Size[/bold] - Log file size.\n" + "\n" + "[bold]Buttons:[/bold]\n" + " [bold]View[/bold] - Display the selected log file\n" + " contents in the viewer below.\n" + " [bold]Status[/bold] - Show a summary of recent backup\n" + " activity.\n" + "\n" + "[bold]Status detection:[/bold]\n" + " - [green]Success[/green] - 'Backup completed' found, no errors.\n" + " - [red]Failed[/red] - ERROR or FATAL entries found.\n" + " - [yellow]Interrupted[/yellow] - Log exists but backup did\n" + " not complete.\n" + "\n" + "[bold]Tips:[/bold]\n" + " - The 20 most recent logs are shown.\n" + " - Check logs after scheduled backups to verify\n" + " they ran successfully." + ), + "settings-screen": ( + "[bold]Settings Screen[/bold]\n" + "\n" + "Global application settings.\n" + "\n" + "[bold]General:[/bold]\n" + " [bold]Log Level[/bold] - Verbosity of log output.\n" + " [bold]Log Retention[/bold] - Days to keep log files.\n" + " [bold]Retention Count[/bold] - Default number of\n" + " snapshots to keep.\n" + " [bold]Bandwidth Limit[/bold] - Default transfer speed\n" + " limit in KB/s.\n" + " [bold]Disk Threshold[/bold] - Warn when remote disk\n" + " usage exceeds this percentage.\n" + "\n" + "[bold]Email Notifications:[/bold]\n" + " Configure SMTP to receive email alerts on backup\n" + " success or failure.\n" + "\n" + "[bold]SSH:[/bold]\n" + " [bold]Timeout[/bold] - Seconds before SSH gives up.\n" + " [bold]Retries[/bold] - Number of retry attempts.\n" + "\n" + "[bold]Web Dashboard:[/bold]\n" + " Port, host, and API key for the web interface.\n" + "\n" + "[bold]Tips:[/bold]\n" + " - Per-target and per-remote settings override\n" + " these defaults." + ), + "running-tasks-screen": ( + "[bold]Running Tasks[/bold]\n" + "\n" + "Monitor running backup and restore jobs.\n" + "\n" + "[bold]Table columns:[/bold]\n" + " [bold]Status[/bold] - Current state (running, ok, failed,\n" + " unknown).\n" + " [bold]Job[/bold] - Description of the operation.\n" + " [bold]Started[/bold] - When the job began.\n" + " [bold]Duration[/bold] - Elapsed time.\n" + "\n" + "[bold]Buttons:[/bold]\n" + " [bold]View Log[/bold] - Show live log output and\n" + " progress bar for the selected job.\n" + " [bold]Kill Job[/bold] - Terminate the selected running\n" + " job.\n" + " [bold]Clear Finished[/bold] - Remove completed jobs\n" + " from the list.\n" + "\n" + "[bold]Tips:[/bold]\n" + " - The table refreshes every second.\n" + " - The progress bar shows rsync transfer progress\n" + " when viewing a running job's log.\n" + " - Jobs continue in the background even if you\n" + " navigate away from this screen." + ), +} diff --git a/tui/gniza.tcss b/tui/gniza.tcss index 7c994af..540d233 100644 --- a/tui/gniza.tcss +++ b/tui/gniza.tcss @@ -33,6 +33,10 @@ Screen { margin: 1 2 0 2; } +.screen-with-docs { + height: 1fr; +} + /* Data tables */ DataTable { height: 12; diff --git a/tui/screens/backup.py b/tui/screens/backup.py index a73d639..de1d468 100644 --- a/tui/screens/backup.py +++ b/tui/screens/backup.py @@ -4,7 +4,7 @@ from textual.widgets import Header, Footer, Static, Button, Select from textual.containers import Vertical, Horizontal from tui.config import list_conf_dir, has_targets, has_remotes from tui.jobs import job_manager -from tui.widgets import ConfirmDialog +from tui.widgets import ConfirmDialog, DocsPanel class BackupScreen(Screen): @@ -15,28 +15,30 @@ class BackupScreen(Screen): yield Header(show_clock=True) targets = list_conf_dir("targets.d") remotes = list_conf_dir("remotes.d") - with Vertical(id="backup-screen"): - yield Static("Backup", id="screen-title") - if not targets: - yield Static("No targets configured. Add a target first.") - else: - yield Static("Target:") - yield Select( - [(t, t) for t in targets], - id="backup-target", - prompt="Select target", - ) - yield Static("Remote (optional):") - yield Select( - [("Default (all)", "")] + [(r, r) for r in remotes], - id="backup-remote", - prompt="Select remote", - value="", - ) - with Horizontal(id="backup-buttons"): - yield Button("Run Backup", variant="primary", id="btn-backup") - yield Button("Backup All", variant="warning", id="btn-backup-all") - yield Button("Back", id="btn-back") + with Horizontal(classes="screen-with-docs"): + with Vertical(id="backup-screen"): + yield Static("Backup", id="screen-title") + if not targets: + yield Static("No targets configured. Add a target first.") + else: + yield Static("Target:") + yield Select( + [(t, t) for t in targets], + id="backup-target", + prompt="Select target", + ) + yield Static("Remote (optional):") + yield Select( + [("Default (all)", "")] + [(r, r) for r in remotes], + id="backup-remote", + prompt="Select remote", + value="", + ) + with Horizontal(id="backup-buttons"): + yield Button("Run Backup", variant="primary", id="btn-backup") + yield Button("Backup All", variant="warning", id="btn-backup-all") + yield Button("Back", id="btn-back") + yield DocsPanel.for_screen("backup-screen") yield Footer() def on_button_pressed(self, event: Button.Pressed) -> None: diff --git a/tui/screens/logs.py b/tui/screens/logs.py index 4a40fc0..e337f3c 100644 --- a/tui/screens/logs.py +++ b/tui/screens/logs.py @@ -8,6 +8,7 @@ from textual.widgets import Header, Footer, Static, Button, DataTable, RichLog from textual.containers import Vertical, Horizontal from tui.config import LOG_DIR +from tui.widgets import DocsPanel _LOG_NAME_RE = re.compile(r"gniza-(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})(\d{2})\.log") @@ -47,14 +48,16 @@ class LogsScreen(Screen): def compose(self) -> ComposeResult: yield Header(show_clock=True) - with Vertical(id="logs-screen"): - yield Static("Logs", id="screen-title") - yield DataTable(id="logs-table") - with Horizontal(id="logs-buttons"): - yield Button("View", variant="primary", id="btn-view") - yield Button("Status", id="btn-status") - yield Button("Back", id="btn-back") - yield RichLog(id="log-viewer", wrap=True, highlight=True) + with Horizontal(classes="screen-with-docs"): + with Vertical(id="logs-screen"): + yield Static("Logs", id="screen-title") + yield DataTable(id="logs-table") + with Horizontal(id="logs-buttons"): + yield Button("View", variant="primary", id="btn-view") + yield Button("Status", id="btn-status") + yield Button("Back", id="btn-back") + yield RichLog(id="log-viewer", wrap=True, highlight=True) + yield DocsPanel.for_screen("logs-screen") yield Footer() def on_mount(self) -> None: diff --git a/tui/screens/remote_edit.py b/tui/screens/remote_edit.py index 98a8ce0..5f0f48b 100644 --- a/tui/screens/remote_edit.py +++ b/tui/screens/remote_edit.py @@ -7,7 +7,7 @@ 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 +from tui.widgets import FilePicker, DocsPanel _NAME_RE = re.compile(r'^[a-zA-Z][a-zA-Z0-9_-]{0,31}$') @@ -31,63 +31,65 @@ class RemoteEditScreen(Screen): data = parse_conf(CONFIG_DIR / "remotes.d" / f"{self._edit_name}.conf") remote = Remote.from_conf(self._edit_name, data) - with Vertical(id="remote-edit"): - yield Static(title, id="screen-title") - if self._is_new: - yield Static("Name:") - yield Input(value="", placeholder="Remote name", id="re-name") - yield Static("Type:") - yield Select( - REMOTE_TYPES, - id="re-type", - value=remote.type, - ) - # SSH fields - yield Static("Host:", id="lbl-host", classes="ssh-field") - yield Input(value=remote.host, placeholder="hostname or IP", id="re-host", classes="ssh-field") - yield Static("Port:", id="lbl-port", classes="ssh-field") - yield Input(value=remote.port, placeholder="22", id="re-port", classes="ssh-field") - yield Static("User:", id="lbl-user", classes="ssh-field") - yield Input(value=remote.user, placeholder="root", id="re-user", classes="ssh-field") - yield Static("Auth method:", id="lbl-auth", classes="ssh-field") - yield Select( - [("SSH Key", "key"), ("Password", "password")], - id="re-auth", - value=remote.auth_method, - classes="ssh-field", - ) - yield Static("SSH Key path:", id="lbl-key", classes="ssh-field ssh-key-field") - with Horizontal(id="re-key-row", classes="ssh-field ssh-key-field"): - yield Input(value=remote.key, placeholder="~/.ssh/id_rsa", id="re-key") - yield Button("Browse...", id="btn-browse-key") - yield Static("Password:", id="lbl-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 - yield Static("Base path:") - yield Input(value=remote.base, placeholder="/backups", id="re-base") - yield Static("Bandwidth limit (KB/s, 0=unlimited):") - yield Input(value=remote.bwlimit, placeholder="0", id="re-bwlimit") - yield Static("Retention count:") - yield Input(value=remote.retention_count, placeholder="30", id="re-retention") - # S3 fields - yield Static("S3 Bucket:", id="lbl-s3bucket", classes="s3-field") - yield Input(value=remote.s3_bucket, placeholder="bucket-name", id="re-s3bucket", classes="s3-field") - yield Static("S3 Region:", id="lbl-s3region", classes="s3-field") - yield Input(value=remote.s3_region, placeholder="us-east-1", id="re-s3region", classes="s3-field") - yield Static("S3 Endpoint:", id="lbl-s3endpoint", classes="s3-field") - yield Input(value=remote.s3_endpoint, placeholder="Leave empty for AWS", id="re-s3endpoint", classes="s3-field") - yield Static("Access Key ID:", id="lbl-s3key", classes="s3-field") - yield Input(value=remote.s3_access_key_id, id="re-s3key", classes="s3-field") - yield Static("Secret Access Key:", id="lbl-s3secret", classes="s3-field") - yield Input(value=remote.s3_secret_access_key, password=True, id="re-s3secret", classes="s3-field") - # GDrive fields - yield Static("Service Account JSON:", id="lbl-gdsa", classes="gdrive-field") - yield Input(value=remote.gdrive_sa_file, placeholder="/path/to/sa.json", id="re-gdsa", classes="gdrive-field") - yield Static("Root Folder ID:", id="lbl-gdfolder", classes="gdrive-field") - yield Input(value=remote.gdrive_root_folder_id, id="re-gdfolder", classes="gdrive-field") - with Horizontal(id="re-buttons"): - yield Button("Save", variant="primary", id="btn-save") - yield Button("Cancel", id="btn-cancel") + with Horizontal(classes="screen-with-docs"): + with Vertical(id="remote-edit"): + yield Static(title, id="screen-title") + if self._is_new: + yield Static("Name:") + yield Input(value="", placeholder="Remote name", id="re-name") + yield Static("Type:") + yield Select( + REMOTE_TYPES, + id="re-type", + value=remote.type, + ) + # SSH fields + yield Static("Host:", id="lbl-host", classes="ssh-field") + yield Input(value=remote.host, placeholder="hostname or IP", id="re-host", classes="ssh-field") + yield Static("Port:", id="lbl-port", classes="ssh-field") + yield Input(value=remote.port, placeholder="22", id="re-port", classes="ssh-field") + yield Static("User:", id="lbl-user", classes="ssh-field") + yield Input(value=remote.user, placeholder="root", id="re-user", classes="ssh-field") + yield Static("Auth method:", id="lbl-auth", classes="ssh-field") + yield Select( + [("SSH Key", "key"), ("Password", "password")], + id="re-auth", + value=remote.auth_method, + classes="ssh-field", + ) + yield Static("SSH Key path:", id="lbl-key", classes="ssh-field ssh-key-field") + with Horizontal(id="re-key-row", classes="ssh-field ssh-key-field"): + yield Input(value=remote.key, placeholder="~/.ssh/id_rsa", id="re-key") + yield Button("Browse...", id="btn-browse-key") + yield Static("Password:", id="lbl-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 + yield Static("Base path:") + yield Input(value=remote.base, placeholder="/backups", id="re-base") + yield Static("Bandwidth limit (KB/s, 0=unlimited):") + yield Input(value=remote.bwlimit, placeholder="0", id="re-bwlimit") + yield Static("Retention count:") + yield Input(value=remote.retention_count, placeholder="30", id="re-retention") + # S3 fields + yield Static("S3 Bucket:", id="lbl-s3bucket", classes="s3-field") + yield Input(value=remote.s3_bucket, placeholder="bucket-name", id="re-s3bucket", classes="s3-field") + yield Static("S3 Region:", id="lbl-s3region", classes="s3-field") + yield Input(value=remote.s3_region, placeholder="us-east-1", id="re-s3region", classes="s3-field") + yield Static("S3 Endpoint:", id="lbl-s3endpoint", classes="s3-field") + yield Input(value=remote.s3_endpoint, placeholder="Leave empty for AWS", id="re-s3endpoint", classes="s3-field") + yield Static("Access Key ID:", id="lbl-s3key", classes="s3-field") + yield Input(value=remote.s3_access_key_id, id="re-s3key", classes="s3-field") + yield Static("Secret Access Key:", id="lbl-s3secret", classes="s3-field") + yield Input(value=remote.s3_secret_access_key, password=True, id="re-s3secret", classes="s3-field") + # GDrive fields + yield Static("Service Account JSON:", id="lbl-gdsa", classes="gdrive-field") + yield Input(value=remote.gdrive_sa_file, placeholder="/path/to/sa.json", id="re-gdsa", classes="gdrive-field") + yield Static("Root Folder ID:", id="lbl-gdfolder", classes="gdrive-field") + yield Input(value=remote.gdrive_root_folder_id, id="re-gdfolder", classes="gdrive-field") + with Horizontal(id="re-buttons"): + yield Button("Save", variant="primary", id="btn-save") + yield Button("Cancel", id="btn-cancel") + yield DocsPanel.for_screen("remote-edit") yield Footer() def on_mount(self) -> None: diff --git a/tui/screens/remotes.py b/tui/screens/remotes.py index 1601b6e..54e0f9d 100644 --- a/tui/screens/remotes.py +++ b/tui/screens/remotes.py @@ -6,7 +6,7 @@ from textual import work from tui.config import list_conf_dir, parse_conf, CONFIG_DIR from tui.backend import run_cli -from tui.widgets import ConfirmDialog, OperationLog +from tui.widgets import ConfirmDialog, OperationLog, DocsPanel class RemotesScreen(Screen): @@ -15,15 +15,17 @@ class RemotesScreen(Screen): def compose(self) -> ComposeResult: yield Header(show_clock=True) - with Vertical(id="remotes-screen"): - yield Static("Remotes", id="screen-title") - yield DataTable(id="remotes-table") - with Horizontal(id="remotes-buttons"): - yield Button("Add", variant="primary", id="btn-add") - yield Button("Edit", id="btn-edit") - yield Button("Test", variant="warning", id="btn-test") - yield Button("Delete", variant="error", id="btn-delete") - yield Button("Back", id="btn-back") + with Horizontal(classes="screen-with-docs"): + with Vertical(id="remotes-screen"): + yield Static("Remotes", id="screen-title") + yield DataTable(id="remotes-table") + with Horizontal(id="remotes-buttons"): + yield Button("Add", variant="primary", id="btn-add") + yield Button("Edit", id="btn-edit") + yield Button("Test", variant="warning", id="btn-test") + yield Button("Delete", variant="error", id="btn-delete") + yield Button("Back", id="btn-back") + yield DocsPanel.for_screen("remotes-screen") yield Footer() def on_mount(self) -> None: diff --git a/tui/screens/restore.py b/tui/screens/restore.py index 7b4511e..3a7fd58 100644 --- a/tui/screens/restore.py +++ b/tui/screens/restore.py @@ -7,7 +7,7 @@ from textual import work, on from tui.config import list_conf_dir, parse_conf, CONFIG_DIR from tui.backend import run_cli from tui.jobs import job_manager -from tui.widgets import ConfirmDialog, FolderPicker +from tui.widgets import ConfirmDialog, FolderPicker, DocsPanel class RestoreScreen(Screen): @@ -18,30 +18,32 @@ class RestoreScreen(Screen): yield Header(show_clock=True) targets = list_conf_dir("targets.d") remotes = list_conf_dir("remotes.d") - with Vertical(id="restore-screen"): - yield Static("Restore", id="screen-title") - 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("Snapshot:") - yield Select([], id="restore-snapshot", prompt="Select target and remote first") - yield Static("Restore location:") - with RadioSet(id="restore-location"): - yield RadioButton("In-place (original)", value=True) - yield RadioButton("Custom directory") - with Horizontal(id="restore-dest-row"): - yield Input(placeholder="Destination directory (e.g. /tmp/restore)", id="restore-dest") - yield Button("Browse...", id="btn-browse-dest") - with Horizontal(id="restore-mysql-row"): - yield Static("Restore MySQL databases:") - yield Switch(value=True, id="restore-mysql-switch") - with Horizontal(id="restore-buttons"): - yield Button("Restore", variant="primary", id="btn-restore") - yield Button("Back", id="btn-back") + with Horizontal(classes="screen-with-docs"): + with Vertical(id="restore-screen"): + yield Static("Restore", id="screen-title") + 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("Snapshot:") + yield Select([], id="restore-snapshot", prompt="Select target and remote first") + yield Static("Restore location:") + with RadioSet(id="restore-location"): + yield RadioButton("In-place (original)", value=True) + yield RadioButton("Custom directory") + with Horizontal(id="restore-dest-row"): + yield Input(placeholder="Destination directory (e.g. /tmp/restore)", id="restore-dest") + yield Button("Browse...", id="btn-browse-dest") + with Horizontal(id="restore-mysql-row"): + yield Static("Restore MySQL databases:") + yield Switch(value=True, id="restore-mysql-switch") + with Horizontal(id="restore-buttons"): + yield Button("Restore", variant="primary", id="btn-restore") + yield Button("Back", id="btn-back") + yield DocsPanel.for_screen("restore-screen") yield Footer() def on_mount(self) -> None: diff --git a/tui/screens/retention.py b/tui/screens/retention.py index 4ca5242..e49cf35 100644 --- a/tui/screens/retention.py +++ b/tui/screens/retention.py @@ -6,7 +6,7 @@ from textual import work from tui.config import list_conf_dir, parse_conf, update_conf_key, CONFIG_DIR from tui.backend import stream_cli -from tui.widgets import ConfirmDialog, OperationLog +from tui.widgets import ConfirmDialog, OperationLog, DocsPanel class RetentionScreen(Screen): @@ -18,26 +18,28 @@ class RetentionScreen(Screen): targets = list_conf_dir("targets.d") conf = parse_conf(CONFIG_DIR / "gniza.conf") current_count = conf.get("RETENTION_COUNT", "30") - with Vertical(id="retention-screen"): - yield Static("Retention Cleanup", id="screen-title") - if not targets: - yield Static("No targets configured.") - else: - yield Static("Target:") - yield Select( - [(t, t) for t in targets], - id="ret-target", - prompt="Select target", - ) - with Horizontal(id="ret-buttons"): - yield Button("Run Cleanup", variant="primary", id="btn-cleanup") - yield Button("Cleanup All", variant="warning", id="btn-cleanup-all") - yield Static("") - yield Static("Default retention count:") - with Horizontal(): - yield Input(value=current_count, id="ret-count", placeholder="30") - yield Button("Save", id="btn-save-count") - yield Button("Back", id="btn-back") + with Horizontal(classes="screen-with-docs"): + with Vertical(id="retention-screen"): + yield Static("Retention Cleanup", id="screen-title") + if not targets: + yield Static("No targets configured.") + else: + yield Static("Target:") + yield Select( + [(t, t) for t in targets], + id="ret-target", + prompt="Select target", + ) + with Horizontal(id="ret-buttons"): + yield Button("Run Cleanup", variant="primary", id="btn-cleanup") + yield Button("Cleanup All", variant="warning", id="btn-cleanup-all") + yield Static("") + yield Static("Default retention count:") + with Horizontal(): + yield Input(value=current_count, id="ret-count", placeholder="30") + yield Button("Save", id="btn-save-count") + yield Button("Back", id="btn-back") + yield DocsPanel.for_screen("retention-screen") yield Footer() def on_button_pressed(self, event: Button.Pressed) -> None: diff --git a/tui/screens/running_tasks.py b/tui/screens/running_tasks.py index c7c41fa..8f7fe65 100644 --- a/tui/screens/running_tasks.py +++ b/tui/screens/running_tasks.py @@ -9,7 +9,7 @@ from textual.containers import Vertical, Horizontal from textual.timer import Timer from tui.jobs import job_manager -from tui.widgets import ConfirmDialog +from tui.widgets import ConfirmDialog, DocsPanel _PROGRESS_RE = re.compile(r"(\d+)%") @@ -20,17 +20,19 @@ class RunningTasksScreen(Screen): def compose(self) -> ComposeResult: yield Header(show_clock=True) - with Vertical(id="running-tasks-screen"): - yield Static("Running Tasks", id="screen-title") - yield DataTable(id="rt-table") - with Horizontal(id="rt-buttons"): - yield Button("View Log", variant="primary", id="btn-rt-view") - yield Button("Kill Job", variant="error", id="btn-rt-kill") - yield Button("Clear Finished", variant="warning", id="btn-rt-clear") - 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) + with Horizontal(classes="screen-with-docs"): + with Vertical(id="running-tasks-screen"): + yield Static("Running Tasks", id="screen-title") + yield DataTable(id="rt-table") + with Horizontal(id="rt-buttons"): + yield Button("View Log", variant="primary", id="btn-rt-view") + yield Button("Kill Job", variant="error", id="btn-rt-kill") + yield Button("Clear Finished", variant="warning", id="btn-rt-clear") + 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 DocsPanel.for_screen("running-tasks-screen") yield Footer() def on_mount(self) -> None: diff --git a/tui/screens/schedule.py b/tui/screens/schedule.py index ad4cb1d..1493909 100644 --- a/tui/screens/schedule.py +++ b/tui/screens/schedule.py @@ -9,7 +9,7 @@ from textual import work from tui.config import list_conf_dir, parse_conf, update_conf_key, CONFIG_DIR, LOG_DIR from tui.models import Schedule from tui.backend import run_cli -from tui.widgets import ConfirmDialog, OperationLog +from tui.widgets import ConfirmDialog, OperationLog, DocsPanel class ScheduleScreen(Screen): @@ -18,16 +18,18 @@ class ScheduleScreen(Screen): def compose(self) -> ComposeResult: yield Header(show_clock=True) - with Vertical(id="schedule-screen"): - yield Static("Schedules", id="screen-title") - yield DataTable(id="sched-table") - with Horizontal(id="sched-buttons"): - yield Button("Add", variant="primary", id="btn-add") - yield Button("Edit", id="btn-edit") - yield Button("Toggle Active", variant="warning", id="btn-toggle") - yield Button("Delete", variant="error", id="btn-delete") - yield Button("Show crontab", id="btn-show") - yield Button("Back", id="btn-back") + with Horizontal(classes="screen-with-docs"): + with Vertical(id="schedule-screen"): + yield Static("Schedules", id="screen-title") + yield DataTable(id="sched-table") + with Horizontal(id="sched-buttons"): + yield Button("Add", variant="primary", id="btn-add") + yield Button("Edit", id="btn-edit") + yield Button("Toggle Active", variant="warning", id="btn-toggle") + yield Button("Delete", variant="error", id="btn-delete") + yield Button("Show crontab", id="btn-show") + yield Button("Back", id="btn-back") + yield DocsPanel.for_screen("schedule-screen") yield Footer() def on_mount(self) -> None: @@ -169,6 +171,36 @@ class ScheduleScreen(Screen): rc, stdout, stderr = await run_cli("schedule", "remove") if rc != 0: self.notify(f"Crontab sync failed: {stderr or stdout}", severity="error") + # Warn if cron daemon is not running + if has_active and not await self._is_cron_running(): + self.notify( + "Cron daemon is not running — schedules won't execute. " + "Start it with: sudo systemctl start cron", + severity="warning", + timeout=10, + ) + + @staticmethod + async def _is_cron_running() -> bool: + """Check if the cron daemon is active.""" + import asyncio + for svc in ("cron", "crond"): + proc = await asyncio.create_subprocess_exec( + "systemctl", "is-active", svc, + stdout=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.DEVNULL, + ) + await proc.wait() + if proc.returncode == 0: + return True + # Fallback: check for a running process + proc = await asyncio.create_subprocess_exec( + "pgrep", "-x", "cron", + stdout=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.DEVNULL, + ) + await proc.wait() + return proc.returncode == 0 def _delete_schedule(self, name: str) -> None: conf = CONFIG_DIR / "schedules.d" / f"{name}.conf" diff --git a/tui/screens/schedule_edit.py b/tui/screens/schedule_edit.py index c789a71..d035265 100644 --- a/tui/screens/schedule_edit.py +++ b/tui/screens/schedule_edit.py @@ -6,6 +6,7 @@ from textual.containers import Vertical, Horizontal from tui.config import list_conf_dir, parse_conf, write_conf, CONFIG_DIR from tui.models import Schedule +from tui.widgets import DocsPanel _NAME_RE = re.compile(r'^[a-zA-Z][a-zA-Z0-9_-]{0,31}$') @@ -45,76 +46,78 @@ class ScheduleEditScreen(Screen): data = parse_conf(CONFIG_DIR / "schedules.d" / f"{self._edit_name}.conf") sched = Schedule.from_conf(self._edit_name, data) - with Vertical(id="schedule-edit"): - yield Static(title, id="screen-title") - if self._is_new: - yield Static("Name:") - yield Input(value="", placeholder="Schedule name", id="sched-name") - yield Static("Type:") - yield Select(SCHEDULE_TYPES, id="sched-type", value=sched.schedule) - yield Static("Schedule Hours:", classes="sched-hourly-field") - yield Select( - HOURLY_INTERVALS, - id="sched-interval", - value=sched.day if sched.schedule == "hourly" and sched.day else "1", - classes="sched-hourly-field", - ) - yield Static("Time (HH:MM):", classes="sched-time-field") - yield Input( - id="sched-time", - value=sched.time, - placeholder="02:00", - classes="sched-time-field", - ) - yield Static("Schedule Days:", classes="sched-daily-days-field") - yield SelectionList[str]( - ("Sunday", "0"), - ("Monday", "1"), - ("Tuesday", "2"), - ("Wednesday", "3"), - ("Thursday", "4"), - ("Friday", "5"), - ("Saturday", "6"), - id="sched-daily-days", - classes="sched-daily-days-field", - ) - yield Static("Schedule Day:", classes="sched-weekly-day-field") - yield Select( - [("Sunday", "0"), ("Monday", "1"), ("Tuesday", "2"), ("Wednesday", "3"), - ("Thursday", "4"), ("Friday", "5"), ("Saturday", "6")], - id="sched-weekly-day", - value=sched.day if sched.schedule == "weekly" and sched.day else "0", - classes="sched-weekly-day-field", - ) - yield Static("Schedule Day:", classes="sched-monthly-field") - yield Select( - [("1st of the month", "1"), ("7th of the month", "7"), - ("14th of the month", "14"), ("21st of the month", "21"), - ("28th of the month", "28")], - id="sched-monthly-day", - value=sched.day if sched.schedule == "monthly" and sched.day else "1", - classes="sched-monthly-field", - ) - yield Static("Custom cron (5 fields):", classes="sched-cron-field") - yield Input( - id="sched-cron", - value=sched.cron, - placeholder="0 2 * * *", - classes="sched-cron-field", - ) - yield Static("Targets (empty=all):") - yield SelectionList[str]( - *self._build_target_choices(), - id="sched-targets", - ) - yield Static("Remotes (empty=all):") - yield SelectionList[str]( - *self._build_remote_choices(), - id="sched-remotes", - ) - with Horizontal(id="sched-edit-buttons"): - yield Button("Save", variant="primary", id="btn-save") - yield Button("Cancel", id="btn-cancel") + with Horizontal(classes="screen-with-docs"): + with Vertical(id="schedule-edit"): + yield Static(title, id="screen-title") + if self._is_new: + yield Static("Name:") + yield Input(value="", placeholder="Schedule name", id="sched-name") + yield Static("Type:") + yield Select(SCHEDULE_TYPES, id="sched-type", value=sched.schedule) + yield Static("Schedule Hours:", classes="sched-hourly-field") + yield Select( + HOURLY_INTERVALS, + id="sched-interval", + value=sched.day if sched.schedule == "hourly" and sched.day else "1", + classes="sched-hourly-field", + ) + yield Static("Time (HH:MM):", classes="sched-time-field") + yield Input( + id="sched-time", + value=sched.time, + placeholder="02:00", + classes="sched-time-field", + ) + yield Static("Schedule Days:", classes="sched-daily-days-field") + yield SelectionList[str]( + ("Sunday", "0"), + ("Monday", "1"), + ("Tuesday", "2"), + ("Wednesday", "3"), + ("Thursday", "4"), + ("Friday", "5"), + ("Saturday", "6"), + id="sched-daily-days", + classes="sched-daily-days-field", + ) + yield Static("Schedule Day:", classes="sched-weekly-day-field") + yield Select( + [("Sunday", "0"), ("Monday", "1"), ("Tuesday", "2"), ("Wednesday", "3"), + ("Thursday", "4"), ("Friday", "5"), ("Saturday", "6")], + id="sched-weekly-day", + value=sched.day if sched.schedule == "weekly" and sched.day else "0", + classes="sched-weekly-day-field", + ) + yield Static("Schedule Day:", classes="sched-monthly-field") + yield Select( + [("1st of the month", "1"), ("7th of the month", "7"), + ("14th of the month", "14"), ("21st of the month", "21"), + ("28th of the month", "28")], + id="sched-monthly-day", + value=sched.day if sched.schedule == "monthly" and sched.day else "1", + classes="sched-monthly-field", + ) + yield Static("Custom cron (5 fields):", classes="sched-cron-field") + yield Input( + id="sched-cron", + value=sched.cron, + placeholder="0 2 * * *", + classes="sched-cron-field", + ) + yield Static("Targets (empty=all):") + yield SelectionList[str]( + *self._build_target_choices(), + id="sched-targets", + ) + yield Static("Remotes (empty=all):") + yield SelectionList[str]( + *self._build_remote_choices(), + id="sched-remotes", + ) + with Horizontal(id="sched-edit-buttons"): + yield Button("Save", variant="primary", id="btn-save") + yield Button("Cancel", id="btn-cancel") + yield DocsPanel.for_screen("schedule-edit") yield Footer() def _build_target_choices(self) -> list[tuple[str, str]]: diff --git a/tui/screens/settings.py b/tui/screens/settings.py index eade053..0d287b9 100644 --- a/tui/screens/settings.py +++ b/tui/screens/settings.py @@ -5,6 +5,7 @@ from textual.containers import Vertical, Horizontal from tui.config import parse_conf, write_conf, CONFIG_DIR from tui.models import AppSettings +from tui.widgets import DocsPanel class SettingsScreen(Screen): @@ -15,62 +16,64 @@ class SettingsScreen(Screen): yield Header(show_clock=True) conf = parse_conf(CONFIG_DIR / "gniza.conf") settings = AppSettings.from_conf(conf) - with Vertical(id="settings-screen"): - yield Static("Settings", id="screen-title") - yield Static("Log Level:") - yield Select( - [("Debug", "debug"), ("Info", "info"), ("Warning", "warn"), ("Error", "error")], - id="set-loglevel", - value=settings.log_level.lower(), - ) - yield Static("Log Retention (days):") - yield Input(value=settings.log_retain, id="set-logretain") - yield Static("Default Retention Count:") - yield Input(value=settings.retention_count, id="set-retention") - yield Static("Default Bandwidth Limit (KB/s, 0=unlimited):") - yield Input(value=settings.bwlimit, id="set-bwlimit") - yield Static("Disk Usage Threshold (%, 0=disable):") - yield Input(value=settings.disk_usage_threshold, id="set-diskthreshold") - yield Static("Notification Email:") - yield Input(value=settings.notify_email, id="set-email") - yield Static("Notify On:") - yield Select( - [("Always", "always"), ("Failure only", "failure"), ("Never", "never")], - id="set-notifyon", - value=settings.notify_on, - ) - yield Static("SMTP Host:") - yield Input(value=settings.smtp_host, id="set-smtphost") - yield Static("SMTP Port:") - yield Input(value=settings.smtp_port, id="set-smtpport") - yield Static("SMTP User:") - yield Input(value=settings.smtp_user, id="set-smtpuser") - yield Static("SMTP Password:") - yield Input(value=settings.smtp_password, password=True, id="set-smtppass") - yield Static("SMTP From:") - yield Input(value=settings.smtp_from, id="set-smtpfrom") - yield Static("SMTP Security:") - yield Select( - [("TLS", "tls"), ("SSL", "ssl"), ("None", "none")], - id="set-smtpsec", - value=settings.smtp_security, - ) - yield Static("SSH Timeout:") - yield Input(value=settings.ssh_timeout, id="set-sshtimeout") - yield Static("SSH Retries:") - yield Input(value=settings.ssh_retries, id="set-sshretries") - yield Static("Extra rsync options:") - yield Input(value=settings.rsync_extra_opts, id="set-rsyncopts") - yield Static("Web Dashboard", classes="section-label") - yield Static("Port:") - yield Input(value=settings.web_port, id="set-web-port") - yield Static("Host:") - yield Input(value=settings.web_host, id="set-web-host") - yield Static("API Key:") - yield Input(value=settings.web_api_key, password=True, id="set-web-key") - with Horizontal(id="set-buttons"): - yield Button("Save", variant="primary", id="btn-save") - yield Button("Back", id="btn-back") + with Horizontal(classes="screen-with-docs"): + with Vertical(id="settings-screen"): + yield Static("Settings", id="screen-title") + yield Static("Log Level:") + yield Select( + [("Debug", "debug"), ("Info", "info"), ("Warning", "warn"), ("Error", "error")], + id="set-loglevel", + value=settings.log_level.lower(), + ) + yield Static("Log Retention (days):") + yield Input(value=settings.log_retain, id="set-logretain") + yield Static("Default Retention Count:") + yield Input(value=settings.retention_count, id="set-retention") + yield Static("Default Bandwidth Limit (KB/s, 0=unlimited):") + yield Input(value=settings.bwlimit, id="set-bwlimit") + yield Static("Disk Usage Threshold (%, 0=disable):") + yield Input(value=settings.disk_usage_threshold, id="set-diskthreshold") + yield Static("Notification Email:") + yield Input(value=settings.notify_email, id="set-email") + yield Static("Notify On:") + yield Select( + [("Always", "always"), ("Failure only", "failure"), ("Never", "never")], + id="set-notifyon", + value=settings.notify_on, + ) + yield Static("SMTP Host:") + yield Input(value=settings.smtp_host, id="set-smtphost") + yield Static("SMTP Port:") + yield Input(value=settings.smtp_port, id="set-smtpport") + yield Static("SMTP User:") + yield Input(value=settings.smtp_user, id="set-smtpuser") + yield Static("SMTP Password:") + yield Input(value=settings.smtp_password, password=True, id="set-smtppass") + yield Static("SMTP From:") + yield Input(value=settings.smtp_from, id="set-smtpfrom") + yield Static("SMTP Security:") + yield Select( + [("TLS", "tls"), ("SSL", "ssl"), ("None", "none")], + id="set-smtpsec", + value=settings.smtp_security, + ) + yield Static("SSH Timeout:") + yield Input(value=settings.ssh_timeout, id="set-sshtimeout") + yield Static("SSH Retries:") + yield Input(value=settings.ssh_retries, id="set-sshretries") + yield Static("Extra rsync options:") + yield Input(value=settings.rsync_extra_opts, id="set-rsyncopts") + yield Static("Web Dashboard", classes="section-label") + yield Static("Port:") + yield Input(value=settings.web_port, id="set-web-port") + yield Static("Host:") + yield Input(value=settings.web_host, id="set-web-host") + yield Static("API Key:") + yield Input(value=settings.web_api_key, password=True, id="set-web-key") + with Horizontal(id="set-buttons"): + yield Button("Save", variant="primary", id="btn-save") + yield Button("Back", id="btn-back") + yield DocsPanel.for_screen("settings-screen") yield Footer() def on_button_pressed(self, event: Button.Pressed) -> None: diff --git a/tui/screens/snapshots.py b/tui/screens/snapshots.py index 776ea74..6dba1c3 100644 --- a/tui/screens/snapshots.py +++ b/tui/screens/snapshots.py @@ -9,7 +9,7 @@ from textual import work from tui.config import list_conf_dir from tui.backend import run_cli -from tui.widgets import SnapshotBrowser +from tui.widgets import SnapshotBrowser, DocsPanel def _format_snapshot_ts(ts: str) -> str: @@ -29,20 +29,22 @@ class SnapshotsScreen(Screen): yield Header(show_clock=True) targets = list_conf_dir("targets.d") remotes = list_conf_dir("remotes.d") - with Vertical(id="snapshots-screen"): - yield Static("Snapshots Browser", id="screen-title") - 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 Button("Load Snapshots", id="btn-load", variant="primary") - yield DataTable(id="snap-table") - with Horizontal(id="snapshots-buttons"): - yield Button("Browse Files", id="btn-browse") - yield Button("Back", id="btn-back") + with Horizontal(classes="screen-with-docs"): + with Vertical(id="snapshots-screen"): + yield Static("Snapshots Browser", id="screen-title") + 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 Button("Load Snapshots", id="btn-load", variant="primary") + yield DataTable(id="snap-table") + with Horizontal(id="snapshots-buttons"): + yield Button("Browse Files", id="btn-browse") + yield Button("Back", id="btn-back") + yield DocsPanel.for_screen("snapshots-screen") yield Footer() def on_mount(self) -> None: diff --git a/tui/screens/target_edit.py b/tui/screens/target_edit.py index 983f077..8f77aab 100644 --- a/tui/screens/target_edit.py +++ b/tui/screens/target_edit.py @@ -6,7 +6,7 @@ from textual.containers import Vertical, Horizontal from tui.config import parse_conf, write_conf, CONFIG_DIR, list_conf_dir from tui.models import Target -from tui.widgets import FolderPicker +from tui.widgets import FolderPicker, DocsPanel _NAME_RE = re.compile(r'^[a-zA-Z][a-zA-Z0-9_-]{0,31}$') @@ -28,63 +28,65 @@ class TargetEditScreen(Screen): data = parse_conf(CONFIG_DIR / "targets.d" / f"{self._edit_name}.conf") target = Target.from_conf(self._edit_name, data) - with Vertical(id="target-edit"): - yield Static(title, id="screen-title") - if self._is_new: - yield Static("Name:") - yield Input(value="", placeholder="Target name", id="te-name") - yield Static("Folders (comma-separated):") - yield Input(value=target.folders, placeholder="/path1,/path2", id="te-folders") - yield Button("Browse...", id="btn-browse") - yield Static("Include patterns:") - yield Input(value=target.include, placeholder="*.conf,docs/", id="te-include") - yield Static("Exclude patterns:") - yield Input(value=target.exclude, placeholder="*.tmp,*.log", id="te-exclude") - yield Static("Remote override:") - yield Input(value=target.remote, placeholder="Leave empty for default", id="te-remote") - yield Static("Retention override:") - yield Input(value=target.retention, placeholder="Leave empty for default", id="te-retention") - yield Static("Pre-backup hook:") - yield Input(value=target.pre_hook, placeholder="Command to run before backup", id="te-prehook") - yield Static("Post-backup hook:") - yield Input(value=target.post_hook, placeholder="Command to run after backup", id="te-posthook") - yield Static("Enabled:") - yield Select( - [("Yes", "yes"), ("No", "no")], - value="yes" if target.enabled == "yes" else "no", - id="te-enabled", - ) - yield Static("--- MySQL Backup ---", classes="section-label") - yield Static("MySQL Enabled:") - yield Select( - [("No", "no"), ("Yes", "yes")], - value=target.mysql_enabled, - id="te-mysql-enabled", - ) - yield Static("MySQL Mode:", classes="mysql-field") - yield Select( - [("All databases", "all"), ("Select databases", "select")], - value=target.mysql_mode, - id="te-mysql-mode", - classes="mysql-field", - ) - yield Static("Databases (comma-separated):", classes="mysql-field mysql-select-field") - yield Input(value=target.mysql_databases, placeholder="db1,db2", id="te-mysql-databases", classes="mysql-field mysql-select-field") - yield Static("Exclude databases (comma-separated):", classes="mysql-field mysql-all-field") - yield Input(value=target.mysql_exclude, placeholder="test_db,dev_db", id="te-mysql-exclude", classes="mysql-field mysql-all-field") - yield Static("MySQL User:", classes="mysql-field") - yield Input(value=target.mysql_user, placeholder="Leave empty for socket/~/.my.cnf auth", id="te-mysql-user", classes="mysql-field") - yield Static("MySQL Password:", classes="mysql-field") - yield Input(value=target.mysql_password, placeholder="Leave empty for socket/~/.my.cnf auth", password=True, id="te-mysql-password", classes="mysql-field") - yield Static("MySQL Host:", classes="mysql-field") - yield Input(value=target.mysql_host, placeholder="localhost", id="te-mysql-host", classes="mysql-field") - yield Static("MySQL Port:", classes="mysql-field") - yield Input(value=target.mysql_port, placeholder="3306", id="te-mysql-port", classes="mysql-field") - yield Static("MySQL Extra Options:", classes="mysql-field") - yield Input(value=target.mysql_extra_opts, placeholder="--single-transaction --routines --triggers", id="te-mysql-extra-opts", classes="mysql-field") - with Horizontal(id="te-buttons"): - yield Button("Save", variant="primary", id="btn-save") - yield Button("Cancel", id="btn-cancel") + with Horizontal(classes="screen-with-docs"): + with Vertical(id="target-edit"): + yield Static(title, id="screen-title") + if self._is_new: + yield Static("Name:") + yield Input(value="", placeholder="Target name", id="te-name") + yield Static("Folders (comma-separated):") + yield Input(value=target.folders, placeholder="/path1,/path2", id="te-folders") + yield Button("Browse...", id="btn-browse") + yield Static("Include patterns:") + yield Input(value=target.include, placeholder="*.conf,docs/", id="te-include") + yield Static("Exclude patterns:") + yield Input(value=target.exclude, placeholder="*.tmp,*.log", id="te-exclude") + yield Static("Remote override:") + yield Input(value=target.remote, placeholder="Leave empty for default", id="te-remote") + yield Static("Retention override:") + yield Input(value=target.retention, placeholder="Leave empty for default", id="te-retention") + yield Static("Pre-backup hook:") + yield Input(value=target.pre_hook, placeholder="Command to run before backup", id="te-prehook") + yield Static("Post-backup hook:") + yield Input(value=target.post_hook, placeholder="Command to run after backup", id="te-posthook") + yield Static("Enabled:") + yield Select( + [("Yes", "yes"), ("No", "no")], + value="yes" if target.enabled == "yes" else "no", + id="te-enabled", + ) + yield Static("--- MySQL Backup ---", classes="section-label") + yield Static("MySQL Enabled:") + yield Select( + [("No", "no"), ("Yes", "yes")], + value=target.mysql_enabled, + id="te-mysql-enabled", + ) + yield Static("MySQL Mode:", classes="mysql-field") + yield Select( + [("All databases", "all"), ("Select databases", "select")], + value=target.mysql_mode, + id="te-mysql-mode", + classes="mysql-field", + ) + yield Static("Databases (comma-separated):", classes="mysql-field mysql-select-field") + yield Input(value=target.mysql_databases, placeholder="db1,db2", id="te-mysql-databases", classes="mysql-field mysql-select-field") + yield Static("Exclude databases (comma-separated):", classes="mysql-field mysql-all-field") + yield Input(value=target.mysql_exclude, placeholder="test_db,dev_db", id="te-mysql-exclude", classes="mysql-field mysql-all-field") + yield Static("MySQL User:", classes="mysql-field") + yield Input(value=target.mysql_user, placeholder="Leave empty for socket/~/.my.cnf auth", id="te-mysql-user", classes="mysql-field") + yield Static("MySQL Password:", classes="mysql-field") + yield Input(value=target.mysql_password, placeholder="Leave empty for socket/~/.my.cnf auth", password=True, id="te-mysql-password", classes="mysql-field") + yield Static("MySQL Host:", classes="mysql-field") + yield Input(value=target.mysql_host, placeholder="localhost", id="te-mysql-host", classes="mysql-field") + yield Static("MySQL Port:", classes="mysql-field") + yield Input(value=target.mysql_port, placeholder="3306", id="te-mysql-port", classes="mysql-field") + yield Static("MySQL Extra Options:", classes="mysql-field") + yield Input(value=target.mysql_extra_opts, placeholder="--single-transaction --routines --triggers", id="te-mysql-extra-opts", classes="mysql-field") + with Horizontal(id="te-buttons"): + yield Button("Save", variant="primary", id="btn-save") + yield Button("Cancel", id="btn-cancel") + yield DocsPanel.for_screen("target-edit") yield Footer() def on_mount(self) -> None: diff --git a/tui/screens/targets.py b/tui/screens/targets.py index 9c0dd7f..e92701f 100644 --- a/tui/screens/targets.py +++ b/tui/screens/targets.py @@ -4,7 +4,7 @@ from textual.widgets import Header, Footer, Static, Button, DataTable from textual.containers import Vertical, Horizontal from tui.config import list_conf_dir, parse_conf, CONFIG_DIR -from tui.widgets import ConfirmDialog +from tui.widgets import ConfirmDialog, DocsPanel class TargetsScreen(Screen): @@ -13,14 +13,16 @@ class TargetsScreen(Screen): def compose(self) -> ComposeResult: yield Header(show_clock=True) - with Vertical(id="targets-screen"): - yield Static("Targets", id="screen-title") - yield DataTable(id="targets-table") - with Horizontal(id="targets-buttons"): - yield Button("Add", variant="primary", id="btn-add") - yield Button("Edit", id="btn-edit") - yield Button("Delete", variant="error", id="btn-delete") - yield Button("Back", id="btn-back") + with Horizontal(classes="screen-with-docs"): + with Vertical(id="targets-screen"): + yield Static("Targets", id="screen-title") + yield DataTable(id="targets-table") + with Horizontal(id="targets-buttons"): + yield Button("Add", variant="primary", id="btn-add") + yield Button("Edit", id="btn-edit") + yield Button("Delete", variant="error", id="btn-delete") + yield Button("Back", id="btn-back") + yield DocsPanel.for_screen("targets-screen") yield Footer() def on_mount(self) -> None: diff --git a/tui/widgets/__init__.py b/tui/widgets/__init__.py index 01337db..86f17a8 100644 --- a/tui/widgets/__init__.py +++ b/tui/widgets/__init__.py @@ -3,3 +3,4 @@ from tui.widgets.file_picker import FilePicker from tui.widgets.confirm_dialog import ConfirmDialog from tui.widgets.operation_log import OperationLog from tui.widgets.snapshot_browser import SnapshotBrowser +from tui.widgets.docs_panel import DocsPanel diff --git a/tui/widgets/docs_panel.py b/tui/widgets/docs_panel.py new file mode 100644 index 0000000..05555f7 --- /dev/null +++ b/tui/widgets/docs_panel.py @@ -0,0 +1,28 @@ +from textual.containers import VerticalScroll +from textual.widgets import Static +from tui.docs import SCREEN_DOCS + + +class DocsPanel(VerticalScroll): + DEFAULT_CSS = """ + DocsPanel { + width: 30%; + min-width: 30; + border-left: solid $accent; + padding: 1 2; + background: $surface; + } + """ + + def __init__(self, content: str, **kwargs): + super().__init__(id="docs-panel", **kwargs) + self._content = content + + def compose(self): + yield Static("[bold underline]Help[/]", id="docs-title") + yield Static(self._content, id="docs-body") + + @classmethod + def for_screen(cls, screen_id: str) -> "DocsPanel": + text = SCREEN_DOCS.get(screen_id, "No documentation available for this screen.") + return cls(content=text)