Files
gniza4linux/tui/screens/target_edit.py
shuki 09b7dd184e Replace all Target/Remote terminology with Source/Destination and add search to file browsers
- Rename all user-facing strings: Target→Source, Remote→Destination across TUI, docs, web dashboard, bash scripts, config examples, and README
- Add go-to-path search input to both FolderPicker and RemoteFolderPicker

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 04:31:35 +02:00

284 lines
16 KiB
Python

import re
from textual.app import ComposeResult
from textual.screen import Screen
from textual.widgets import Header, Footer, Static, Button, Input, Select
from tui.widgets.header import GnizaHeader as Header # noqa: F811
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, RemoteFolderPicker, DocsPanel
_NAME_RE = re.compile(r'^[a-zA-Z][a-zA-Z0-9_-]{0,31}$')
class TargetEditScreen(Screen):
BINDINGS = [("escape", "go_back", "Back")]
def __init__(self, name: str = ""):
super().__init__()
self._edit_name = name
self._is_new = not name
def compose(self) -> ComposeResult:
yield Header(show_clock=True)
title = "Add Source" if self._is_new else f"Edit Source: {self._edit_name}"
target = Target()
if not self._is_new:
data = parse_conf(CONFIG_DIR / "targets.d" / f"{self._edit_name}.conf")
target = Target.from_conf(self._edit_name, data)
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("--- Source ---", classes="section-label")
yield Static("Source Type:")
yield Select(
[("Local", "local"), ("SSH", "ssh"), ("S3", "s3"), ("Google Drive", "gdrive")],
value=target.source_type,
id="te-source-type",
)
yield Static("Source Host:", classes="source-field source-ssh-field")
yield Input(value=target.source_host, placeholder="hostname or IP", id="te-source-host", classes="source-field source-ssh-field")
yield Static("Source Port:", classes="source-field source-ssh-field")
yield Input(value=target.source_port, placeholder="22", id="te-source-port", classes="source-field source-ssh-field")
yield Static("Source User:", classes="source-field source-ssh-field")
yield Input(value=target.source_user, placeholder="root", id="te-source-user", classes="source-field source-ssh-field")
yield Static("Auth Method:", classes="source-field source-ssh-field")
yield Select(
[("SSH Key", "key"), ("Password", "password")],
value=target.source_auth_method,
id="te-source-auth-method",
classes="source-field source-ssh-field",
)
yield Static("SSH Key Path:", classes="source-field source-ssh-field source-key-field")
yield Input(value=target.source_key, placeholder="/root/.ssh/id_rsa", id="te-source-key", classes="source-field source-ssh-field source-key-field")
yield Static("Password:", classes="source-field source-ssh-field source-password-field")
yield Input(value=target.source_password, placeholder="SSH password", password=True, id="te-source-password", classes="source-field source-ssh-field source-password-field")
yield Static("S3 Bucket:", classes="source-field source-s3-field")
yield Input(value=target.source_s3_bucket, placeholder="my-bucket", id="te-source-s3-bucket", classes="source-field source-s3-field")
yield Static("S3 Region:", classes="source-field source-s3-field")
yield Input(value=target.source_s3_region, placeholder="us-east-1", id="te-source-s3-region", classes="source-field source-s3-field")
yield Static("S3 Endpoint:", classes="source-field source-s3-field")
yield Input(value=target.source_s3_endpoint, placeholder="https://s3.amazonaws.com", id="te-source-s3-endpoint", classes="source-field source-s3-field")
yield Static("S3 Access Key:", classes="source-field source-s3-field")
yield Input(value=target.source_s3_access_key_id, placeholder="AKIA...", id="te-source-s3-access-key", classes="source-field source-s3-field")
yield Static("S3 Secret Key:", classes="source-field source-s3-field")
yield Input(value=target.source_s3_secret_access_key, placeholder="secret", password=True, id="te-source-s3-secret-key", classes="source-field source-s3-field")
yield Static("Service Account File:", classes="source-field source-gdrive-field")
yield Input(value=target.source_gdrive_sa_file, placeholder="/path/to/sa.json", id="te-source-gdrive-sa-file", classes="source-field source-gdrive-field")
yield Static("Root Folder ID:", classes="source-field source-gdrive-field")
yield Input(value=target.source_gdrive_root_folder_id, placeholder="folder ID", id="te-source-gdrive-root-folder-id", classes="source-field source-gdrive-field")
yield Static("Folders (comma-separated):")
with Horizontal(id="te-folders-row"):
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("Destination 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:
self._update_mysql_visibility()
self._update_source_visibility()
def on_select_changed(self, event: Select.Changed) -> None:
if event.select.id in ("te-mysql-enabled", "te-mysql-mode"):
self._update_mysql_visibility()
elif event.select.id in ("te-source-type", "te-source-auth-method"):
self._update_source_visibility()
def _update_mysql_visibility(self) -> None:
enabled = str(self.query_one("#te-mysql-enabled", Select).value)
is_enabled = enabled == "yes"
for w in self.query(".mysql-field"):
w.display = is_enabled
if is_enabled:
mode = str(self.query_one("#te-mysql-mode", Select).value)
for w in self.query(".mysql-select-field"):
w.display = mode == "select"
for w in self.query(".mysql-all-field"):
w.display = mode == "all"
def _update_source_visibility(self) -> None:
source_type = str(self.query_one("#te-source-type", Select).value)
is_remote = source_type != "local"
# Hide all source fields first
for w in self.query(".source-field"):
w.display = False
# Show fields for selected source type
if source_type == "ssh":
for w in self.query(".source-ssh-field"):
w.display = True
# Toggle key/password based on auth method
auth = str(self.query_one("#te-source-auth-method", Select).value)
for w in self.query(".source-key-field"):
w.display = auth == "key"
for w in self.query(".source-password-field"):
w.display = auth == "password"
elif source_type == "s3":
for w in self.query(".source-s3-field"):
w.display = True
elif source_type == "gdrive":
for w in self.query(".source-gdrive-field"):
w.display = True
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "btn-cancel":
self.dismiss(None)
elif event.button.id == "btn-browse":
source_type = str(self.query_one("#te-source-type", Select).value)
if source_type == "ssh":
host = self.query_one("#te-source-host", Input).value.strip()
if not host:
self.notify("Enter Source Host first", severity="error")
return
self.app.push_screen(
RemoteFolderPicker(
host=host,
user=self.query_one("#te-source-user", Input).value.strip() or "root",
port=self.query_one("#te-source-port", Input).value.strip() or "22",
auth_method=str(self.query_one("#te-source-auth-method", Select).value),
key=self.query_one("#te-source-key", Input).value.strip(),
password=self.query_one("#te-source-password", Input).value.strip(),
),
callback=self._folder_selected,
)
else:
self.app.push_screen(
FolderPicker("Select folder to back up"),
callback=self._folder_selected,
)
elif event.button.id == "btn-save":
self._save()
def _folder_selected(self, path: str | None) -> None:
if path:
folders_input = self.query_one("#te-folders", Input)
current = folders_input.value.strip()
if current:
existing = [f.strip() for f in current.split(",")]
if path not in existing:
folders_input.value = current + "," + path
else:
folders_input.value = path
def _save(self) -> None:
if self._is_new:
name = self.query_one("#te-name", Input).value.strip()
if not name:
self.notify("Name is required", severity="error")
return
if not _NAME_RE.match(name):
self.notify("Invalid name. Use letters, digits, _ - (max 32 chars, start with letter).", severity="error")
return
conf = CONFIG_DIR / "targets.d" / f"{name}.conf"
if conf.exists():
self.notify(f"Target '{name}' already exists.", severity="error")
return
else:
name = self._edit_name
folders = self.query_one("#te-folders", Input).value.strip()
mysql_enabled = str(self.query_one("#te-mysql-enabled", Select).value)
source_type = str(self.query_one("#te-source-type", Select).value)
if not folders and mysql_enabled != "yes" and source_type == "local":
self.notify("At least one folder or MySQL backup is required", severity="error")
return
if source_type != "local" and not folders:
self.notify("At least one remote path is required", severity="error")
return
target = Target(
name=name,
folders=folders,
exclude=self.query_one("#te-exclude", Input).value.strip(),
include=self.query_one("#te-include", Input).value.strip(),
remote=self.query_one("#te-remote", Input).value.strip(),
retention=self.query_one("#te-retention", Input).value.strip(),
pre_hook=self.query_one("#te-prehook", Input).value.strip(),
post_hook=self.query_one("#te-posthook", Input).value.strip(),
enabled=str(self.query_one("#te-enabled", Select).value),
source_type=source_type,
source_host=self.query_one("#te-source-host", Input).value.strip(),
source_port=self.query_one("#te-source-port", Input).value.strip(),
source_user=self.query_one("#te-source-user", Input).value.strip(),
source_auth_method=str(self.query_one("#te-source-auth-method", Select).value),
source_key=self.query_one("#te-source-key", Input).value.strip(),
source_password=self.query_one("#te-source-password", Input).value.strip(),
source_s3_bucket=self.query_one("#te-source-s3-bucket", Input).value.strip(),
source_s3_region=self.query_one("#te-source-s3-region", Input).value.strip(),
source_s3_endpoint=self.query_one("#te-source-s3-endpoint", Input).value.strip(),
source_s3_access_key_id=self.query_one("#te-source-s3-access-key", Input).value.strip(),
source_s3_secret_access_key=self.query_one("#te-source-s3-secret-key", Input).value.strip(),
source_gdrive_sa_file=self.query_one("#te-source-gdrive-sa-file", Input).value.strip(),
source_gdrive_root_folder_id=self.query_one("#te-source-gdrive-root-folder-id", Input).value.strip(),
mysql_enabled=mysql_enabled,
mysql_mode=str(self.query_one("#te-mysql-mode", Select).value),
mysql_databases=self.query_one("#te-mysql-databases", Input).value.strip(),
mysql_exclude=self.query_one("#te-mysql-exclude", Input).value.strip(),
mysql_user=self.query_one("#te-mysql-user", Input).value.strip(),
mysql_password=self.query_one("#te-mysql-password", Input).value.strip(),
mysql_host=self.query_one("#te-mysql-host", Input).value.strip(),
mysql_port=self.query_one("#te-mysql-port", Input).value.strip(),
mysql_extra_opts=self.query_one("#te-mysql-extra-opts", Input).value.strip(),
)
conf = CONFIG_DIR / "targets.d" / f"{name}.conf"
write_conf(conf, target.to_conf())
self.notify(f"Target '{name}' saved.")
self.dismiss(name)
def action_go_back(self) -> None:
self.dismiss(None)