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 <noreply@anthropic.com>
This commit is contained in:
shuki
2026-03-06 23:38:47 +02:00
parent 19f6077e33
commit b28146529e
18 changed files with 805 additions and 376 deletions

View File

@@ -1,4 +1,5 @@
from textual.app import App from textual.app import App
from textual.css.query import NoMatches
from tui.config import has_remotes, has_targets from tui.config import has_remotes, has_targets
from tui.screens.main_menu import MainMenuScreen from tui.screens.main_menu import MainMenuScreen
@@ -23,6 +24,7 @@ class GnizaApp(App):
TITLE = "GNIZA - Linux Backup Manager" TITLE = "GNIZA - Linux Backup Manager"
CSS_PATH = "gniza.tcss" CSS_PATH = "gniza.tcss"
BINDINGS = [("f1", "toggle_docs", "Help")]
SCREENS = { SCREENS = {
"main": MainMenuScreen, "main": MainMenuScreen,
@@ -60,6 +62,13 @@ class GnizaApp(App):
else: else:
self.notify(f"{job.label} failed (exit code {message.return_code})", severity="error") 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: async def action_quit(self) -> None:
if job_manager.running_count() > 0: if job_manager.running_count() > 0:
from tui.widgets import ConfirmDialog from tui.widgets import ConfirmDialog

328
tui/docs.py Normal file
View File

@@ -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."
),
}

View File

@@ -33,6 +33,10 @@ Screen {
margin: 1 2 0 2; margin: 1 2 0 2;
} }
.screen-with-docs {
height: 1fr;
}
/* Data tables */ /* Data tables */
DataTable { DataTable {
height: 12; height: 12;

View File

@@ -4,7 +4,7 @@ from textual.widgets import Header, Footer, Static, Button, Select
from textual.containers import Vertical, Horizontal from textual.containers import Vertical, Horizontal
from tui.config import list_conf_dir, has_targets, has_remotes from tui.config import list_conf_dir, has_targets, has_remotes
from tui.jobs import job_manager from tui.jobs import job_manager
from tui.widgets import ConfirmDialog from tui.widgets import ConfirmDialog, DocsPanel
class BackupScreen(Screen): class BackupScreen(Screen):
@@ -15,28 +15,30 @@ class BackupScreen(Screen):
yield Header(show_clock=True) yield Header(show_clock=True)
targets = list_conf_dir("targets.d") targets = list_conf_dir("targets.d")
remotes = list_conf_dir("remotes.d") remotes = list_conf_dir("remotes.d")
with Vertical(id="backup-screen"): with Horizontal(classes="screen-with-docs"):
yield Static("Backup", id="screen-title") with Vertical(id="backup-screen"):
if not targets: yield Static("Backup", id="screen-title")
yield Static("No targets configured. Add a target first.") if not targets:
else: yield Static("No targets configured. Add a target first.")
yield Static("Target:") else:
yield Select( yield Static("Target:")
[(t, t) for t in targets], yield Select(
id="backup-target", [(t, t) for t in targets],
prompt="Select target", id="backup-target",
) prompt="Select target",
yield Static("Remote (optional):") )
yield Select( yield Static("Remote (optional):")
[("Default (all)", "")] + [(r, r) for r in remotes], yield Select(
id="backup-remote", [("Default (all)", "")] + [(r, r) for r in remotes],
prompt="Select remote", id="backup-remote",
value="", prompt="Select remote",
) value="",
with Horizontal(id="backup-buttons"): )
yield Button("Run Backup", variant="primary", id="btn-backup") with Horizontal(id="backup-buttons"):
yield Button("Backup All", variant="warning", id="btn-backup-all") yield Button("Run Backup", variant="primary", id="btn-backup")
yield Button("Back", id="btn-back") yield Button("Backup All", variant="warning", id="btn-backup-all")
yield Button("Back", id="btn-back")
yield DocsPanel.for_screen("backup-screen")
yield Footer() yield Footer()
def on_button_pressed(self, event: Button.Pressed) -> None: def on_button_pressed(self, event: Button.Pressed) -> None:

View File

@@ -8,6 +8,7 @@ from textual.widgets import Header, Footer, Static, Button, DataTable, RichLog
from textual.containers import Vertical, Horizontal from textual.containers import Vertical, Horizontal
from tui.config import LOG_DIR 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") _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: def compose(self) -> ComposeResult:
yield Header(show_clock=True) yield Header(show_clock=True)
with Vertical(id="logs-screen"): with Horizontal(classes="screen-with-docs"):
yield Static("Logs", id="screen-title") with Vertical(id="logs-screen"):
yield DataTable(id="logs-table") yield Static("Logs", id="screen-title")
with Horizontal(id="logs-buttons"): yield DataTable(id="logs-table")
yield Button("View", variant="primary", id="btn-view") with Horizontal(id="logs-buttons"):
yield Button("Status", id="btn-status") yield Button("View", variant="primary", id="btn-view")
yield Button("Back", id="btn-back") yield Button("Status", id="btn-status")
yield RichLog(id="log-viewer", wrap=True, highlight=True) yield Button("Back", id="btn-back")
yield RichLog(id="log-viewer", wrap=True, highlight=True)
yield DocsPanel.for_screen("logs-screen")
yield Footer() yield Footer()
def on_mount(self) -> None: def on_mount(self) -> None:

View File

@@ -7,7 +7,7 @@ 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 from tui.widgets import FilePicker, DocsPanel
_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}$')
@@ -31,63 +31,65 @@ class RemoteEditScreen(Screen):
data = parse_conf(CONFIG_DIR / "remotes.d" / f"{self._edit_name}.conf") data = parse_conf(CONFIG_DIR / "remotes.d" / f"{self._edit_name}.conf")
remote = Remote.from_conf(self._edit_name, data) remote = Remote.from_conf(self._edit_name, data)
with Vertical(id="remote-edit"): with Horizontal(classes="screen-with-docs"):
yield Static(title, id="screen-title") with Vertical(id="remote-edit"):
if self._is_new: yield Static(title, id="screen-title")
yield Static("Name:") if self._is_new:
yield Input(value="", placeholder="Remote name", id="re-name") yield Static("Name:")
yield Static("Type:") yield Input(value="", placeholder="Remote name", id="re-name")
yield Select( yield Static("Type:")
REMOTE_TYPES, yield Select(
id="re-type", REMOTE_TYPES,
value=remote.type, id="re-type",
) value=remote.type,
# SSH fields )
yield Static("Host:", id="lbl-host", classes="ssh-field") # SSH fields
yield Input(value=remote.host, placeholder="hostname or IP", id="re-host", classes="ssh-field") yield Static("Host:", id="lbl-host", classes="ssh-field")
yield Static("Port:", id="lbl-port", classes="ssh-field") yield Input(value=remote.host, placeholder="hostname or IP", id="re-host", classes="ssh-field")
yield Input(value=remote.port, placeholder="22", id="re-port", classes="ssh-field") yield Static("Port:", id="lbl-port", classes="ssh-field")
yield Static("User:", id="lbl-user", classes="ssh-field") yield Input(value=remote.port, placeholder="22", id="re-port", classes="ssh-field")
yield Input(value=remote.user, placeholder="root", id="re-user", classes="ssh-field") yield Static("User:", id="lbl-user", classes="ssh-field")
yield Static("Auth method:", id="lbl-auth", classes="ssh-field") yield Input(value=remote.user, placeholder="root", id="re-user", classes="ssh-field")
yield Select( yield Static("Auth method:", id="lbl-auth", classes="ssh-field")
[("SSH Key", "key"), ("Password", "password")], yield Select(
id="re-auth", [("SSH Key", "key"), ("Password", "password")],
value=remote.auth_method, id="re-auth",
classes="ssh-field", 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 Static("SSH Key path:", id="lbl-key", classes="ssh-field ssh-key-field")
yield Input(value=remote.key, placeholder="~/.ssh/id_rsa", id="re-key") with Horizontal(id="re-key-row", classes="ssh-field ssh-key-field"):
yield Button("Browse...", id="btn-browse-key") yield Input(value=remote.key, placeholder="~/.ssh/id_rsa", id="re-key")
yield Static("Password:", id="lbl-password", classes="ssh-field ssh-password-field") yield Button("Browse...", id="btn-browse-key")
yield Input(value=remote.password, placeholder="SSH password", password=True, id="re-password", classes="ssh-field ssh-password-field") yield Static("Password:", id="lbl-password", classes="ssh-field ssh-password-field")
# Common fields yield Input(value=remote.password, placeholder="SSH password", password=True, id="re-password", classes="ssh-field ssh-password-field")
yield Static("Base path:") # Common fields
yield Input(value=remote.base, placeholder="/backups", id="re-base") yield Static("Base path:")
yield Static("Bandwidth limit (KB/s, 0=unlimited):") yield Input(value=remote.base, placeholder="/backups", id="re-base")
yield Input(value=remote.bwlimit, placeholder="0", id="re-bwlimit") yield Static("Bandwidth limit (KB/s, 0=unlimited):")
yield Static("Retention count:") yield Input(value=remote.bwlimit, placeholder="0", id="re-bwlimit")
yield Input(value=remote.retention_count, placeholder="30", id="re-retention") yield Static("Retention count:")
# S3 fields yield Input(value=remote.retention_count, placeholder="30", id="re-retention")
yield Static("S3 Bucket:", id="lbl-s3bucket", classes="s3-field") # S3 fields
yield Input(value=remote.s3_bucket, placeholder="bucket-name", id="re-s3bucket", classes="s3-field") yield Static("S3 Bucket:", id="lbl-s3bucket", classes="s3-field")
yield Static("S3 Region:", id="lbl-s3region", classes="s3-field") yield Input(value=remote.s3_bucket, placeholder="bucket-name", id="re-s3bucket", classes="s3-field")
yield Input(value=remote.s3_region, placeholder="us-east-1", id="re-s3region", classes="s3-field") yield Static("S3 Region:", id="lbl-s3region", classes="s3-field")
yield Static("S3 Endpoint:", id="lbl-s3endpoint", classes="s3-field") yield Input(value=remote.s3_region, placeholder="us-east-1", id="re-s3region", classes="s3-field")
yield Input(value=remote.s3_endpoint, placeholder="Leave empty for AWS", id="re-s3endpoint", classes="s3-field") yield Static("S3 Endpoint:", id="lbl-s3endpoint", classes="s3-field")
yield Static("Access Key ID:", id="lbl-s3key", classes="s3-field") yield Input(value=remote.s3_endpoint, placeholder="Leave empty for AWS", id="re-s3endpoint", classes="s3-field")
yield Input(value=remote.s3_access_key_id, id="re-s3key", classes="s3-field") yield Static("Access Key ID:", id="lbl-s3key", classes="s3-field")
yield Static("Secret Access Key:", id="lbl-s3secret", classes="s3-field") yield Input(value=remote.s3_access_key_id, id="re-s3key", classes="s3-field")
yield Input(value=remote.s3_secret_access_key, password=True, id="re-s3secret", classes="s3-field") yield Static("Secret Access Key:", id="lbl-s3secret", classes="s3-field")
# GDrive fields yield Input(value=remote.s3_secret_access_key, password=True, id="re-s3secret", classes="s3-field")
yield Static("Service Account JSON:", id="lbl-gdsa", classes="gdrive-field") # GDrive fields
yield Input(value=remote.gdrive_sa_file, placeholder="/path/to/sa.json", id="re-gdsa", classes="gdrive-field") yield Static("Service Account JSON:", id="lbl-gdsa", classes="gdrive-field")
yield Static("Root Folder ID:", id="lbl-gdfolder", classes="gdrive-field") yield Input(value=remote.gdrive_sa_file, placeholder="/path/to/sa.json", id="re-gdsa", classes="gdrive-field")
yield Input(value=remote.gdrive_root_folder_id, id="re-gdfolder", classes="gdrive-field") yield Static("Root Folder ID:", id="lbl-gdfolder", classes="gdrive-field")
with Horizontal(id="re-buttons"): yield Input(value=remote.gdrive_root_folder_id, id="re-gdfolder", classes="gdrive-field")
yield Button("Save", variant="primary", id="btn-save") with Horizontal(id="re-buttons"):
yield Button("Cancel", id="btn-cancel") yield Button("Save", variant="primary", id="btn-save")
yield Button("Cancel", id="btn-cancel")
yield DocsPanel.for_screen("remote-edit")
yield Footer() yield Footer()
def on_mount(self) -> None: def on_mount(self) -> None:

View File

@@ -6,7 +6,7 @@ from textual import work
from tui.config import list_conf_dir, parse_conf, CONFIG_DIR from tui.config import list_conf_dir, parse_conf, CONFIG_DIR
from tui.backend import run_cli from tui.backend import run_cli
from tui.widgets import ConfirmDialog, OperationLog from tui.widgets import ConfirmDialog, OperationLog, DocsPanel
class RemotesScreen(Screen): class RemotesScreen(Screen):
@@ -15,15 +15,17 @@ class RemotesScreen(Screen):
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
yield Header(show_clock=True) yield Header(show_clock=True)
with Vertical(id="remotes-screen"): with Horizontal(classes="screen-with-docs"):
yield Static("Remotes", id="screen-title") with Vertical(id="remotes-screen"):
yield DataTable(id="remotes-table") yield Static("Remotes", id="screen-title")
with Horizontal(id="remotes-buttons"): yield DataTable(id="remotes-table")
yield Button("Add", variant="primary", id="btn-add") with Horizontal(id="remotes-buttons"):
yield Button("Edit", id="btn-edit") yield Button("Add", variant="primary", id="btn-add")
yield Button("Test", variant="warning", id="btn-test") yield Button("Edit", id="btn-edit")
yield Button("Delete", variant="error", id="btn-delete") yield Button("Test", variant="warning", id="btn-test")
yield Button("Back", id="btn-back") yield Button("Delete", variant="error", id="btn-delete")
yield Button("Back", id="btn-back")
yield DocsPanel.for_screen("remotes-screen")
yield Footer() yield Footer()
def on_mount(self) -> None: def on_mount(self) -> None:

View File

@@ -7,7 +7,7 @@ from textual import work, on
from tui.config import list_conf_dir, parse_conf, CONFIG_DIR from tui.config import list_conf_dir, parse_conf, CONFIG_DIR
from tui.backend import run_cli from tui.backend import run_cli
from tui.jobs import job_manager from tui.jobs import job_manager
from tui.widgets import ConfirmDialog, FolderPicker from tui.widgets import ConfirmDialog, FolderPicker, DocsPanel
class RestoreScreen(Screen): class RestoreScreen(Screen):
@@ -18,30 +18,32 @@ class RestoreScreen(Screen):
yield Header(show_clock=True) yield Header(show_clock=True)
targets = list_conf_dir("targets.d") targets = list_conf_dir("targets.d")
remotes = list_conf_dir("remotes.d") remotes = list_conf_dir("remotes.d")
with Vertical(id="restore-screen"): with Horizontal(classes="screen-with-docs"):
yield Static("Restore", id="screen-title") with Vertical(id="restore-screen"):
if not targets or not remotes: yield Static("Restore", id="screen-title")
yield Static("Both targets and remotes must be configured for restore.") if not targets or not remotes:
else: yield Static("Both targets and remotes must be configured for restore.")
yield Static("Target:") else:
yield Select([(t, t) for t in targets], id="restore-target", prompt="Select target") yield Static("Target:")
yield Static("Remote:") yield Select([(t, t) for t in targets], id="restore-target", prompt="Select target")
yield Select([(r, r) for r in remotes], id="restore-remote", prompt="Select remote") yield Static("Remote:")
yield Static("Snapshot:") yield Select([(r, r) for r in remotes], id="restore-remote", prompt="Select remote")
yield Select([], id="restore-snapshot", prompt="Select target and remote first") yield Static("Snapshot:")
yield Static("Restore location:") yield Select([], id="restore-snapshot", prompt="Select target and remote first")
with RadioSet(id="restore-location"): yield Static("Restore location:")
yield RadioButton("In-place (original)", value=True) with RadioSet(id="restore-location"):
yield RadioButton("Custom directory") yield RadioButton("In-place (original)", value=True)
with Horizontal(id="restore-dest-row"): yield RadioButton("Custom directory")
yield Input(placeholder="Destination directory (e.g. /tmp/restore)", id="restore-dest") with Horizontal(id="restore-dest-row"):
yield Button("Browse...", id="btn-browse-dest") yield Input(placeholder="Destination directory (e.g. /tmp/restore)", id="restore-dest")
with Horizontal(id="restore-mysql-row"): yield Button("Browse...", id="btn-browse-dest")
yield Static("Restore MySQL databases:") with Horizontal(id="restore-mysql-row"):
yield Switch(value=True, id="restore-mysql-switch") yield Static("Restore MySQL databases:")
with Horizontal(id="restore-buttons"): yield Switch(value=True, id="restore-mysql-switch")
yield Button("Restore", variant="primary", id="btn-restore") with Horizontal(id="restore-buttons"):
yield Button("Back", id="btn-back") yield Button("Restore", variant="primary", id="btn-restore")
yield Button("Back", id="btn-back")
yield DocsPanel.for_screen("restore-screen")
yield Footer() yield Footer()
def on_mount(self) -> None: def on_mount(self) -> None:

View File

@@ -6,7 +6,7 @@ from textual import work
from tui.config import list_conf_dir, parse_conf, update_conf_key, CONFIG_DIR from tui.config import list_conf_dir, parse_conf, update_conf_key, CONFIG_DIR
from tui.backend import stream_cli from tui.backend import stream_cli
from tui.widgets import ConfirmDialog, OperationLog from tui.widgets import ConfirmDialog, OperationLog, DocsPanel
class RetentionScreen(Screen): class RetentionScreen(Screen):
@@ -18,26 +18,28 @@ class RetentionScreen(Screen):
targets = list_conf_dir("targets.d") targets = list_conf_dir("targets.d")
conf = parse_conf(CONFIG_DIR / "gniza.conf") conf = parse_conf(CONFIG_DIR / "gniza.conf")
current_count = conf.get("RETENTION_COUNT", "30") current_count = conf.get("RETENTION_COUNT", "30")
with Vertical(id="retention-screen"): with Horizontal(classes="screen-with-docs"):
yield Static("Retention Cleanup", id="screen-title") with Vertical(id="retention-screen"):
if not targets: yield Static("Retention Cleanup", id="screen-title")
yield Static("No targets configured.") if not targets:
else: yield Static("No targets configured.")
yield Static("Target:") else:
yield Select( yield Static("Target:")
[(t, t) for t in targets], yield Select(
id="ret-target", [(t, t) for t in targets],
prompt="Select target", id="ret-target",
) prompt="Select target",
with Horizontal(id="ret-buttons"): )
yield Button("Run Cleanup", variant="primary", id="btn-cleanup") with Horizontal(id="ret-buttons"):
yield Button("Cleanup All", variant="warning", id="btn-cleanup-all") yield Button("Run Cleanup", variant="primary", id="btn-cleanup")
yield Static("") yield Button("Cleanup All", variant="warning", id="btn-cleanup-all")
yield Static("Default retention count:") yield Static("")
with Horizontal(): yield Static("Default retention count:")
yield Input(value=current_count, id="ret-count", placeholder="30") with Horizontal():
yield Button("Save", id="btn-save-count") yield Input(value=current_count, id="ret-count", placeholder="30")
yield Button("Back", id="btn-back") yield Button("Save", id="btn-save-count")
yield Button("Back", id="btn-back")
yield DocsPanel.for_screen("retention-screen")
yield Footer() yield Footer()
def on_button_pressed(self, event: Button.Pressed) -> None: def on_button_pressed(self, event: Button.Pressed) -> None:

View File

@@ -9,7 +9,7 @@ from textual.containers import Vertical, Horizontal
from textual.timer import Timer from textual.timer import Timer
from tui.jobs import job_manager from tui.jobs import job_manager
from tui.widgets import ConfirmDialog from tui.widgets import ConfirmDialog, DocsPanel
_PROGRESS_RE = re.compile(r"(\d+)%") _PROGRESS_RE = re.compile(r"(\d+)%")
@@ -20,17 +20,19 @@ class RunningTasksScreen(Screen):
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
yield Header(show_clock=True) yield Header(show_clock=True)
with Vertical(id="running-tasks-screen"): with Horizontal(classes="screen-with-docs"):
yield Static("Running Tasks", id="screen-title") with Vertical(id="running-tasks-screen"):
yield DataTable(id="rt-table") yield Static("Running Tasks", id="screen-title")
with Horizontal(id="rt-buttons"): yield DataTable(id="rt-table")
yield Button("View Log", variant="primary", id="btn-rt-view") with Horizontal(id="rt-buttons"):
yield Button("Kill Job", variant="error", id="btn-rt-kill") yield Button("View Log", variant="primary", id="btn-rt-view")
yield Button("Clear Finished", variant="warning", id="btn-rt-clear") yield Button("Kill Job", variant="error", id="btn-rt-kill")
yield Button("Back", id="btn-rt-back") yield Button("Clear Finished", variant="warning", id="btn-rt-clear")
yield Static("", id="rt-progress-label") yield Button("Back", id="btn-rt-back")
yield ProgressBar(id="rt-progress", total=100, show_eta=False) yield Static("", id="rt-progress-label")
yield RichLog(id="rt-log-viewer", wrap=True, highlight=True) 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() yield Footer()
def on_mount(self) -> None: def on_mount(self) -> None:

View File

@@ -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.config import list_conf_dir, parse_conf, update_conf_key, CONFIG_DIR, LOG_DIR
from tui.models import Schedule from tui.models import Schedule
from tui.backend import run_cli from tui.backend import run_cli
from tui.widgets import ConfirmDialog, OperationLog from tui.widgets import ConfirmDialog, OperationLog, DocsPanel
class ScheduleScreen(Screen): class ScheduleScreen(Screen):
@@ -18,16 +18,18 @@ class ScheduleScreen(Screen):
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
yield Header(show_clock=True) yield Header(show_clock=True)
with Vertical(id="schedule-screen"): with Horizontal(classes="screen-with-docs"):
yield Static("Schedules", id="screen-title") with Vertical(id="schedule-screen"):
yield DataTable(id="sched-table") yield Static("Schedules", id="screen-title")
with Horizontal(id="sched-buttons"): yield DataTable(id="sched-table")
yield Button("Add", variant="primary", id="btn-add") with Horizontal(id="sched-buttons"):
yield Button("Edit", id="btn-edit") yield Button("Add", variant="primary", id="btn-add")
yield Button("Toggle Active", variant="warning", id="btn-toggle") yield Button("Edit", id="btn-edit")
yield Button("Delete", variant="error", id="btn-delete") yield Button("Toggle Active", variant="warning", id="btn-toggle")
yield Button("Show crontab", id="btn-show") yield Button("Delete", variant="error", id="btn-delete")
yield Button("Back", id="btn-back") yield Button("Show crontab", id="btn-show")
yield Button("Back", id="btn-back")
yield DocsPanel.for_screen("schedule-screen")
yield Footer() yield Footer()
def on_mount(self) -> None: def on_mount(self) -> None:
@@ -169,6 +171,36 @@ class ScheduleScreen(Screen):
rc, stdout, stderr = await run_cli("schedule", "remove") 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") 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: def _delete_schedule(self, name: str) -> None:
conf = CONFIG_DIR / "schedules.d" / f"{name}.conf" conf = CONFIG_DIR / "schedules.d" / f"{name}.conf"

View File

@@ -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.config import list_conf_dir, parse_conf, write_conf, CONFIG_DIR
from tui.models import Schedule from tui.models import Schedule
from tui.widgets import DocsPanel
_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}$')
@@ -45,76 +46,78 @@ class ScheduleEditScreen(Screen):
data = parse_conf(CONFIG_DIR / "schedules.d" / f"{self._edit_name}.conf") data = parse_conf(CONFIG_DIR / "schedules.d" / f"{self._edit_name}.conf")
sched = Schedule.from_conf(self._edit_name, data) sched = Schedule.from_conf(self._edit_name, data)
with Vertical(id="schedule-edit"): with Horizontal(classes="screen-with-docs"):
yield Static(title, id="screen-title") with Vertical(id="schedule-edit"):
if self._is_new: yield Static(title, id="screen-title")
yield Static("Name:") if self._is_new:
yield Input(value="", placeholder="Schedule name", id="sched-name") yield Static("Name:")
yield Static("Type:") yield Input(value="", placeholder="Schedule name", id="sched-name")
yield Select(SCHEDULE_TYPES, id="sched-type", value=sched.schedule) yield Static("Type:")
yield Static("Schedule Hours:", classes="sched-hourly-field") yield Select(SCHEDULE_TYPES, id="sched-type", value=sched.schedule)
yield Select( yield Static("Schedule Hours:", classes="sched-hourly-field")
HOURLY_INTERVALS, yield Select(
id="sched-interval", HOURLY_INTERVALS,
value=sched.day if sched.schedule == "hourly" and sched.day else "1", id="sched-interval",
classes="sched-hourly-field", 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( yield Static("Time (HH:MM):", classes="sched-time-field")
id="sched-time", yield Input(
value=sched.time, id="sched-time",
placeholder="02:00", value=sched.time,
classes="sched-time-field", placeholder="02:00",
) classes="sched-time-field",
yield Static("Schedule Days:", classes="sched-daily-days-field") )
yield SelectionList[str]( yield Static("Schedule Days:", classes="sched-daily-days-field")
("Sunday", "0"), yield SelectionList[str](
("Monday", "1"), ("Sunday", "0"),
("Tuesday", "2"), ("Monday", "1"),
("Wednesday", "3"), ("Tuesday", "2"),
("Thursday", "4"), ("Wednesday", "3"),
("Friday", "5"), ("Thursday", "4"),
("Saturday", "6"), ("Friday", "5"),
id="sched-daily-days", ("Saturday", "6"),
classes="sched-daily-days-field", id="sched-daily-days",
) classes="sched-daily-days-field",
yield Static("Schedule Day:", classes="sched-weekly-day-field") )
yield Select( yield Static("Schedule Day:", classes="sched-weekly-day-field")
[("Sunday", "0"), ("Monday", "1"), ("Tuesday", "2"), ("Wednesday", "3"), yield Select(
("Thursday", "4"), ("Friday", "5"), ("Saturday", "6")], [("Sunday", "0"), ("Monday", "1"), ("Tuesday", "2"), ("Wednesday", "3"),
id="sched-weekly-day", ("Thursday", "4"), ("Friday", "5"), ("Saturday", "6")],
value=sched.day if sched.schedule == "weekly" and sched.day else "0", id="sched-weekly-day",
classes="sched-weekly-day-field", 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( yield Static("Schedule Day:", classes="sched-monthly-field")
[("1st of the month", "1"), ("7th of the month", "7"), yield Select(
("14th of the month", "14"), ("21st of the month", "21"), [("1st of the month", "1"), ("7th of the month", "7"),
("28th of the month", "28")], ("14th of the month", "14"), ("21st of the month", "21"),
id="sched-monthly-day", ("28th of the month", "28")],
value=sched.day if sched.schedule == "monthly" and sched.day else "1", id="sched-monthly-day",
classes="sched-monthly-field", 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( yield Static("Custom cron (5 fields):", classes="sched-cron-field")
id="sched-cron", yield Input(
value=sched.cron, id="sched-cron",
placeholder="0 2 * * *", value=sched.cron,
classes="sched-cron-field", placeholder="0 2 * * *",
) classes="sched-cron-field",
yield Static("Targets (empty=all):") )
yield SelectionList[str]( yield Static("Targets (empty=all):")
*self._build_target_choices(), yield SelectionList[str](
id="sched-targets", *self._build_target_choices(),
) id="sched-targets",
yield Static("Remotes (empty=all):") )
yield SelectionList[str]( yield Static("Remotes (empty=all):")
*self._build_remote_choices(), yield SelectionList[str](
id="sched-remotes", *self._build_remote_choices(),
) id="sched-remotes",
with Horizontal(id="sched-edit-buttons"): )
yield Button("Save", variant="primary", id="btn-save") with Horizontal(id="sched-edit-buttons"):
yield Button("Cancel", id="btn-cancel") yield Button("Save", variant="primary", id="btn-save")
yield Button("Cancel", id="btn-cancel")
yield DocsPanel.for_screen("schedule-edit")
yield Footer() yield Footer()
def _build_target_choices(self) -> list[tuple[str, str]]: def _build_target_choices(self) -> list[tuple[str, str]]:

View File

@@ -5,6 +5,7 @@ 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 AppSettings from tui.models import AppSettings
from tui.widgets import DocsPanel
class SettingsScreen(Screen): class SettingsScreen(Screen):
@@ -15,62 +16,64 @@ class SettingsScreen(Screen):
yield Header(show_clock=True) yield Header(show_clock=True)
conf = parse_conf(CONFIG_DIR / "gniza.conf") conf = parse_conf(CONFIG_DIR / "gniza.conf")
settings = AppSettings.from_conf(conf) settings = AppSettings.from_conf(conf)
with Vertical(id="settings-screen"): with Horizontal(classes="screen-with-docs"):
yield Static("Settings", id="screen-title") with Vertical(id="settings-screen"):
yield Static("Log Level:") yield Static("Settings", id="screen-title")
yield Select( yield Static("Log Level:")
[("Debug", "debug"), ("Info", "info"), ("Warning", "warn"), ("Error", "error")], yield Select(
id="set-loglevel", [("Debug", "debug"), ("Info", "info"), ("Warning", "warn"), ("Error", "error")],
value=settings.log_level.lower(), id="set-loglevel",
) value=settings.log_level.lower(),
yield Static("Log Retention (days):") )
yield Input(value=settings.log_retain, id="set-logretain") yield Static("Log Retention (days):")
yield Static("Default Retention Count:") yield Input(value=settings.log_retain, id="set-logretain")
yield Input(value=settings.retention_count, id="set-retention") yield Static("Default Retention Count:")
yield Static("Default Bandwidth Limit (KB/s, 0=unlimited):") yield Input(value=settings.retention_count, id="set-retention")
yield Input(value=settings.bwlimit, id="set-bwlimit") yield Static("Default Bandwidth Limit (KB/s, 0=unlimited):")
yield Static("Disk Usage Threshold (%, 0=disable):") yield Input(value=settings.bwlimit, id="set-bwlimit")
yield Input(value=settings.disk_usage_threshold, id="set-diskthreshold") yield Static("Disk Usage Threshold (%, 0=disable):")
yield Static("Notification Email:") yield Input(value=settings.disk_usage_threshold, id="set-diskthreshold")
yield Input(value=settings.notify_email, id="set-email") yield Static("Notification Email:")
yield Static("Notify On:") yield Input(value=settings.notify_email, id="set-email")
yield Select( yield Static("Notify On:")
[("Always", "always"), ("Failure only", "failure"), ("Never", "never")], yield Select(
id="set-notifyon", [("Always", "always"), ("Failure only", "failure"), ("Never", "never")],
value=settings.notify_on, id="set-notifyon",
) value=settings.notify_on,
yield Static("SMTP Host:") )
yield Input(value=settings.smtp_host, id="set-smtphost") yield Static("SMTP Host:")
yield Static("SMTP Port:") yield Input(value=settings.smtp_host, id="set-smtphost")
yield Input(value=settings.smtp_port, id="set-smtpport") yield Static("SMTP Port:")
yield Static("SMTP User:") yield Input(value=settings.smtp_port, id="set-smtpport")
yield Input(value=settings.smtp_user, id="set-smtpuser") yield Static("SMTP User:")
yield Static("SMTP Password:") yield Input(value=settings.smtp_user, id="set-smtpuser")
yield Input(value=settings.smtp_password, password=True, id="set-smtppass") yield Static("SMTP Password:")
yield Static("SMTP From:") yield Input(value=settings.smtp_password, password=True, id="set-smtppass")
yield Input(value=settings.smtp_from, id="set-smtpfrom") yield Static("SMTP From:")
yield Static("SMTP Security:") yield Input(value=settings.smtp_from, id="set-smtpfrom")
yield Select( yield Static("SMTP Security:")
[("TLS", "tls"), ("SSL", "ssl"), ("None", "none")], yield Select(
id="set-smtpsec", [("TLS", "tls"), ("SSL", "ssl"), ("None", "none")],
value=settings.smtp_security, id="set-smtpsec",
) value=settings.smtp_security,
yield Static("SSH Timeout:") )
yield Input(value=settings.ssh_timeout, id="set-sshtimeout") yield Static("SSH Timeout:")
yield Static("SSH Retries:") yield Input(value=settings.ssh_timeout, id="set-sshtimeout")
yield Input(value=settings.ssh_retries, id="set-sshretries") yield Static("SSH Retries:")
yield Static("Extra rsync options:") yield Input(value=settings.ssh_retries, id="set-sshretries")
yield Input(value=settings.rsync_extra_opts, id="set-rsyncopts") yield Static("Extra rsync options:")
yield Static("Web Dashboard", classes="section-label") yield Input(value=settings.rsync_extra_opts, id="set-rsyncopts")
yield Static("Port:") yield Static("Web Dashboard", classes="section-label")
yield Input(value=settings.web_port, id="set-web-port") yield Static("Port:")
yield Static("Host:") yield Input(value=settings.web_port, id="set-web-port")
yield Input(value=settings.web_host, id="set-web-host") yield Static("Host:")
yield Static("API Key:") yield Input(value=settings.web_host, id="set-web-host")
yield Input(value=settings.web_api_key, password=True, id="set-web-key") yield Static("API Key:")
with Horizontal(id="set-buttons"): yield Input(value=settings.web_api_key, password=True, id="set-web-key")
yield Button("Save", variant="primary", id="btn-save") with Horizontal(id="set-buttons"):
yield Button("Back", id="btn-back") yield Button("Save", variant="primary", id="btn-save")
yield Button("Back", id="btn-back")
yield DocsPanel.for_screen("settings-screen")
yield Footer() yield Footer()
def on_button_pressed(self, event: Button.Pressed) -> None: def on_button_pressed(self, event: Button.Pressed) -> None:

View File

@@ -9,7 +9,7 @@ from textual import work
from tui.config import list_conf_dir from tui.config import list_conf_dir
from tui.backend import run_cli from tui.backend import run_cli
from tui.widgets import SnapshotBrowser from tui.widgets import SnapshotBrowser, DocsPanel
def _format_snapshot_ts(ts: str) -> str: def _format_snapshot_ts(ts: str) -> str:
@@ -29,20 +29,22 @@ class SnapshotsScreen(Screen):
yield Header(show_clock=True) yield Header(show_clock=True)
targets = list_conf_dir("targets.d") targets = list_conf_dir("targets.d")
remotes = list_conf_dir("remotes.d") remotes = list_conf_dir("remotes.d")
with Vertical(id="snapshots-screen"): with Horizontal(classes="screen-with-docs"):
yield Static("Snapshots Browser", id="screen-title") with Vertical(id="snapshots-screen"):
if not targets or not remotes: yield Static("Snapshots Browser", id="screen-title")
yield Static("Targets and remotes must be configured to browse snapshots.") if not targets or not remotes:
else: yield Static("Targets and remotes must be configured to browse snapshots.")
yield Static("Target:") else:
yield Select([(t, t) for t in targets], id="snap-target", prompt="Select target") yield Static("Target:")
yield Static("Remote:") yield Select([(t, t) for t in targets], id="snap-target", prompt="Select target")
yield Select([(r, r) for r in remotes], id="snap-remote", prompt="Select remote") yield Static("Remote:")
yield Button("Load Snapshots", id="btn-load", variant="primary") yield Select([(r, r) for r in remotes], id="snap-remote", prompt="Select remote")
yield DataTable(id="snap-table") yield Button("Load Snapshots", id="btn-load", variant="primary")
with Horizontal(id="snapshots-buttons"): yield DataTable(id="snap-table")
yield Button("Browse Files", id="btn-browse") with Horizontal(id="snapshots-buttons"):
yield Button("Back", id="btn-back") yield Button("Browse Files", id="btn-browse")
yield Button("Back", id="btn-back")
yield DocsPanel.for_screen("snapshots-screen")
yield Footer() yield Footer()
def on_mount(self) -> None: def on_mount(self) -> None:

View File

@@ -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.config import parse_conf, write_conf, CONFIG_DIR, list_conf_dir
from tui.models import Target 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}$') _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") data = parse_conf(CONFIG_DIR / "targets.d" / f"{self._edit_name}.conf")
target = Target.from_conf(self._edit_name, data) target = Target.from_conf(self._edit_name, data)
with Vertical(id="target-edit"): with Horizontal(classes="screen-with-docs"):
yield Static(title, id="screen-title") with Vertical(id="target-edit"):
if self._is_new: yield Static(title, id="screen-title")
yield Static("Name:") if self._is_new:
yield Input(value="", placeholder="Target name", id="te-name") yield Static("Name:")
yield Static("Folders (comma-separated):") yield Input(value="", placeholder="Target name", id="te-name")
yield Input(value=target.folders, placeholder="/path1,/path2", id="te-folders") yield Static("Folders (comma-separated):")
yield Button("Browse...", id="btn-browse") yield Input(value=target.folders, placeholder="/path1,/path2", id="te-folders")
yield Static("Include patterns:") yield Button("Browse...", id="btn-browse")
yield Input(value=target.include, placeholder="*.conf,docs/", id="te-include") yield Static("Include patterns:")
yield Static("Exclude patterns:") yield Input(value=target.include, placeholder="*.conf,docs/", id="te-include")
yield Input(value=target.exclude, placeholder="*.tmp,*.log", id="te-exclude") yield Static("Exclude patterns:")
yield Static("Remote override:") yield Input(value=target.exclude, placeholder="*.tmp,*.log", id="te-exclude")
yield Input(value=target.remote, placeholder="Leave empty for default", id="te-remote") yield Static("Remote override:")
yield Static("Retention override:") yield Input(value=target.remote, placeholder="Leave empty for default", id="te-remote")
yield Input(value=target.retention, placeholder="Leave empty for default", id="te-retention") yield Static("Retention override:")
yield Static("Pre-backup hook:") yield Input(value=target.retention, placeholder="Leave empty for default", id="te-retention")
yield Input(value=target.pre_hook, placeholder="Command to run before backup", id="te-prehook") yield Static("Pre-backup hook:")
yield Static("Post-backup hook:") yield Input(value=target.pre_hook, placeholder="Command to run before backup", id="te-prehook")
yield Input(value=target.post_hook, placeholder="Command to run after backup", id="te-posthook") yield Static("Post-backup hook:")
yield Static("Enabled:") yield Input(value=target.post_hook, placeholder="Command to run after backup", id="te-posthook")
yield Select( yield Static("Enabled:")
[("Yes", "yes"), ("No", "no")], yield Select(
value="yes" if target.enabled == "yes" else "no", [("Yes", "yes"), ("No", "no")],
id="te-enabled", value="yes" if target.enabled == "yes" else "no",
) id="te-enabled",
yield Static("--- MySQL Backup ---", classes="section-label") )
yield Static("MySQL Enabled:") yield Static("--- MySQL Backup ---", classes="section-label")
yield Select( yield Static("MySQL Enabled:")
[("No", "no"), ("Yes", "yes")], yield Select(
value=target.mysql_enabled, [("No", "no"), ("Yes", "yes")],
id="te-mysql-enabled", value=target.mysql_enabled,
) id="te-mysql-enabled",
yield Static("MySQL Mode:", classes="mysql-field") )
yield Select( yield Static("MySQL Mode:", classes="mysql-field")
[("All databases", "all"), ("Select databases", "select")], yield Select(
value=target.mysql_mode, [("All databases", "all"), ("Select databases", "select")],
id="te-mysql-mode", value=target.mysql_mode,
classes="mysql-field", 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("Databases (comma-separated):", classes="mysql-field mysql-select-field")
yield Static("Exclude databases (comma-separated):", classes="mysql-field mysql-all-field") yield Input(value=target.mysql_databases, placeholder="db1,db2", id="te-mysql-databases", classes="mysql-field mysql-select-field")
yield Input(value=target.mysql_exclude, placeholder="test_db,dev_db", id="te-mysql-exclude", classes="mysql-field mysql-all-field") yield Static("Exclude databases (comma-separated):", classes="mysql-field mysql-all-field")
yield Static("MySQL User:", classes="mysql-field") yield Input(value=target.mysql_exclude, placeholder="test_db,dev_db", id="te-mysql-exclude", classes="mysql-field mysql-all-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 User:", classes="mysql-field")
yield Static("MySQL Password:", 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 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 Password:", classes="mysql-field")
yield Static("MySQL Host:", 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 Input(value=target.mysql_host, placeholder="localhost", id="te-mysql-host", classes="mysql-field") yield Static("MySQL Host:", classes="mysql-field")
yield Static("MySQL Port:", classes="mysql-field") yield Input(value=target.mysql_host, placeholder="localhost", id="te-mysql-host", classes="mysql-field")
yield Input(value=target.mysql_port, placeholder="3306", id="te-mysql-port", classes="mysql-field") yield Static("MySQL Port:", classes="mysql-field")
yield Static("MySQL Extra Options:", classes="mysql-field") yield Input(value=target.mysql_port, placeholder="3306", id="te-mysql-port", classes="mysql-field")
yield Input(value=target.mysql_extra_opts, placeholder="--single-transaction --routines --triggers", id="te-mysql-extra-opts", classes="mysql-field") yield Static("MySQL Extra Options:", classes="mysql-field")
with Horizontal(id="te-buttons"): yield Input(value=target.mysql_extra_opts, placeholder="--single-transaction --routines --triggers", id="te-mysql-extra-opts", classes="mysql-field")
yield Button("Save", variant="primary", id="btn-save") with Horizontal(id="te-buttons"):
yield Button("Cancel", id="btn-cancel") yield Button("Save", variant="primary", id="btn-save")
yield Button("Cancel", id="btn-cancel")
yield DocsPanel.for_screen("target-edit")
yield Footer() yield Footer()
def on_mount(self) -> None: def on_mount(self) -> None:

View File

@@ -4,7 +4,7 @@ from textual.widgets import Header, Footer, Static, Button, DataTable
from textual.containers import Vertical, Horizontal from textual.containers import Vertical, Horizontal
from tui.config import list_conf_dir, parse_conf, CONFIG_DIR 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): class TargetsScreen(Screen):
@@ -13,14 +13,16 @@ class TargetsScreen(Screen):
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
yield Header(show_clock=True) yield Header(show_clock=True)
with Vertical(id="targets-screen"): with Horizontal(classes="screen-with-docs"):
yield Static("Targets", id="screen-title") with Vertical(id="targets-screen"):
yield DataTable(id="targets-table") yield Static("Targets", id="screen-title")
with Horizontal(id="targets-buttons"): yield DataTable(id="targets-table")
yield Button("Add", variant="primary", id="btn-add") with Horizontal(id="targets-buttons"):
yield Button("Edit", id="btn-edit") yield Button("Add", variant="primary", id="btn-add")
yield Button("Delete", variant="error", id="btn-delete") yield Button("Edit", id="btn-edit")
yield Button("Back", id="btn-back") yield Button("Delete", variant="error", id="btn-delete")
yield Button("Back", id="btn-back")
yield DocsPanel.for_screen("targets-screen")
yield Footer() yield Footer()
def on_mount(self) -> None: def on_mount(self) -> None:

View File

@@ -3,3 +3,4 @@ from tui.widgets.file_picker import FilePicker
from tui.widgets.confirm_dialog import ConfirmDialog from tui.widgets.confirm_dialog import ConfirmDialog
from tui.widgets.operation_log import OperationLog from tui.widgets.operation_log import OperationLog
from tui.widgets.snapshot_browser import SnapshotBrowser from tui.widgets.snapshot_browser import SnapshotBrowser
from tui.widgets.docs_panel import DocsPanel

28
tui/widgets/docs_panel.py Normal file
View File

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