Add Python Textual TUI replacing gum-based bash TUI
New tui/ package with 14 screens (main menu, backup, restore, targets, remotes, snapshots, verify, retention, schedule, logs, settings, wizard), 3 custom widgets (folder picker, confirm dialog, operation log), async backend wrapper, pure-Python config parser, and TCSS theme. bin/gniza now launches Textual TUI when available, falls back to gum. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -2,3 +2,5 @@
|
|||||||
*.swo
|
*.swo
|
||||||
*~
|
*~
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
|||||||
@@ -436,8 +436,11 @@ if [[ -n "$SUBCOMMAND" ]]; then
|
|||||||
run_cli
|
run_cli
|
||||||
elif [[ "$FORCE_CLI" == "true" ]]; then
|
elif [[ "$FORCE_CLI" == "true" ]]; then
|
||||||
run_cli
|
run_cli
|
||||||
|
elif python3 -c "import textual" 2>/dev/null && [[ -t 1 ]]; then
|
||||||
|
# Python Textual TUI mode
|
||||||
|
PYTHONPATH="$GNIZA_DIR:${PYTHONPATH:-}" exec python3 -m tui "$@"
|
||||||
elif command -v gum &>/dev/null && [[ -t 1 ]]; then
|
elif command -v gum &>/dev/null && [[ -t 1 ]]; then
|
||||||
# TUI mode
|
# Legacy gum TUI mode
|
||||||
show_logo
|
show_logo
|
||||||
if ! has_remotes || ! has_targets; then
|
if ! has_remotes || ! has_targets; then
|
||||||
ui_first_run_wizard
|
ui_first_run_wizard
|
||||||
|
|||||||
0
tui/__init__.py
Normal file
0
tui/__init__.py
Normal file
10
tui/__main__.py
Normal file
10
tui/__main__.py
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
from tui.app import GnizaApp
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
app = GnizaApp()
|
||||||
|
app.run()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
46
tui/app.py
Normal file
46
tui/app.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
from textual.app import App
|
||||||
|
|
||||||
|
from tui.config import has_remotes, has_targets
|
||||||
|
from tui.screens.main_menu import MainMenuScreen
|
||||||
|
from tui.screens.backup import BackupScreen
|
||||||
|
from tui.screens.restore import RestoreScreen
|
||||||
|
from tui.screens.targets import TargetsScreen
|
||||||
|
from tui.screens.target_edit import TargetEditScreen
|
||||||
|
from tui.screens.remotes import RemotesScreen
|
||||||
|
from tui.screens.remote_edit import RemoteEditScreen
|
||||||
|
from tui.screens.snapshots import SnapshotsScreen
|
||||||
|
from tui.screens.verify import VerifyScreen
|
||||||
|
from tui.screens.retention import RetentionScreen
|
||||||
|
from tui.screens.schedule import ScheduleScreen
|
||||||
|
from tui.screens.logs import LogsScreen
|
||||||
|
from tui.screens.settings import SettingsScreen
|
||||||
|
from tui.screens.wizard import WizardScreen
|
||||||
|
|
||||||
|
|
||||||
|
class GnizaApp(App):
|
||||||
|
|
||||||
|
TITLE = "gniza - Linux Backup Manager"
|
||||||
|
CSS_PATH = "gniza.tcss"
|
||||||
|
|
||||||
|
SCREENS = {
|
||||||
|
"main": MainMenuScreen,
|
||||||
|
"backup": BackupScreen,
|
||||||
|
"restore": RestoreScreen,
|
||||||
|
"targets": TargetsScreen,
|
||||||
|
"target_edit": TargetEditScreen,
|
||||||
|
"remotes": RemotesScreen,
|
||||||
|
"remote_edit": RemoteEditScreen,
|
||||||
|
"snapshots": SnapshotsScreen,
|
||||||
|
"verify": VerifyScreen,
|
||||||
|
"retention": RetentionScreen,
|
||||||
|
"schedule": ScheduleScreen,
|
||||||
|
"logs": LogsScreen,
|
||||||
|
"settings": SettingsScreen,
|
||||||
|
"wizard": WizardScreen,
|
||||||
|
}
|
||||||
|
|
||||||
|
def on_mount(self) -> None:
|
||||||
|
if not has_remotes() or not has_targets():
|
||||||
|
self.push_screen("wizard")
|
||||||
|
else:
|
||||||
|
self.push_screen("main")
|
||||||
40
tui/backend.py
Normal file
40
tui/backend.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def _gniza_bin() -> str:
|
||||||
|
gniza_dir = os.environ.get("GNIZA_DIR", "")
|
||||||
|
if gniza_dir:
|
||||||
|
return str(Path(gniza_dir) / "bin" / "gniza")
|
||||||
|
here = Path(__file__).resolve().parent.parent / "bin" / "gniza"
|
||||||
|
if here.is_file():
|
||||||
|
return str(here)
|
||||||
|
return "gniza"
|
||||||
|
|
||||||
|
|
||||||
|
async def run_cli(*args: str) -> tuple[int, str, str]:
|
||||||
|
cmd = [_gniza_bin(), "--cli"] + list(args)
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
*cmd,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
)
|
||||||
|
stdout, stderr = await proc.communicate()
|
||||||
|
return proc.returncode or 0, stdout.decode(), stderr.decode()
|
||||||
|
|
||||||
|
|
||||||
|
async def stream_cli(callback, *args: str) -> int:
|
||||||
|
cmd = [_gniza_bin(), "--cli"] + list(args)
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
*cmd,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.STDOUT,
|
||||||
|
)
|
||||||
|
while True:
|
||||||
|
line = await proc.stdout.readline()
|
||||||
|
if not line:
|
||||||
|
break
|
||||||
|
callback(line.decode().rstrip("\n"))
|
||||||
|
await proc.wait()
|
||||||
|
return proc.returncode or 0
|
||||||
92
tui/config.py
Normal file
92
tui/config.py
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import os
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
_KV_RE = re.compile(r'^([A-Z_][A-Z_0-9]*)=(.*)')
|
||||||
|
_QUOTED_RE = re.compile(r'^"(.*)"$|^\'(.*)\'$')
|
||||||
|
|
||||||
|
|
||||||
|
def _get_config_dir() -> Path:
|
||||||
|
if os.geteuid() == 0:
|
||||||
|
return Path("/etc/gniza")
|
||||||
|
xdg = os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config"))
|
||||||
|
return Path(xdg) / "gniza"
|
||||||
|
|
||||||
|
|
||||||
|
def _get_log_dir() -> Path:
|
||||||
|
if os.geteuid() == 0:
|
||||||
|
return Path("/var/log/gniza")
|
||||||
|
xdg = os.environ.get("XDG_STATE_HOME", os.path.expanduser("~/.local/state"))
|
||||||
|
return Path(xdg) / "gniza" / "log"
|
||||||
|
|
||||||
|
|
||||||
|
CONFIG_DIR = _get_config_dir()
|
||||||
|
LOG_DIR = _get_log_dir()
|
||||||
|
|
||||||
|
|
||||||
|
def parse_conf(filepath: Path) -> dict[str, str]:
|
||||||
|
data = {}
|
||||||
|
if not filepath.is_file():
|
||||||
|
return data
|
||||||
|
for line in filepath.read_text().splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if not line or line.startswith("#"):
|
||||||
|
continue
|
||||||
|
m = _KV_RE.match(line)
|
||||||
|
if m:
|
||||||
|
key = m.group(1)
|
||||||
|
value = m.group(2)
|
||||||
|
qm = _QUOTED_RE.match(value)
|
||||||
|
if qm:
|
||||||
|
value = qm.group(1) if qm.group(1) is not None else qm.group(2)
|
||||||
|
data[key] = value
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_value(value: str) -> str:
|
||||||
|
return value.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "")
|
||||||
|
|
||||||
|
|
||||||
|
def write_conf(filepath: Path, data: dict[str, str]) -> None:
|
||||||
|
filepath.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
lines = []
|
||||||
|
for key, value in data.items():
|
||||||
|
lines.append(f'{key}="{_sanitize_value(value)}"')
|
||||||
|
filepath.write_text("\n".join(lines) + "\n")
|
||||||
|
filepath.chmod(0o600)
|
||||||
|
|
||||||
|
|
||||||
|
def update_conf_key(filepath: Path, key: str, value: str) -> None:
|
||||||
|
value = _sanitize_value(value)
|
||||||
|
filepath.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
if not filepath.is_file():
|
||||||
|
filepath.write_text(f'{key}="{value}"\n')
|
||||||
|
filepath.chmod(0o600)
|
||||||
|
return
|
||||||
|
lines = filepath.read_text().splitlines()
|
||||||
|
found = False
|
||||||
|
for i, line in enumerate(lines):
|
||||||
|
m = _KV_RE.match(line.strip())
|
||||||
|
if m and m.group(1) == key:
|
||||||
|
lines[i] = f'{key}="{value}"'
|
||||||
|
found = True
|
||||||
|
break
|
||||||
|
if not found:
|
||||||
|
lines.append(f'{key}="{value}"')
|
||||||
|
filepath.write_text("\n".join(lines) + "\n")
|
||||||
|
filepath.chmod(0o600)
|
||||||
|
|
||||||
|
|
||||||
|
def list_conf_dir(subdir: str) -> list[str]:
|
||||||
|
d = CONFIG_DIR / subdir
|
||||||
|
if not d.is_dir():
|
||||||
|
return []
|
||||||
|
return sorted(p.stem for p in d.glob("*.conf") if p.is_file())
|
||||||
|
|
||||||
|
|
||||||
|
def has_targets() -> bool:
|
||||||
|
return len(list_conf_dir("targets.d")) > 0
|
||||||
|
|
||||||
|
|
||||||
|
def has_remotes() -> bool:
|
||||||
|
return len(list_conf_dir("remotes.d")) > 0
|
||||||
197
tui/gniza.tcss
Normal file
197
tui/gniza.tcss
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
/* gniza TUI theme */
|
||||||
|
|
||||||
|
Screen {
|
||||||
|
background: $surface;
|
||||||
|
}
|
||||||
|
|
||||||
|
#screen-title {
|
||||||
|
text-style: bold;
|
||||||
|
color: #00cc00;
|
||||||
|
padding: 1 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main menu */
|
||||||
|
#main-menu {
|
||||||
|
width: 50;
|
||||||
|
padding: 1 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
#main-menu Button {
|
||||||
|
width: 100%;
|
||||||
|
margin: 0 0 1 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#logo {
|
||||||
|
text-align: center;
|
||||||
|
padding: 0 0 1 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Data tables */
|
||||||
|
DataTable {
|
||||||
|
height: 12;
|
||||||
|
margin: 1 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form screens */
|
||||||
|
#target-edit,
|
||||||
|
#remote-edit,
|
||||||
|
#settings-screen,
|
||||||
|
#schedule-screen,
|
||||||
|
#backup-screen,
|
||||||
|
#restore-screen,
|
||||||
|
#verify-screen,
|
||||||
|
#retention-screen,
|
||||||
|
#snapshots-screen,
|
||||||
|
#targets-screen,
|
||||||
|
#remotes-screen,
|
||||||
|
#logs-screen {
|
||||||
|
padding: 1 2;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
Input {
|
||||||
|
margin: 0 0 1 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
Select {
|
||||||
|
margin: 0 0 1 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button rows */
|
||||||
|
#backup-buttons,
|
||||||
|
#restore-buttons,
|
||||||
|
#verify-buttons,
|
||||||
|
#ret-buttons,
|
||||||
|
#targets-buttons,
|
||||||
|
#remotes-buttons,
|
||||||
|
#logs-buttons,
|
||||||
|
#sched-buttons,
|
||||||
|
#te-buttons,
|
||||||
|
#re-buttons,
|
||||||
|
#set-buttons {
|
||||||
|
height: auto;
|
||||||
|
margin: 1 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#backup-buttons Button,
|
||||||
|
#restore-buttons Button,
|
||||||
|
#verify-buttons Button,
|
||||||
|
#ret-buttons Button,
|
||||||
|
#targets-buttons Button,
|
||||||
|
#remotes-buttons Button,
|
||||||
|
#logs-buttons Button,
|
||||||
|
#sched-buttons Button,
|
||||||
|
#te-buttons Button,
|
||||||
|
#re-buttons Button,
|
||||||
|
#set-buttons Button {
|
||||||
|
margin: 0 1 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dialogs */
|
||||||
|
#confirm-dialog {
|
||||||
|
width: 60;
|
||||||
|
height: auto;
|
||||||
|
padding: 2;
|
||||||
|
background: $panel;
|
||||||
|
border: thick $accent;
|
||||||
|
}
|
||||||
|
|
||||||
|
#cd-title {
|
||||||
|
text-style: bold;
|
||||||
|
color: #00cc00;
|
||||||
|
margin: 0 0 1 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#cd-message {
|
||||||
|
margin: 0 0 1 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#cd-buttons {
|
||||||
|
height: auto;
|
||||||
|
align: center middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
#cd-buttons Button {
|
||||||
|
margin: 0 1 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Folder picker */
|
||||||
|
#folder-picker {
|
||||||
|
width: 70;
|
||||||
|
height: 30;
|
||||||
|
padding: 1;
|
||||||
|
background: $panel;
|
||||||
|
border: thick $accent;
|
||||||
|
}
|
||||||
|
|
||||||
|
#fp-title {
|
||||||
|
text-style: bold;
|
||||||
|
color: #00cc00;
|
||||||
|
margin: 0 0 1 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#fp-tree {
|
||||||
|
height: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
#fp-buttons {
|
||||||
|
height: auto;
|
||||||
|
margin: 1 0 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#fp-buttons Button {
|
||||||
|
margin: 0 1 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Operation log */
|
||||||
|
#op-log {
|
||||||
|
width: 80%;
|
||||||
|
height: 80%;
|
||||||
|
padding: 1;
|
||||||
|
background: $panel;
|
||||||
|
border: thick $accent;
|
||||||
|
}
|
||||||
|
|
||||||
|
#ol-title {
|
||||||
|
text-style: bold;
|
||||||
|
color: #00cc00;
|
||||||
|
margin: 0 0 1 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#ol-log {
|
||||||
|
height: 1fr;
|
||||||
|
border: round $accent;
|
||||||
|
}
|
||||||
|
|
||||||
|
#ol-close {
|
||||||
|
margin: 1 0 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Log viewer */
|
||||||
|
#log-viewer {
|
||||||
|
height: 1fr;
|
||||||
|
min-height: 8;
|
||||||
|
border: round $accent;
|
||||||
|
margin: 1 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Wizard */
|
||||||
|
#wizard {
|
||||||
|
width: 60;
|
||||||
|
padding: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
#wizard Button {
|
||||||
|
width: 100%;
|
||||||
|
margin: 0 0 1 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#wizard-welcome {
|
||||||
|
margin: 0 0 1 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Switch row */
|
||||||
|
Switch {
|
||||||
|
margin: 0 0 1 1;
|
||||||
|
}
|
||||||
215
tui/models.py
Normal file
215
tui/models.py
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Target:
|
||||||
|
name: str = ""
|
||||||
|
folders: str = ""
|
||||||
|
exclude: str = ""
|
||||||
|
remote: str = ""
|
||||||
|
retention: str = ""
|
||||||
|
pre_hook: str = ""
|
||||||
|
post_hook: str = ""
|
||||||
|
enabled: str = "yes"
|
||||||
|
|
||||||
|
def to_conf(self) -> dict[str, str]:
|
||||||
|
return {
|
||||||
|
"TARGET_NAME": self.name,
|
||||||
|
"TARGET_FOLDERS": self.folders,
|
||||||
|
"TARGET_EXCLUDE": self.exclude,
|
||||||
|
"TARGET_REMOTE": self.remote,
|
||||||
|
"TARGET_RETENTION": self.retention,
|
||||||
|
"TARGET_PRE_HOOK": self.pre_hook,
|
||||||
|
"TARGET_POST_HOOK": self.post_hook,
|
||||||
|
"TARGET_ENABLED": self.enabled,
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_conf(cls, name: str, data: dict[str, str]) -> "Target":
|
||||||
|
return cls(
|
||||||
|
name=data.get("TARGET_NAME", name),
|
||||||
|
folders=data.get("TARGET_FOLDERS", ""),
|
||||||
|
exclude=data.get("TARGET_EXCLUDE", ""),
|
||||||
|
remote=data.get("TARGET_REMOTE", ""),
|
||||||
|
retention=data.get("TARGET_RETENTION", ""),
|
||||||
|
pre_hook=data.get("TARGET_PRE_HOOK", ""),
|
||||||
|
post_hook=data.get("TARGET_POST_HOOK", ""),
|
||||||
|
enabled=data.get("TARGET_ENABLED", "yes"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Remote:
|
||||||
|
name: str = ""
|
||||||
|
type: str = "ssh"
|
||||||
|
host: str = ""
|
||||||
|
port: str = "22"
|
||||||
|
user: str = "root"
|
||||||
|
auth_method: str = "key"
|
||||||
|
key: str = ""
|
||||||
|
password: str = ""
|
||||||
|
base: str = "/backups"
|
||||||
|
bwlimit: str = "0"
|
||||||
|
retention_count: str = "30"
|
||||||
|
s3_bucket: str = ""
|
||||||
|
s3_region: str = "us-east-1"
|
||||||
|
s3_endpoint: str = ""
|
||||||
|
s3_access_key_id: str = ""
|
||||||
|
s3_secret_access_key: str = ""
|
||||||
|
gdrive_sa_file: str = ""
|
||||||
|
gdrive_root_folder_id: str = ""
|
||||||
|
|
||||||
|
def to_conf(self) -> dict[str, str]:
|
||||||
|
data: dict[str, str] = {"REMOTE_TYPE": self.type}
|
||||||
|
if self.type == "ssh":
|
||||||
|
data.update({
|
||||||
|
"REMOTE_HOST": self.host,
|
||||||
|
"REMOTE_PORT": self.port,
|
||||||
|
"REMOTE_USER": self.user,
|
||||||
|
"REMOTE_AUTH_METHOD": self.auth_method,
|
||||||
|
"REMOTE_KEY": self.key,
|
||||||
|
"REMOTE_PASSWORD": self.password,
|
||||||
|
"REMOTE_BASE": self.base,
|
||||||
|
"BWLIMIT": self.bwlimit,
|
||||||
|
"RETENTION_COUNT": self.retention_count,
|
||||||
|
})
|
||||||
|
elif self.type == "local":
|
||||||
|
data.update({
|
||||||
|
"REMOTE_BASE": self.base,
|
||||||
|
"RETENTION_COUNT": self.retention_count,
|
||||||
|
})
|
||||||
|
elif self.type == "s3":
|
||||||
|
data.update({
|
||||||
|
"S3_BUCKET": self.s3_bucket,
|
||||||
|
"S3_REGION": self.s3_region,
|
||||||
|
"S3_ENDPOINT": self.s3_endpoint,
|
||||||
|
"S3_ACCESS_KEY_ID": self.s3_access_key_id,
|
||||||
|
"S3_SECRET_ACCESS_KEY": self.s3_secret_access_key,
|
||||||
|
"REMOTE_BASE": self.base,
|
||||||
|
"RETENTION_COUNT": self.retention_count,
|
||||||
|
})
|
||||||
|
elif self.type == "gdrive":
|
||||||
|
data.update({
|
||||||
|
"GDRIVE_SERVICE_ACCOUNT_FILE": self.gdrive_sa_file,
|
||||||
|
"GDRIVE_ROOT_FOLDER_ID": self.gdrive_root_folder_id,
|
||||||
|
"REMOTE_BASE": self.base,
|
||||||
|
"RETENTION_COUNT": self.retention_count,
|
||||||
|
})
|
||||||
|
return data
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_conf(cls, name: str, data: dict[str, str]) -> "Remote":
|
||||||
|
return cls(
|
||||||
|
name=name,
|
||||||
|
type=data.get("REMOTE_TYPE", "ssh"),
|
||||||
|
host=data.get("REMOTE_HOST", ""),
|
||||||
|
port=data.get("REMOTE_PORT", "22"),
|
||||||
|
user=data.get("REMOTE_USER", "root"),
|
||||||
|
auth_method=data.get("REMOTE_AUTH_METHOD", "key"),
|
||||||
|
key=data.get("REMOTE_KEY", ""),
|
||||||
|
password=data.get("REMOTE_PASSWORD", ""),
|
||||||
|
base=data.get("REMOTE_BASE", "/backups"),
|
||||||
|
bwlimit=data.get("BWLIMIT", "0"),
|
||||||
|
retention_count=data.get("RETENTION_COUNT", "30"),
|
||||||
|
s3_bucket=data.get("S3_BUCKET", ""),
|
||||||
|
s3_region=data.get("S3_REGION", "us-east-1"),
|
||||||
|
s3_endpoint=data.get("S3_ENDPOINT", ""),
|
||||||
|
s3_access_key_id=data.get("S3_ACCESS_KEY_ID", ""),
|
||||||
|
s3_secret_access_key=data.get("S3_SECRET_ACCESS_KEY", ""),
|
||||||
|
gdrive_sa_file=data.get("GDRIVE_SERVICE_ACCOUNT_FILE", ""),
|
||||||
|
gdrive_root_folder_id=data.get("GDRIVE_ROOT_FOLDER_ID", ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Schedule:
|
||||||
|
name: str = ""
|
||||||
|
schedule: str = "daily"
|
||||||
|
time: str = "02:00"
|
||||||
|
day: str = ""
|
||||||
|
cron: str = ""
|
||||||
|
targets: str = ""
|
||||||
|
remotes: str = ""
|
||||||
|
|
||||||
|
def to_conf(self) -> dict[str, str]:
|
||||||
|
return {
|
||||||
|
"SCHEDULE": self.schedule,
|
||||||
|
"SCHEDULE_TIME": self.time,
|
||||||
|
"SCHEDULE_DAY": self.day,
|
||||||
|
"SCHEDULE_CRON": self.cron,
|
||||||
|
"TARGETS": self.targets,
|
||||||
|
"REMOTES": self.remotes,
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_conf(cls, name: str, data: dict[str, str]) -> "Schedule":
|
||||||
|
return cls(
|
||||||
|
name=name,
|
||||||
|
schedule=data.get("SCHEDULE", "daily"),
|
||||||
|
time=data.get("SCHEDULE_TIME", "02:00"),
|
||||||
|
day=data.get("SCHEDULE_DAY", ""),
|
||||||
|
cron=data.get("SCHEDULE_CRON", ""),
|
||||||
|
targets=data.get("TARGETS", ""),
|
||||||
|
remotes=data.get("REMOTES", ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AppSettings:
|
||||||
|
backup_mode: str = "incremental"
|
||||||
|
bwlimit: str = "0"
|
||||||
|
retention_count: str = "7"
|
||||||
|
log_level: str = "INFO"
|
||||||
|
log_retain: str = "30"
|
||||||
|
notify_email: str = ""
|
||||||
|
notify_on: str = "failure"
|
||||||
|
smtp_host: str = ""
|
||||||
|
smtp_port: str = "587"
|
||||||
|
smtp_user: str = ""
|
||||||
|
smtp_password: str = ""
|
||||||
|
smtp_from: str = ""
|
||||||
|
smtp_security: str = "tls"
|
||||||
|
ssh_timeout: str = "30"
|
||||||
|
ssh_retries: str = "3"
|
||||||
|
rsync_extra_opts: str = ""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_conf(cls, data: dict[str, str]) -> "AppSettings":
|
||||||
|
return cls(
|
||||||
|
backup_mode=data.get("BACKUP_MODE", "incremental"),
|
||||||
|
bwlimit=data.get("BWLIMIT", "0"),
|
||||||
|
retention_count=data.get("RETENTION_COUNT", "7"),
|
||||||
|
log_level=data.get("LOG_LEVEL", "INFO"),
|
||||||
|
log_retain=data.get("LOG_RETAIN", "30"),
|
||||||
|
notify_email=data.get("NOTIFY_EMAIL", ""),
|
||||||
|
notify_on=data.get("NOTIFY_ON", "failure"),
|
||||||
|
smtp_host=data.get("SMTP_HOST", ""),
|
||||||
|
smtp_port=data.get("SMTP_PORT", "587"),
|
||||||
|
smtp_user=data.get("SMTP_USER", ""),
|
||||||
|
smtp_password=data.get("SMTP_PASSWORD", ""),
|
||||||
|
smtp_from=data.get("SMTP_FROM", ""),
|
||||||
|
smtp_security=data.get("SMTP_SECURITY", "tls"),
|
||||||
|
ssh_timeout=data.get("SSH_TIMEOUT", "30"),
|
||||||
|
ssh_retries=data.get("SSH_RETRIES", "3"),
|
||||||
|
rsync_extra_opts=data.get("RSYNC_EXTRA_OPTS", ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_conf(self) -> dict[str, str]:
|
||||||
|
return {
|
||||||
|
"BACKUP_MODE": self.backup_mode,
|
||||||
|
"BWLIMIT": self.bwlimit,
|
||||||
|
"RETENTION_COUNT": self.retention_count,
|
||||||
|
"LOG_LEVEL": self.log_level,
|
||||||
|
"LOG_RETAIN": self.log_retain,
|
||||||
|
"NOTIFY_EMAIL": self.notify_email,
|
||||||
|
"NOTIFY_ON": self.notify_on,
|
||||||
|
"SMTP_HOST": self.smtp_host,
|
||||||
|
"SMTP_PORT": self.smtp_port,
|
||||||
|
"SMTP_USER": self.smtp_user,
|
||||||
|
"SMTP_PASSWORD": self.smtp_password,
|
||||||
|
"SMTP_FROM": self.smtp_from,
|
||||||
|
"SMTP_SECURITY": self.smtp_security,
|
||||||
|
"SSH_TIMEOUT": self.ssh_timeout,
|
||||||
|
"SSH_RETRIES": self.ssh_retries,
|
||||||
|
"RSYNC_EXTRA_OPTS": self.rsync_extra_opts,
|
||||||
|
}
|
||||||
14
tui/screens/__init__.py
Normal file
14
tui/screens/__init__.py
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
from tui.screens.main_menu import MainMenuScreen
|
||||||
|
from tui.screens.backup import BackupScreen
|
||||||
|
from tui.screens.restore import RestoreScreen
|
||||||
|
from tui.screens.targets import TargetsScreen
|
||||||
|
from tui.screens.target_edit import TargetEditScreen
|
||||||
|
from tui.screens.remotes import RemotesScreen
|
||||||
|
from tui.screens.remote_edit import RemoteEditScreen
|
||||||
|
from tui.screens.snapshots import SnapshotsScreen
|
||||||
|
from tui.screens.verify import VerifyScreen
|
||||||
|
from tui.screens.retention import RetentionScreen
|
||||||
|
from tui.screens.schedule import ScheduleScreen
|
||||||
|
from tui.screens.logs import LogsScreen
|
||||||
|
from tui.screens.settings import SettingsScreen
|
||||||
|
from tui.screens.wizard import WizardScreen
|
||||||
92
tui/screens/backup.py
Normal file
92
tui/screens/backup.py
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
from textual.app import ComposeResult
|
||||||
|
from textual.screen import Screen
|
||||||
|
from textual.widgets import Header, Footer, Static, Button, Select
|
||||||
|
from textual.containers import Vertical, Horizontal
|
||||||
|
from textual import work
|
||||||
|
|
||||||
|
from tui.config import list_conf_dir, has_targets, has_remotes
|
||||||
|
from tui.backend import stream_cli
|
||||||
|
from tui.widgets import ConfirmDialog, OperationLog
|
||||||
|
|
||||||
|
|
||||||
|
class BackupScreen(Screen):
|
||||||
|
|
||||||
|
BINDINGS = [("escape", "go_back", "Back")]
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield Header()
|
||||||
|
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")
|
||||||
|
yield Footer()
|
||||||
|
|
||||||
|
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||||
|
if event.button.id == "btn-back":
|
||||||
|
self.app.pop_screen()
|
||||||
|
elif event.button.id == "btn-backup":
|
||||||
|
target_sel = self.query_one("#backup-target", Select)
|
||||||
|
if target_sel.value is Select.BLANK:
|
||||||
|
self.notify("Please select a target", severity="error")
|
||||||
|
return
|
||||||
|
target = str(target_sel.value)
|
||||||
|
remote_sel = self.query_one("#backup-remote", Select)
|
||||||
|
remote = str(remote_sel.value) if remote_sel.value != Select.BLANK else ""
|
||||||
|
msg = f"Run backup for target '{target}'?"
|
||||||
|
if remote:
|
||||||
|
msg += f"\nRemote: {remote}"
|
||||||
|
self.app.push_screen(
|
||||||
|
ConfirmDialog(msg, "Confirm Backup"),
|
||||||
|
callback=lambda ok: self._do_backup(target, remote) if ok else None,
|
||||||
|
)
|
||||||
|
elif event.button.id == "btn-backup-all":
|
||||||
|
self.app.push_screen(
|
||||||
|
ConfirmDialog("Backup ALL targets now?", "Confirm Backup"),
|
||||||
|
callback=lambda ok: self._do_backup_all() if ok else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
@work
|
||||||
|
async def _do_backup(self, target: str, remote: str) -> None:
|
||||||
|
log_screen = OperationLog(f"Backup: {target}")
|
||||||
|
self.app.push_screen(log_screen)
|
||||||
|
args = ["backup", f"--target={target}"]
|
||||||
|
if remote:
|
||||||
|
args.append(f"--remote={remote}")
|
||||||
|
rc = await stream_cli(log_screen.write, *args)
|
||||||
|
if rc == 0:
|
||||||
|
log_screen.write("\n[green]Backup completed successfully.[/green]")
|
||||||
|
else:
|
||||||
|
log_screen.write(f"\n[red]Backup failed (exit code {rc}).[/red]")
|
||||||
|
|
||||||
|
@work
|
||||||
|
async def _do_backup_all(self) -> None:
|
||||||
|
log_screen = OperationLog("Backup All Targets")
|
||||||
|
self.app.push_screen(log_screen)
|
||||||
|
rc = await stream_cli(log_screen.write, "backup", "--all")
|
||||||
|
if rc == 0:
|
||||||
|
log_screen.write("\n[green]All backups completed.[/green]")
|
||||||
|
else:
|
||||||
|
log_screen.write(f"\n[red]Backup failed (exit code {rc}).[/red]")
|
||||||
|
|
||||||
|
def action_go_back(self) -> None:
|
||||||
|
self.app.pop_screen()
|
||||||
111
tui/screens/logs.py
Normal file
111
tui/screens/logs.py
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
from textual.app import ComposeResult
|
||||||
|
from textual.screen import Screen
|
||||||
|
from textual.widgets import Header, Footer, Static, Button, DataTable, RichLog
|
||||||
|
from textual.containers import Vertical, Horizontal
|
||||||
|
|
||||||
|
from tui.config import LOG_DIR
|
||||||
|
|
||||||
|
|
||||||
|
class LogsScreen(Screen):
|
||||||
|
|
||||||
|
BINDINGS = [("escape", "go_back", "Back")]
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield Header()
|
||||||
|
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 Footer()
|
||||||
|
|
||||||
|
def on_mount(self) -> None:
|
||||||
|
self._refresh_table()
|
||||||
|
|
||||||
|
def _refresh_table(self) -> None:
|
||||||
|
table = self.query_one("#logs-table", DataTable)
|
||||||
|
table.clear(columns=True)
|
||||||
|
table.add_columns("File", "Size")
|
||||||
|
log_dir = Path(LOG_DIR)
|
||||||
|
if not log_dir.is_dir():
|
||||||
|
return
|
||||||
|
logs = sorted(log_dir.glob("gniza-*.log"), key=lambda p: p.stat().st_mtime, reverse=True)[:20]
|
||||||
|
for f in logs:
|
||||||
|
size = f.stat().st_size
|
||||||
|
if size >= 1048576:
|
||||||
|
size_str = f"{size / 1048576:.1f} MB"
|
||||||
|
elif size >= 1024:
|
||||||
|
size_str = f"{size / 1024:.1f} KB"
|
||||||
|
else:
|
||||||
|
size_str = f"{size} B"
|
||||||
|
table.add_row(f.name, size_str, key=f.name)
|
||||||
|
|
||||||
|
def _selected_log(self) -> str | None:
|
||||||
|
table = self.query_one("#logs-table", DataTable)
|
||||||
|
if table.cursor_row is not None and table.row_count > 0:
|
||||||
|
return str(table.coordinate_to_cell_key((table.cursor_row, 0)).row_key.value)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||||
|
if event.button.id == "btn-back":
|
||||||
|
self.app.pop_screen()
|
||||||
|
elif event.button.id == "btn-view":
|
||||||
|
name = self._selected_log()
|
||||||
|
if name:
|
||||||
|
self._view_log(name)
|
||||||
|
else:
|
||||||
|
self.notify("Select a log file first", severity="warning")
|
||||||
|
elif event.button.id == "btn-status":
|
||||||
|
self._show_status()
|
||||||
|
|
||||||
|
def _view_log(self, name: str) -> None:
|
||||||
|
filepath = (Path(LOG_DIR) / name).resolve()
|
||||||
|
if not filepath.is_relative_to(Path(LOG_DIR).resolve()):
|
||||||
|
self.notify("Invalid log path", severity="error")
|
||||||
|
return
|
||||||
|
viewer = self.query_one("#log-viewer", RichLog)
|
||||||
|
viewer.clear()
|
||||||
|
if filepath.is_file():
|
||||||
|
content = filepath.read_text()
|
||||||
|
viewer.write(content)
|
||||||
|
else:
|
||||||
|
viewer.write(f"File not found: {filepath}")
|
||||||
|
|
||||||
|
def _show_status(self) -> None:
|
||||||
|
viewer = self.query_one("#log-viewer", RichLog)
|
||||||
|
viewer.clear()
|
||||||
|
log_dir = Path(LOG_DIR)
|
||||||
|
viewer.write("Backup Status Overview")
|
||||||
|
viewer.write("=" * 40)
|
||||||
|
if not log_dir.is_dir():
|
||||||
|
viewer.write("Log directory does not exist.")
|
||||||
|
return
|
||||||
|
logs = sorted(log_dir.glob("gniza-*.log"), key=lambda p: p.stat().st_mtime, reverse=True)
|
||||||
|
if logs:
|
||||||
|
latest = logs[0]
|
||||||
|
from datetime import datetime
|
||||||
|
mtime = datetime.fromtimestamp(latest.stat().st_mtime)
|
||||||
|
viewer.write(f"Last log: {mtime.strftime('%Y-%m-%d %H:%M:%S')}")
|
||||||
|
last_line = ""
|
||||||
|
with open(latest) as f:
|
||||||
|
for line in f:
|
||||||
|
last_line = line.rstrip()
|
||||||
|
if last_line:
|
||||||
|
viewer.write(f"Last entry: {last_line}")
|
||||||
|
else:
|
||||||
|
viewer.write("No backup logs found.")
|
||||||
|
viewer.write(f"Log files: {len(logs)}")
|
||||||
|
total = sum(f.stat().st_size for f in logs)
|
||||||
|
if total >= 1048576:
|
||||||
|
viewer.write(f"Total size: {total / 1048576:.1f} MB")
|
||||||
|
elif total >= 1024:
|
||||||
|
viewer.write(f"Total size: {total / 1024:.1f} KB")
|
||||||
|
else:
|
||||||
|
viewer.write(f"Total size: {total} B")
|
||||||
|
|
||||||
|
def action_go_back(self) -> None:
|
||||||
|
self.app.pop_screen()
|
||||||
58
tui/screens/main_menu.py
Normal file
58
tui/screens/main_menu.py
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
from textual.app import ComposeResult
|
||||||
|
from textual.screen import Screen
|
||||||
|
from textual.widgets import Header, Footer, Static, Button
|
||||||
|
from textual.containers import Vertical, Center
|
||||||
|
|
||||||
|
LOGO = """\
|
||||||
|
[green]
|
||||||
|
\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593
|
||||||
|
\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593 \u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593
|
||||||
|
\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593
|
||||||
|
\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593
|
||||||
|
[/green]
|
||||||
|
gniza - Linux Backup Manager
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class MainMenuScreen(Screen):
|
||||||
|
|
||||||
|
BINDINGS = [("q", "quit_app", "Quit")]
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield Header()
|
||||||
|
with Center():
|
||||||
|
with Vertical(id="main-menu"):
|
||||||
|
yield Static(LOGO, id="logo", markup=True)
|
||||||
|
yield Button("Backup", id="menu-backup", variant="primary")
|
||||||
|
yield Button("Restore", id="menu-restore")
|
||||||
|
yield Button("Targets", id="menu-targets")
|
||||||
|
yield Button("Remotes", id="menu-remotes")
|
||||||
|
yield Button("Snapshots", id="menu-snapshots")
|
||||||
|
yield Button("Verify", id="menu-verify")
|
||||||
|
yield Button("Retention", id="menu-retention")
|
||||||
|
yield Button("Schedules", id="menu-schedule")
|
||||||
|
yield Button("Logs", id="menu-logs")
|
||||||
|
yield Button("Settings", id="menu-settings")
|
||||||
|
yield Button("Quit", id="menu-quit", variant="error")
|
||||||
|
yield Footer()
|
||||||
|
|
||||||
|
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||||
|
button_map = {
|
||||||
|
"menu-backup": "backup",
|
||||||
|
"menu-restore": "restore",
|
||||||
|
"menu-targets": "targets",
|
||||||
|
"menu-remotes": "remotes",
|
||||||
|
"menu-snapshots": "snapshots",
|
||||||
|
"menu-verify": "verify",
|
||||||
|
"menu-retention": "retention",
|
||||||
|
"menu-schedule": "schedule",
|
||||||
|
"menu-logs": "logs",
|
||||||
|
"menu-settings": "settings",
|
||||||
|
}
|
||||||
|
if event.button.id == "menu-quit":
|
||||||
|
self.app.exit()
|
||||||
|
elif event.button.id in button_map:
|
||||||
|
self.app.push_screen(button_map[event.button.id])
|
||||||
|
|
||||||
|
def action_quit_app(self) -> None:
|
||||||
|
self.app.exit()
|
||||||
161
tui/screens/remote_edit.py
Normal file
161
tui/screens/remote_edit.py
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
import re
|
||||||
|
from textual.app import ComposeResult
|
||||||
|
from textual.screen import Screen
|
||||||
|
from textual.widgets import Header, Footer, Static, Button, Input, Select, RadioSet, RadioButton
|
||||||
|
from textual.containers import Vertical, Horizontal
|
||||||
|
|
||||||
|
from tui.config import parse_conf, write_conf, CONFIG_DIR
|
||||||
|
from tui.models import Remote
|
||||||
|
|
||||||
|
_NAME_RE = re.compile(r'^[a-zA-Z][a-zA-Z0-9_-]{0,31}$')
|
||||||
|
|
||||||
|
REMOTE_TYPES = [("SSH", "ssh"), ("Local directory", "local"), ("Amazon S3", "s3"), ("Google Drive", "gdrive")]
|
||||||
|
|
||||||
|
|
||||||
|
class RemoteEditScreen(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()
|
||||||
|
title = "Add Remote" if self._is_new else f"Edit Remote: {self._edit_name}"
|
||||||
|
remote = Remote()
|
||||||
|
if not self._is_new:
|
||||||
|
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")
|
||||||
|
yield Input(value=remote.key, placeholder="~/.ssh/id_rsa", id="re-key", classes="ssh-field")
|
||||||
|
yield Static("Password:", id="lbl-password", classes="ssh-field")
|
||||||
|
yield Input(value=remote.password, placeholder="SSH password", password=True, id="re-password", classes="ssh-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 Footer()
|
||||||
|
|
||||||
|
def on_mount(self) -> None:
|
||||||
|
self._update_field_visibility()
|
||||||
|
|
||||||
|
def on_select_changed(self, event: Select.Changed) -> None:
|
||||||
|
if event.select.id == "re-type":
|
||||||
|
self._update_field_visibility()
|
||||||
|
|
||||||
|
def _update_field_visibility(self) -> None:
|
||||||
|
type_sel = self.query_one("#re-type", Select)
|
||||||
|
rtype = str(type_sel.value) if type_sel.value is not Select.BLANK else "ssh"
|
||||||
|
for w in self.query(".ssh-field"):
|
||||||
|
w.display = rtype == "ssh"
|
||||||
|
for w in self.query(".s3-field"):
|
||||||
|
w.display = rtype == "s3"
|
||||||
|
for w in self.query(".gdrive-field"):
|
||||||
|
w.display = rtype == "gdrive"
|
||||||
|
|
||||||
|
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||||
|
if event.button.id == "btn-cancel":
|
||||||
|
self.dismiss(None)
|
||||||
|
elif event.button.id == "btn-save":
|
||||||
|
self._save()
|
||||||
|
|
||||||
|
def _save(self) -> None:
|
||||||
|
if self._is_new:
|
||||||
|
name = self.query_one("#re-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.", severity="error")
|
||||||
|
return
|
||||||
|
if (CONFIG_DIR / "remotes.d" / f"{name}.conf").exists():
|
||||||
|
self.notify(f"Remote '{name}' already exists.", severity="error")
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
name = self._edit_name
|
||||||
|
|
||||||
|
type_sel = self.query_one("#re-type", Select)
|
||||||
|
rtype = str(type_sel.value) if type_sel.value is not Select.BLANK else "ssh"
|
||||||
|
|
||||||
|
remote = Remote(
|
||||||
|
name=name,
|
||||||
|
type=rtype,
|
||||||
|
host=self.query_one("#re-host", Input).value.strip(),
|
||||||
|
port=self.query_one("#re-port", Input).value.strip() or "22",
|
||||||
|
user=self.query_one("#re-user", Input).value.strip() or "root",
|
||||||
|
auth_method=str(self.query_one("#re-auth", Select).value) if self.query_one("#re-auth", Select).value is not Select.BLANK else "key",
|
||||||
|
key=self.query_one("#re-key", Input).value.strip(),
|
||||||
|
password=self.query_one("#re-password", Input).value,
|
||||||
|
base=self.query_one("#re-base", Input).value.strip() or "/backups",
|
||||||
|
bwlimit=self.query_one("#re-bwlimit", Input).value.strip() or "0",
|
||||||
|
retention_count=self.query_one("#re-retention", Input).value.strip() or "30",
|
||||||
|
s3_bucket=self.query_one("#re-s3bucket", Input).value.strip(),
|
||||||
|
s3_region=self.query_one("#re-s3region", Input).value.strip() or "us-east-1",
|
||||||
|
s3_endpoint=self.query_one("#re-s3endpoint", Input).value.strip(),
|
||||||
|
s3_access_key_id=self.query_one("#re-s3key", Input).value.strip(),
|
||||||
|
s3_secret_access_key=self.query_one("#re-s3secret", Input).value,
|
||||||
|
gdrive_sa_file=self.query_one("#re-gdsa", Input).value.strip(),
|
||||||
|
gdrive_root_folder_id=self.query_one("#re-gdfolder", Input).value.strip(),
|
||||||
|
)
|
||||||
|
|
||||||
|
if rtype == "ssh" and not remote.host:
|
||||||
|
self.notify("Host is required for SSH remotes", severity="error")
|
||||||
|
return
|
||||||
|
|
||||||
|
conf = CONFIG_DIR / "remotes.d" / f"{name}.conf"
|
||||||
|
write_conf(conf, remote.to_conf())
|
||||||
|
self.notify(f"Remote '{name}' saved.")
|
||||||
|
self.dismiss(name)
|
||||||
|
|
||||||
|
def action_go_back(self) -> None:
|
||||||
|
self.dismiss(None)
|
||||||
106
tui/screens/remotes.py
Normal file
106
tui/screens/remotes.py
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
from textual.app import ComposeResult
|
||||||
|
from textual.screen import Screen
|
||||||
|
from textual.widgets import Header, Footer, Static, Button, DataTable
|
||||||
|
from textual.containers import Vertical, Horizontal
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
class RemotesScreen(Screen):
|
||||||
|
|
||||||
|
BINDINGS = [("escape", "go_back", "Back")]
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield Header()
|
||||||
|
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 Footer()
|
||||||
|
|
||||||
|
def on_mount(self) -> None:
|
||||||
|
self._refresh_table()
|
||||||
|
|
||||||
|
def _refresh_table(self) -> None:
|
||||||
|
table = self.query_one("#remotes-table", DataTable)
|
||||||
|
table.clear(columns=True)
|
||||||
|
table.add_columns("Name", "Type", "Host/Path")
|
||||||
|
remotes = list_conf_dir("remotes.d")
|
||||||
|
for name in remotes:
|
||||||
|
data = parse_conf(CONFIG_DIR / "remotes.d" / f"{name}.conf")
|
||||||
|
rtype = data.get("REMOTE_TYPE", "ssh")
|
||||||
|
if rtype == "ssh":
|
||||||
|
loc = f"{data.get('REMOTE_USER', 'root')}@{data.get('REMOTE_HOST', '')}:{data.get('REMOTE_BASE', '')}"
|
||||||
|
elif rtype == "local":
|
||||||
|
loc = data.get("REMOTE_BASE", "")
|
||||||
|
elif rtype == "s3":
|
||||||
|
loc = f"s3://{data.get('S3_BUCKET', '')}{data.get('REMOTE_BASE', '')}"
|
||||||
|
else:
|
||||||
|
loc = data.get("REMOTE_BASE", "")
|
||||||
|
table.add_row(name, rtype, loc, key=name)
|
||||||
|
|
||||||
|
def _selected_remote(self) -> str | None:
|
||||||
|
table = self.query_one("#remotes-table", DataTable)
|
||||||
|
if table.cursor_row is not None and table.row_count > 0:
|
||||||
|
return str(table.coordinate_to_cell_key((table.cursor_row, 0)).row_key.value)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||||
|
if event.button.id == "btn-back":
|
||||||
|
self.app.pop_screen()
|
||||||
|
elif event.button.id == "btn-add":
|
||||||
|
self.app.push_screen("remote_edit", callback=lambda _: self._refresh_table())
|
||||||
|
elif event.button.id == "btn-edit":
|
||||||
|
name = self._selected_remote()
|
||||||
|
if name:
|
||||||
|
from tui.screens.remote_edit import RemoteEditScreen
|
||||||
|
self.app.push_screen(RemoteEditScreen(name), callback=lambda _: self._refresh_table())
|
||||||
|
else:
|
||||||
|
self.notify("Select a remote first", severity="warning")
|
||||||
|
elif event.button.id == "btn-test":
|
||||||
|
name = self._selected_remote()
|
||||||
|
if name:
|
||||||
|
self._test_remote(name)
|
||||||
|
else:
|
||||||
|
self.notify("Select a remote first", severity="warning")
|
||||||
|
elif event.button.id == "btn-delete":
|
||||||
|
name = self._selected_remote()
|
||||||
|
if name:
|
||||||
|
self.app.push_screen(
|
||||||
|
ConfirmDialog(f"Delete remote '{name}'? This cannot be undone.", "Delete Remote"),
|
||||||
|
callback=lambda ok: self._delete_remote(name) if ok else None,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.notify("Select a remote first", severity="warning")
|
||||||
|
|
||||||
|
@work
|
||||||
|
async def _test_remote(self, name: str) -> None:
|
||||||
|
log_screen = OperationLog(f"Testing Remote: {name}")
|
||||||
|
self.app.push_screen(log_screen)
|
||||||
|
rc, stdout, stderr = await run_cli("remotes", "test", f"--name={name}")
|
||||||
|
if stdout:
|
||||||
|
log_screen.write(stdout)
|
||||||
|
if stderr:
|
||||||
|
log_screen.write(stderr)
|
||||||
|
if rc == 0:
|
||||||
|
log_screen.write("\n[green]Connection test passed.[/green]")
|
||||||
|
else:
|
||||||
|
log_screen.write(f"\n[red]Connection test failed (exit code {rc}).[/red]")
|
||||||
|
|
||||||
|
def _delete_remote(self, name: str) -> None:
|
||||||
|
conf = CONFIG_DIR / "remotes.d" / f"{name}.conf"
|
||||||
|
if conf.is_file():
|
||||||
|
conf.unlink()
|
||||||
|
self.notify(f"Remote '{name}' deleted.")
|
||||||
|
self._refresh_table()
|
||||||
|
|
||||||
|
def action_go_back(self) -> None:
|
||||||
|
self.app.pop_screen()
|
||||||
110
tui/screens/restore.py
Normal file
110
tui/screens/restore.py
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
from textual.app import ComposeResult
|
||||||
|
from textual.screen import Screen
|
||||||
|
from textual.widgets import Header, Footer, Static, Button, Select, Input, RadioSet, RadioButton
|
||||||
|
from textual.containers import Vertical, Horizontal
|
||||||
|
from textual import work
|
||||||
|
|
||||||
|
from tui.config import list_conf_dir, parse_conf, CONFIG_DIR
|
||||||
|
from tui.backend import run_cli, stream_cli
|
||||||
|
from tui.widgets import ConfirmDialog, OperationLog
|
||||||
|
|
||||||
|
|
||||||
|
class RestoreScreen(Screen):
|
||||||
|
|
||||||
|
BINDINGS = [("escape", "go_back", "Back")]
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield Header()
|
||||||
|
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="Load snapshots first")
|
||||||
|
yield Button("Load Snapshots", id="btn-load-snaps")
|
||||||
|
yield Static("Restore location:")
|
||||||
|
with RadioSet(id="restore-location"):
|
||||||
|
yield RadioButton("In-place (original)", value=True)
|
||||||
|
yield RadioButton("Custom directory")
|
||||||
|
yield Input(placeholder="Destination directory (e.g. /tmp/restore)", id="restore-dest")
|
||||||
|
with Horizontal(id="restore-buttons"):
|
||||||
|
yield Button("Restore", variant="primary", id="btn-restore")
|
||||||
|
yield Button("Back", id="btn-back")
|
||||||
|
yield Footer()
|
||||||
|
|
||||||
|
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||||
|
if event.button.id == "btn-back":
|
||||||
|
self.app.pop_screen()
|
||||||
|
elif event.button.id == "btn-load-snaps":
|
||||||
|
self._load_snapshots()
|
||||||
|
elif event.button.id == "btn-restore":
|
||||||
|
self._start_restore()
|
||||||
|
|
||||||
|
@work
|
||||||
|
async def _load_snapshots(self) -> None:
|
||||||
|
target_sel = self.query_one("#restore-target", Select)
|
||||||
|
remote_sel = self.query_one("#restore-remote", Select)
|
||||||
|
if target_sel.value is Select.BLANK or remote_sel.value is Select.BLANK:
|
||||||
|
self.notify("Select target and remote first", severity="error")
|
||||||
|
return
|
||||||
|
target = str(target_sel.value)
|
||||||
|
remote = str(remote_sel.value)
|
||||||
|
rc, stdout, stderr = await run_cli("snapshots", "list", f"--target={target}", f"--remote={remote}")
|
||||||
|
snap_sel = self.query_one("#restore-snapshot", Select)
|
||||||
|
lines = [l.strip() for l in stdout.splitlines() if l.strip() and not l.startswith("===")]
|
||||||
|
if lines:
|
||||||
|
snap_sel.set_options([(s, s) for s in lines])
|
||||||
|
else:
|
||||||
|
self.notify("No snapshots found", severity="warning")
|
||||||
|
|
||||||
|
def _start_restore(self) -> None:
|
||||||
|
target_sel = self.query_one("#restore-target", Select)
|
||||||
|
remote_sel = self.query_one("#restore-remote", Select)
|
||||||
|
snap_sel = self.query_one("#restore-snapshot", Select)
|
||||||
|
if target_sel.value is Select.BLANK:
|
||||||
|
self.notify("Select a target", severity="error")
|
||||||
|
return
|
||||||
|
if remote_sel.value is Select.BLANK:
|
||||||
|
self.notify("Select a remote", severity="error")
|
||||||
|
return
|
||||||
|
if snap_sel.value is Select.BLANK:
|
||||||
|
self.notify("Select a snapshot", severity="error")
|
||||||
|
return
|
||||||
|
target = str(target_sel.value)
|
||||||
|
remote = str(remote_sel.value)
|
||||||
|
snapshot = str(snap_sel.value)
|
||||||
|
radio = self.query_one("#restore-location", RadioSet)
|
||||||
|
dest_input = self.query_one("#restore-dest", Input)
|
||||||
|
dest = "" if radio.pressed_index == 0 else dest_input.value
|
||||||
|
msg = f"Restore snapshot?\n\nTarget: {target}\nRemote: {remote}\nSnapshot: {snapshot}"
|
||||||
|
if dest:
|
||||||
|
msg += f"\nDestination: {dest}"
|
||||||
|
else:
|
||||||
|
msg += "\nLocation: In-place"
|
||||||
|
self.app.push_screen(
|
||||||
|
ConfirmDialog(msg, "Confirm Restore"),
|
||||||
|
callback=lambda ok: self._do_restore(target, remote, snapshot, dest) if ok else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
@work
|
||||||
|
async def _do_restore(self, target: str, remote: str, snapshot: str, dest: str) -> None:
|
||||||
|
log_screen = OperationLog(f"Restore: {target}")
|
||||||
|
self.app.push_screen(log_screen)
|
||||||
|
args = ["restore", f"--target={target}", f"--remote={remote}", f"--snapshot={snapshot}"]
|
||||||
|
if dest:
|
||||||
|
args.append(f"--dest={dest}")
|
||||||
|
rc = await stream_cli(log_screen.write, *args)
|
||||||
|
if rc == 0:
|
||||||
|
log_screen.write("\n[green]Restore completed successfully.[/green]")
|
||||||
|
else:
|
||||||
|
log_screen.write(f"\n[red]Restore failed (exit code {rc}).[/red]")
|
||||||
|
|
||||||
|
def action_go_back(self) -> None:
|
||||||
|
self.app.pop_screen()
|
||||||
90
tui/screens/retention.py
Normal file
90
tui/screens/retention.py
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
from textual.app import ComposeResult
|
||||||
|
from textual.screen import Screen
|
||||||
|
from textual.widgets import Header, Footer, Static, Button, Select, Input
|
||||||
|
from textual.containers import Vertical, Horizontal
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
class RetentionScreen(Screen):
|
||||||
|
|
||||||
|
BINDINGS = [("escape", "go_back", "Back")]
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield Header()
|
||||||
|
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")
|
||||||
|
yield Footer()
|
||||||
|
|
||||||
|
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||||
|
if event.button.id == "btn-back":
|
||||||
|
self.app.pop_screen()
|
||||||
|
elif event.button.id == "btn-cleanup":
|
||||||
|
target_sel = self.query_one("#ret-target", Select)
|
||||||
|
if target_sel.value is Select.BLANK:
|
||||||
|
self.notify("Select a target first", severity="error")
|
||||||
|
return
|
||||||
|
target = str(target_sel.value)
|
||||||
|
self.app.push_screen(
|
||||||
|
ConfirmDialog(f"Run retention cleanup for '{target}'?", "Confirm"),
|
||||||
|
callback=lambda ok: self._do_cleanup(target) if ok else None,
|
||||||
|
)
|
||||||
|
elif event.button.id == "btn-cleanup-all":
|
||||||
|
self.app.push_screen(
|
||||||
|
ConfirmDialog("Run retention cleanup for ALL targets?", "Confirm"),
|
||||||
|
callback=lambda ok: self._do_cleanup_all() if ok else None,
|
||||||
|
)
|
||||||
|
elif event.button.id == "btn-save-count":
|
||||||
|
val = self.query_one("#ret-count", Input).value.strip()
|
||||||
|
if not val.isdigit() or int(val) < 1:
|
||||||
|
self.notify("Retention count must be a positive integer.", severity="error")
|
||||||
|
return
|
||||||
|
update_conf_key(CONFIG_DIR / "gniza.conf", "RETENTION_COUNT", val)
|
||||||
|
self.notify(f"Retention count set to {val}.")
|
||||||
|
|
||||||
|
@work
|
||||||
|
async def _do_cleanup(self, target: str) -> None:
|
||||||
|
log_screen = OperationLog(f"Retention: {target}")
|
||||||
|
self.app.push_screen(log_screen)
|
||||||
|
rc = await stream_cli(log_screen.write, "retention", f"--target={target}")
|
||||||
|
if rc == 0:
|
||||||
|
log_screen.write("\n[green]Cleanup completed.[/green]")
|
||||||
|
else:
|
||||||
|
log_screen.write(f"\n[red]Cleanup failed (exit code {rc}).[/red]")
|
||||||
|
|
||||||
|
@work
|
||||||
|
async def _do_cleanup_all(self) -> None:
|
||||||
|
log_screen = OperationLog("Retention: All Targets")
|
||||||
|
self.app.push_screen(log_screen)
|
||||||
|
rc = await stream_cli(log_screen.write, "retention", "--all")
|
||||||
|
if rc == 0:
|
||||||
|
log_screen.write("\n[green]All cleanups completed.[/green]")
|
||||||
|
else:
|
||||||
|
log_screen.write(f"\n[red]Cleanup failed (exit code {rc}).[/red]")
|
||||||
|
|
||||||
|
def action_go_back(self) -> None:
|
||||||
|
self.app.pop_screen()
|
||||||
164
tui/screens/schedule.py
Normal file
164
tui/screens/schedule.py
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import re
|
||||||
|
from textual.app import ComposeResult
|
||||||
|
from textual.screen import Screen
|
||||||
|
from textual.widgets import Header, Footer, Static, Button, DataTable, Input, Select
|
||||||
|
from textual.containers import Vertical, Horizontal
|
||||||
|
from textual import work
|
||||||
|
|
||||||
|
from tui.config import list_conf_dir, parse_conf, write_conf, CONFIG_DIR
|
||||||
|
from tui.models import Schedule
|
||||||
|
from tui.backend import run_cli
|
||||||
|
from tui.widgets import ConfirmDialog, OperationLog
|
||||||
|
|
||||||
|
_NAME_RE = re.compile(r'^[a-zA-Z][a-zA-Z0-9_-]{0,31}$')
|
||||||
|
|
||||||
|
SCHEDULE_TYPES = [
|
||||||
|
("Hourly", "hourly"),
|
||||||
|
("Daily", "daily"),
|
||||||
|
("Weekly", "weekly"),
|
||||||
|
("Monthly", "monthly"),
|
||||||
|
("Custom cron", "custom"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class ScheduleScreen(Screen):
|
||||||
|
|
||||||
|
BINDINGS = [("escape", "go_back", "Back")]
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield Header()
|
||||||
|
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("Delete", variant="error", id="btn-delete")
|
||||||
|
yield Button("Install to crontab", id="btn-install")
|
||||||
|
yield Button("Remove from crontab", id="btn-remove")
|
||||||
|
yield Button("Show crontab", id="btn-show")
|
||||||
|
yield Button("Back", id="btn-back")
|
||||||
|
yield Static("", id="sched-divider")
|
||||||
|
yield Static("Add Schedule", id="sched-add-title")
|
||||||
|
yield Static("Name:")
|
||||||
|
yield Input(id="sched-name", placeholder="Schedule name")
|
||||||
|
yield Static("Type:")
|
||||||
|
yield Select(SCHEDULE_TYPES, id="sched-type", value="daily")
|
||||||
|
yield Static("Time (HH:MM):")
|
||||||
|
yield Input(id="sched-time", value="02:00", placeholder="02:00")
|
||||||
|
yield Static("Day (0=Sun for weekly, 1-28 for monthly):")
|
||||||
|
yield Input(id="sched-day", placeholder="Leave empty if not needed")
|
||||||
|
yield Static("Custom cron (5 fields):")
|
||||||
|
yield Input(id="sched-cron", placeholder="0 2 * * *")
|
||||||
|
yield Static("Targets (comma-separated, empty=all):")
|
||||||
|
yield Input(id="sched-targets", placeholder="")
|
||||||
|
yield Static("Remotes (comma-separated, empty=all):")
|
||||||
|
yield Input(id="sched-remotes", placeholder="")
|
||||||
|
yield Footer()
|
||||||
|
|
||||||
|
def on_mount(self) -> None:
|
||||||
|
self._refresh_table()
|
||||||
|
|
||||||
|
def _refresh_table(self) -> None:
|
||||||
|
table = self.query_one("#sched-table", DataTable)
|
||||||
|
table.clear(columns=True)
|
||||||
|
table.add_columns("Name", "Type", "Time", "Targets", "Remotes")
|
||||||
|
schedules = list_conf_dir("schedules.d")
|
||||||
|
for name in schedules:
|
||||||
|
data = parse_conf(CONFIG_DIR / "schedules.d" / f"{name}.conf")
|
||||||
|
s = Schedule.from_conf(name, data)
|
||||||
|
table.add_row(name, s.schedule, s.time, s.targets or "all", s.remotes or "all", key=name)
|
||||||
|
|
||||||
|
def _selected_schedule(self) -> str | None:
|
||||||
|
table = self.query_one("#sched-table", DataTable)
|
||||||
|
if table.cursor_row is not None and table.row_count > 0:
|
||||||
|
return str(table.coordinate_to_cell_key((table.cursor_row, 0)).row_key.value)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||||
|
if event.button.id == "btn-back":
|
||||||
|
self.app.pop_screen()
|
||||||
|
elif event.button.id == "btn-add":
|
||||||
|
self._add_schedule()
|
||||||
|
elif event.button.id == "btn-delete":
|
||||||
|
name = self._selected_schedule()
|
||||||
|
if name:
|
||||||
|
self.app.push_screen(
|
||||||
|
ConfirmDialog(f"Delete schedule '{name}'?", "Delete Schedule"),
|
||||||
|
callback=lambda ok: self._delete_schedule(name) if ok else None,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.notify("Select a schedule first", severity="warning")
|
||||||
|
elif event.button.id == "btn-install":
|
||||||
|
self._install_schedules()
|
||||||
|
elif event.button.id == "btn-remove":
|
||||||
|
self._remove_schedules()
|
||||||
|
elif event.button.id == "btn-show":
|
||||||
|
self._show_crontab()
|
||||||
|
|
||||||
|
def _add_schedule(self) -> None:
|
||||||
|
name = self.query_one("#sched-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.", severity="error")
|
||||||
|
return
|
||||||
|
conf = CONFIG_DIR / "schedules.d" / f"{name}.conf"
|
||||||
|
if conf.exists():
|
||||||
|
self.notify(f"Schedule '{name}' already exists.", severity="error")
|
||||||
|
return
|
||||||
|
type_sel = self.query_one("#sched-type", Select)
|
||||||
|
stype = str(type_sel.value) if type_sel.value is not Select.BLANK else "daily"
|
||||||
|
sched = Schedule(
|
||||||
|
name=name,
|
||||||
|
schedule=stype,
|
||||||
|
time=self.query_one("#sched-time", Input).value.strip() or "02:00",
|
||||||
|
day=self.query_one("#sched-day", Input).value.strip(),
|
||||||
|
cron=self.query_one("#sched-cron", Input).value.strip(),
|
||||||
|
targets=self.query_one("#sched-targets", Input).value.strip(),
|
||||||
|
remotes=self.query_one("#sched-remotes", Input).value.strip(),
|
||||||
|
)
|
||||||
|
write_conf(conf, sched.to_conf())
|
||||||
|
self.notify(f"Schedule '{name}' created.")
|
||||||
|
self._refresh_table()
|
||||||
|
self.query_one("#sched-name", Input).value = ""
|
||||||
|
|
||||||
|
def _delete_schedule(self, name: str) -> None:
|
||||||
|
conf = CONFIG_DIR / "schedules.d" / f"{name}.conf"
|
||||||
|
if conf.is_file():
|
||||||
|
conf.unlink()
|
||||||
|
self.notify(f"Schedule '{name}' deleted.")
|
||||||
|
self._refresh_table()
|
||||||
|
|
||||||
|
@work
|
||||||
|
async def _install_schedules(self) -> None:
|
||||||
|
log_screen = OperationLog("Install Schedules")
|
||||||
|
self.app.push_screen(log_screen)
|
||||||
|
rc, stdout, stderr = await run_cli("schedule", "install")
|
||||||
|
if stdout:
|
||||||
|
log_screen.write(stdout)
|
||||||
|
if stderr:
|
||||||
|
log_screen.write(stderr)
|
||||||
|
|
||||||
|
@work
|
||||||
|
async def _remove_schedules(self) -> None:
|
||||||
|
log_screen = OperationLog("Remove Schedules")
|
||||||
|
self.app.push_screen(log_screen)
|
||||||
|
rc, stdout, stderr = await run_cli("schedule", "remove")
|
||||||
|
if stdout:
|
||||||
|
log_screen.write(stdout)
|
||||||
|
if stderr:
|
||||||
|
log_screen.write(stderr)
|
||||||
|
|
||||||
|
@work
|
||||||
|
async def _show_crontab(self) -> None:
|
||||||
|
log_screen = OperationLog("Current Crontab")
|
||||||
|
self.app.push_screen(log_screen)
|
||||||
|
rc, stdout, stderr = await run_cli("schedule", "show")
|
||||||
|
if stdout:
|
||||||
|
log_screen.write(stdout)
|
||||||
|
if stderr:
|
||||||
|
log_screen.write(stderr)
|
||||||
|
|
||||||
|
def action_go_back(self) -> None:
|
||||||
|
self.app.pop_screen()
|
||||||
100
tui/screens/settings.py
Normal file
100
tui/screens/settings.py
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
from textual.app import ComposeResult
|
||||||
|
from textual.screen import Screen
|
||||||
|
from textual.widgets import Header, Footer, Static, Button, Input, Select
|
||||||
|
from textual.containers import Vertical, Horizontal
|
||||||
|
|
||||||
|
from tui.config import parse_conf, write_conf, CONFIG_DIR
|
||||||
|
from tui.models import AppSettings
|
||||||
|
|
||||||
|
|
||||||
|
class SettingsScreen(Screen):
|
||||||
|
|
||||||
|
BINDINGS = [("escape", "go_back", "Back")]
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield Header()
|
||||||
|
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("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")
|
||||||
|
with Horizontal(id="set-buttons"):
|
||||||
|
yield Button("Save", variant="primary", id="btn-save")
|
||||||
|
yield Button("Back", id="btn-back")
|
||||||
|
yield Footer()
|
||||||
|
|
||||||
|
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||||
|
if event.button.id == "btn-back":
|
||||||
|
self.app.pop_screen()
|
||||||
|
elif event.button.id == "btn-save":
|
||||||
|
self._save()
|
||||||
|
|
||||||
|
def _get_select_val(self, sel_id: str, default: str) -> str:
|
||||||
|
sel = self.query_one(sel_id, Select)
|
||||||
|
return str(sel.value) if sel.value is not Select.BLANK else default
|
||||||
|
|
||||||
|
def _save(self) -> None:
|
||||||
|
settings = AppSettings(
|
||||||
|
log_level=self._get_select_val("#set-loglevel", "info"),
|
||||||
|
log_retain=self.query_one("#set-logretain", Input).value.strip() or "30",
|
||||||
|
retention_count=self.query_one("#set-retention", Input).value.strip() or "7",
|
||||||
|
bwlimit=self.query_one("#set-bwlimit", Input).value.strip() or "0",
|
||||||
|
notify_email=self.query_one("#set-email", Input).value.strip(),
|
||||||
|
notify_on=self._get_select_val("#set-notifyon", "failure"),
|
||||||
|
smtp_host=self.query_one("#set-smtphost", Input).value.strip(),
|
||||||
|
smtp_port=self.query_one("#set-smtpport", Input).value.strip() or "587",
|
||||||
|
smtp_user=self.query_one("#set-smtpuser", Input).value.strip(),
|
||||||
|
smtp_password=self.query_one("#set-smtppass", Input).value,
|
||||||
|
smtp_from=self.query_one("#set-smtpfrom", Input).value.strip(),
|
||||||
|
smtp_security=self._get_select_val("#set-smtpsec", "tls"),
|
||||||
|
ssh_timeout=self.query_one("#set-sshtimeout", Input).value.strip() or "30",
|
||||||
|
ssh_retries=self.query_one("#set-sshretries", Input).value.strip() or "3",
|
||||||
|
rsync_extra_opts=self.query_one("#set-rsyncopts", Input).value.strip(),
|
||||||
|
)
|
||||||
|
conf_path = CONFIG_DIR / "gniza.conf"
|
||||||
|
write_conf(conf_path, settings.to_conf())
|
||||||
|
self.notify("Settings saved.")
|
||||||
|
|
||||||
|
def action_go_back(self) -> None:
|
||||||
|
self.app.pop_screen()
|
||||||
68
tui/screens/snapshots.py
Normal file
68
tui/screens/snapshots.py
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
from textual.app import ComposeResult
|
||||||
|
from textual.screen import Screen
|
||||||
|
from textual.widgets import Header, Footer, Static, Button, Select, DataTable
|
||||||
|
from textual.containers import Vertical, Horizontal
|
||||||
|
from textual import work
|
||||||
|
|
||||||
|
from tui.config import list_conf_dir
|
||||||
|
from tui.backend import run_cli
|
||||||
|
|
||||||
|
|
||||||
|
class SnapshotsScreen(Screen):
|
||||||
|
|
||||||
|
BINDINGS = [("escape", "go_back", "Back")]
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield Header()
|
||||||
|
targets = list_conf_dir("targets.d")
|
||||||
|
remotes = list_conf_dir("remotes.d")
|
||||||
|
with Vertical(id="snapshots-screen"):
|
||||||
|
yield Static("Snapshots", 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")
|
||||||
|
yield Button("Back", id="btn-back")
|
||||||
|
yield Footer()
|
||||||
|
|
||||||
|
def on_mount(self) -> None:
|
||||||
|
try:
|
||||||
|
table = self.query_one("#snap-table", DataTable)
|
||||||
|
table.add_columns("Snapshot")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||||
|
if event.button.id == "btn-back":
|
||||||
|
self.app.pop_screen()
|
||||||
|
elif event.button.id == "btn-load":
|
||||||
|
self._load_snapshots()
|
||||||
|
|
||||||
|
@work
|
||||||
|
async def _load_snapshots(self) -> None:
|
||||||
|
target_sel = self.query_one("#snap-target", Select)
|
||||||
|
remote_sel = self.query_one("#snap-remote", Select)
|
||||||
|
if target_sel.value is Select.BLANK or remote_sel.value is Select.BLANK:
|
||||||
|
self.notify("Select target and remote first", severity="error")
|
||||||
|
return
|
||||||
|
target = str(target_sel.value)
|
||||||
|
remote = str(remote_sel.value)
|
||||||
|
rc, stdout, stderr = await run_cli("snapshots", "list", f"--target={target}", f"--remote={remote}")
|
||||||
|
table = self.query_one("#snap-table", DataTable)
|
||||||
|
table.clear()
|
||||||
|
lines = [l.strip() for l in stdout.splitlines() if l.strip() and not l.startswith("===")]
|
||||||
|
if lines:
|
||||||
|
for s in lines:
|
||||||
|
table.add_row(s)
|
||||||
|
else:
|
||||||
|
self.notify("No snapshots found", severity="warning")
|
||||||
|
if stderr:
|
||||||
|
self.notify(stderr.strip(), severity="error")
|
||||||
|
|
||||||
|
def action_go_back(self) -> None:
|
||||||
|
self.app.pop_screen()
|
||||||
116
tui/screens/target_edit.py
Normal file
116
tui/screens/target_edit.py
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import re
|
||||||
|
from textual.app import ComposeResult
|
||||||
|
from textual.screen import Screen
|
||||||
|
from textual.widgets import Header, Footer, Static, Button, Input, Switch
|
||||||
|
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
|
||||||
|
|
||||||
|
_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()
|
||||||
|
title = "Add Target" if self._is_new else f"Edit Target: {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 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("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")
|
||||||
|
with Horizontal():
|
||||||
|
yield Static("Enabled: ")
|
||||||
|
yield Switch(value=target.enabled == "yes", id="te-enabled")
|
||||||
|
with Horizontal(id="te-buttons"):
|
||||||
|
yield Button("Save", variant="primary", id="btn-save")
|
||||||
|
yield Button("Cancel", id="btn-cancel")
|
||||||
|
yield Footer()
|
||||||
|
|
||||||
|
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||||
|
if event.button.id == "btn-cancel":
|
||||||
|
self.dismiss(None)
|
||||||
|
elif event.button.id == "btn-browse":
|
||||||
|
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()
|
||||||
|
if not folders:
|
||||||
|
self.notify("At least one folder is required", severity="error")
|
||||||
|
return
|
||||||
|
|
||||||
|
target = Target(
|
||||||
|
name=name,
|
||||||
|
folders=folders,
|
||||||
|
exclude=self.query_one("#te-exclude", 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="yes" if self.query_one("#te-enabled", Switch).value else "no",
|
||||||
|
)
|
||||||
|
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)
|
||||||
80
tui/screens/targets.py
Normal file
80
tui/screens/targets.py
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
from textual.app import ComposeResult
|
||||||
|
from textual.screen import Screen
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
class TargetsScreen(Screen):
|
||||||
|
|
||||||
|
BINDINGS = [("escape", "go_back", "Back")]
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield Header()
|
||||||
|
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 Footer()
|
||||||
|
|
||||||
|
def on_mount(self) -> None:
|
||||||
|
self._refresh_table()
|
||||||
|
|
||||||
|
def _refresh_table(self) -> None:
|
||||||
|
table = self.query_one("#targets-table", DataTable)
|
||||||
|
table.clear(columns=True)
|
||||||
|
table.add_columns("Name", "Folders", "Enabled")
|
||||||
|
targets = list_conf_dir("targets.d")
|
||||||
|
for name in targets:
|
||||||
|
data = parse_conf(CONFIG_DIR / "targets.d" / f"{name}.conf")
|
||||||
|
table.add_row(
|
||||||
|
name,
|
||||||
|
data.get("TARGET_FOLDERS", ""),
|
||||||
|
data.get("TARGET_ENABLED", "yes"),
|
||||||
|
key=name,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _selected_target(self) -> str | None:
|
||||||
|
table = self.query_one("#targets-table", DataTable)
|
||||||
|
if table.cursor_row is not None and table.row_count > 0:
|
||||||
|
row_key = table.get_row_at(table.cursor_row)
|
||||||
|
return str(table.coordinate_to_cell_key((table.cursor_row, 0)).row_key.value)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||||
|
if event.button.id == "btn-back":
|
||||||
|
self.app.pop_screen()
|
||||||
|
elif event.button.id == "btn-add":
|
||||||
|
self.app.push_screen("target_edit", callback=lambda _: self._refresh_table())
|
||||||
|
elif event.button.id == "btn-edit":
|
||||||
|
name = self._selected_target()
|
||||||
|
if name:
|
||||||
|
from tui.screens.target_edit import TargetEditScreen
|
||||||
|
self.app.push_screen(TargetEditScreen(name), callback=lambda _: self._refresh_table())
|
||||||
|
else:
|
||||||
|
self.notify("Select a target first", severity="warning")
|
||||||
|
elif event.button.id == "btn-delete":
|
||||||
|
name = self._selected_target()
|
||||||
|
if name:
|
||||||
|
self.app.push_screen(
|
||||||
|
ConfirmDialog(f"Delete target '{name}'? This cannot be undone.", "Delete Target"),
|
||||||
|
callback=lambda ok: self._delete_target(name) if ok else None,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.notify("Select a target first", severity="warning")
|
||||||
|
|
||||||
|
def _delete_target(self, name: str) -> None:
|
||||||
|
conf = CONFIG_DIR / "targets.d" / f"{name}.conf"
|
||||||
|
if conf.is_file():
|
||||||
|
conf.unlink()
|
||||||
|
self.notify(f"Target '{name}' deleted.")
|
||||||
|
self._refresh_table()
|
||||||
|
|
||||||
|
def action_go_back(self) -> None:
|
||||||
|
self.app.pop_screen()
|
||||||
69
tui/screens/verify.py
Normal file
69
tui/screens/verify.py
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
from textual.app import ComposeResult
|
||||||
|
from textual.screen import Screen
|
||||||
|
from textual.widgets import Header, Footer, Static, Button, Select
|
||||||
|
from textual.containers import Vertical, Horizontal
|
||||||
|
from textual import work
|
||||||
|
|
||||||
|
from tui.config import list_conf_dir
|
||||||
|
from tui.backend import stream_cli
|
||||||
|
from tui.widgets import ConfirmDialog, OperationLog
|
||||||
|
|
||||||
|
|
||||||
|
class VerifyScreen(Screen):
|
||||||
|
|
||||||
|
BINDINGS = [("escape", "go_back", "Back")]
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield Header()
|
||||||
|
targets = list_conf_dir("targets.d")
|
||||||
|
with Vertical(id="verify-screen"):
|
||||||
|
yield Static("Verify Backups", id="screen-title")
|
||||||
|
if not targets:
|
||||||
|
yield Static("No targets configured.")
|
||||||
|
else:
|
||||||
|
yield Static("Target:")
|
||||||
|
yield Select(
|
||||||
|
[(t, t) for t in targets],
|
||||||
|
id="verify-target",
|
||||||
|
prompt="Select target",
|
||||||
|
)
|
||||||
|
with Horizontal(id="verify-buttons"):
|
||||||
|
yield Button("Verify Selected", variant="primary", id="btn-verify")
|
||||||
|
yield Button("Verify All", variant="warning", id="btn-verify-all")
|
||||||
|
yield Button("Back", id="btn-back")
|
||||||
|
yield Footer()
|
||||||
|
|
||||||
|
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||||
|
if event.button.id == "btn-back":
|
||||||
|
self.app.pop_screen()
|
||||||
|
elif event.button.id == "btn-verify":
|
||||||
|
target_sel = self.query_one("#verify-target", Select)
|
||||||
|
if target_sel.value is Select.BLANK:
|
||||||
|
self.notify("Select a target first", severity="error")
|
||||||
|
return
|
||||||
|
self._do_verify(str(target_sel.value))
|
||||||
|
elif event.button.id == "btn-verify-all":
|
||||||
|
self._do_verify_all()
|
||||||
|
|
||||||
|
@work
|
||||||
|
async def _do_verify(self, target: str) -> None:
|
||||||
|
log_screen = OperationLog(f"Verify: {target}")
|
||||||
|
self.app.push_screen(log_screen)
|
||||||
|
rc = await stream_cli(log_screen.write, "verify", f"--target={target}")
|
||||||
|
if rc == 0:
|
||||||
|
log_screen.write("\n[green]Verification completed successfully.[/green]")
|
||||||
|
else:
|
||||||
|
log_screen.write(f"\n[red]Verification failed (exit code {rc}).[/red]")
|
||||||
|
|
||||||
|
@work
|
||||||
|
async def _do_verify_all(self) -> None:
|
||||||
|
log_screen = OperationLog("Verify All Targets")
|
||||||
|
self.app.push_screen(log_screen)
|
||||||
|
rc = await stream_cli(log_screen.write, "verify", "--all")
|
||||||
|
if rc == 0:
|
||||||
|
log_screen.write("\n[green]All verifications completed.[/green]")
|
||||||
|
else:
|
||||||
|
log_screen.write(f"\n[red]Verification failed (exit code {rc}).[/red]")
|
||||||
|
|
||||||
|
def action_go_back(self) -> None:
|
||||||
|
self.app.pop_screen()
|
||||||
46
tui/screens/wizard.py
Normal file
46
tui/screens/wizard.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
from textual.app import ComposeResult
|
||||||
|
from textual.screen import Screen
|
||||||
|
from textual.widgets import Header, Footer, Static, Button
|
||||||
|
from textual.containers import Vertical, Center
|
||||||
|
|
||||||
|
from tui.config import has_remotes, has_targets
|
||||||
|
|
||||||
|
|
||||||
|
class WizardScreen(Screen):
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield Header()
|
||||||
|
with Center():
|
||||||
|
with Vertical(id="wizard"):
|
||||||
|
yield Static(
|
||||||
|
"[bold]Welcome to gniza Backup Manager![/bold]\n\n"
|
||||||
|
"This wizard will help you set up your first backup:\n\n"
|
||||||
|
" 1. Configure a backup destination (remote)\n"
|
||||||
|
" 2. Define what to back up (target)\n"
|
||||||
|
" 3. Optionally run your first backup\n",
|
||||||
|
id="wizard-welcome",
|
||||||
|
markup=True,
|
||||||
|
)
|
||||||
|
if not has_remotes():
|
||||||
|
yield Button("Step 1: Add Remote", variant="primary", id="wiz-remote")
|
||||||
|
else:
|
||||||
|
yield Static("[green]Remote configured.[/green]", markup=True)
|
||||||
|
if not has_targets():
|
||||||
|
yield Button("Step 2: Add Target", variant="primary", id="wiz-target")
|
||||||
|
else:
|
||||||
|
yield Static("[green]Target configured.[/green]", markup=True)
|
||||||
|
yield Button("Continue to Main Menu", id="wiz-continue")
|
||||||
|
yield Button("Skip Setup", id="wiz-skip")
|
||||||
|
yield Footer()
|
||||||
|
|
||||||
|
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||||
|
if event.button.id == "wiz-remote":
|
||||||
|
self.app.push_screen("remote_edit", callback=self._check_progress)
|
||||||
|
elif event.button.id == "wiz-target":
|
||||||
|
self.app.push_screen("target_edit", callback=self._check_progress)
|
||||||
|
elif event.button.id in ("wiz-continue", "wiz-skip"):
|
||||||
|
self.app.switch_screen("main")
|
||||||
|
|
||||||
|
def _check_progress(self, result) -> None:
|
||||||
|
if has_remotes() and has_targets():
|
||||||
|
self.app.switch_screen("main")
|
||||||
3
tui/widgets/__init__.py
Normal file
3
tui/widgets/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from tui.widgets.folder_picker import FolderPicker
|
||||||
|
from tui.widgets.confirm_dialog import ConfirmDialog
|
||||||
|
from tui.widgets.operation_log import OperationLog
|
||||||
28
tui/widgets/confirm_dialog.py
Normal file
28
tui/widgets/confirm_dialog.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
from textual.app import ComposeResult
|
||||||
|
from textual.screen import ModalScreen
|
||||||
|
from textual.widgets import Static, Button
|
||||||
|
from textual.containers import Horizontal, Vertical
|
||||||
|
|
||||||
|
|
||||||
|
class ConfirmDialog(ModalScreen[bool]):
|
||||||
|
|
||||||
|
BINDINGS = [("escape", "cancel", "Cancel")]
|
||||||
|
|
||||||
|
def __init__(self, message: str, title: str = "Confirm"):
|
||||||
|
super().__init__()
|
||||||
|
self._message = message
|
||||||
|
self._title = title
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
with Vertical(id="confirm-dialog"):
|
||||||
|
yield Static(self._title, id="cd-title")
|
||||||
|
yield Static(self._message, id="cd-message")
|
||||||
|
with Horizontal(id="cd-buttons"):
|
||||||
|
yield Button("Yes", variant="primary", id="cd-yes")
|
||||||
|
yield Button("No", variant="default", id="cd-no")
|
||||||
|
|
||||||
|
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||||
|
self.dismiss(event.button.id == "cd-yes")
|
||||||
|
|
||||||
|
def action_cancel(self) -> None:
|
||||||
|
self.dismiss(False)
|
||||||
42
tui/widgets/folder_picker.py
Normal file
42
tui/widgets/folder_picker.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
from textual.app import ComposeResult
|
||||||
|
from textual.screen import ModalScreen
|
||||||
|
from textual.widgets import DirectoryTree, Header, Footer, Static, Button
|
||||||
|
from textual.containers import Horizontal, Vertical
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
class _DirOnly(DirectoryTree):
|
||||||
|
def filter_paths(self, paths):
|
||||||
|
return [p for p in paths if p.is_dir()]
|
||||||
|
|
||||||
|
|
||||||
|
class FolderPicker(ModalScreen[str | None]):
|
||||||
|
|
||||||
|
BINDINGS = [("escape", "cancel", "Cancel")]
|
||||||
|
|
||||||
|
def __init__(self, title: str = "Select folder", start: str = "/"):
|
||||||
|
super().__init__()
|
||||||
|
self._title = title
|
||||||
|
self._start = start
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
with Vertical(id="folder-picker"):
|
||||||
|
yield Static(self._title, id="fp-title")
|
||||||
|
yield _DirOnly(self._start, id="fp-tree")
|
||||||
|
with Horizontal(id="fp-buttons"):
|
||||||
|
yield Button("Select", variant="primary", id="fp-select")
|
||||||
|
yield Button("Cancel", variant="default", id="fp-cancel")
|
||||||
|
|
||||||
|
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||||
|
if event.button.id == "fp-select":
|
||||||
|
tree = self.query_one("#fp-tree", DirectoryTree)
|
||||||
|
node = tree.cursor_node
|
||||||
|
if node and node.data and node.data.path:
|
||||||
|
self.dismiss(str(node.data.path))
|
||||||
|
else:
|
||||||
|
self.dismiss(None)
|
||||||
|
else:
|
||||||
|
self.dismiss(None)
|
||||||
|
|
||||||
|
def action_cancel(self) -> None:
|
||||||
|
self.dismiss(None)
|
||||||
28
tui/widgets/operation_log.py
Normal file
28
tui/widgets/operation_log.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
from textual.app import ComposeResult
|
||||||
|
from textual.screen import ModalScreen
|
||||||
|
from textual.widgets import RichLog, Button, Static
|
||||||
|
from textual.containers import Vertical
|
||||||
|
|
||||||
|
|
||||||
|
class OperationLog(ModalScreen[None]):
|
||||||
|
|
||||||
|
BINDINGS = [("escape", "close", "Close")]
|
||||||
|
|
||||||
|
def __init__(self, title: str = "Operation Output"):
|
||||||
|
super().__init__()
|
||||||
|
self._title = title
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
with Vertical(id="op-log"):
|
||||||
|
yield Static(self._title, id="ol-title")
|
||||||
|
yield RichLog(id="ol-log", wrap=True, highlight=True)
|
||||||
|
yield Button("Close", variant="primary", id="ol-close")
|
||||||
|
|
||||||
|
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||||
|
self.dismiss(None)
|
||||||
|
|
||||||
|
def action_close(self) -> None:
|
||||||
|
self.dismiss(None)
|
||||||
|
|
||||||
|
def write(self, text: str) -> None:
|
||||||
|
self.query_one("#ol-log", RichLog).write(text)
|
||||||
Reference in New Issue
Block a user