diff --git a/bin/gniza b/bin/gniza index db50eae..d32c3eb 100755 --- a/bin/gniza +++ b/bin/gniza @@ -48,6 +48,7 @@ Commands: retention [--target=NAME] [--remote=NAME] [--all] schedule install|show|remove logs [--last] [--tail=N] + web start|install-service|remove-service|status [--port=PORT] [--host=HOST] version If no command is given, the TUI is launched. @@ -394,9 +395,43 @@ run_cli() { } # ── Mode selection ─────────────────────────────────────────── -if [[ "$SUBCOMMAND" == "web" || " $* " == *" --web "* ]]; then - # Web GUI mode - PYTHONPATH="$GNIZA_DIR:${PYTHONPATH:-}" exec python3 -m tui --web "$@" +if [[ "$SUBCOMMAND" == "web" ]]; then + _web_action="${SUBCMD_ARGS[0]:-start}" + case "$_web_action" in + start) + _web_port="" _web_host="" + _web_port=$(_parse_flag "--port" "${SUBCMD_ARGS[@]+"${SUBCMD_ARGS[@]}"}") || true + _web_host=$(_parse_flag "--host" "${SUBCMD_ARGS[@]+"${SUBCMD_ARGS[@]}"}") || true + _web_args=() + [[ -n "$_web_port" ]] && _web_args+=(--port="$_web_port") + [[ -n "$_web_host" ]] && _web_args+=(--host="$_web_host") + PYTHONPATH="$GNIZA_DIR:${PYTHONPATH:-}" exec python3 -m web "${_web_args[@]}" + ;; + install-service) + _service_src="$GNIZA_DIR/etc/gniza-web.service" + _service_dst="/etc/systemd/system/gniza-web.service" + if [[ ! -f "$_service_src" ]]; then + die "Service file not found: $_service_src" + fi + cp "$_service_src" "$_service_dst" + systemctl daemon-reload + systemctl enable gniza-web + systemctl start gniza-web + echo "GNIZA web service installed and started." + echo "Access the dashboard at http://$(hostname -I | awk '{print $1}'):8080" + ;; + remove-service) + systemctl stop gniza-web 2>/dev/null || true + systemctl disable gniza-web 2>/dev/null || true + rm -f /etc/systemd/system/gniza-web.service + systemctl daemon-reload + echo "GNIZA web service removed." + ;; + status) + systemctl status gniza-web 2>/dev/null || echo "GNIZA web service is not installed." + ;; + *) die "Unknown web action: $_web_action (expected start|install-service|remove-service|status)" ;; + esac elif [[ -n "$SUBCOMMAND" ]]; then # Explicit subcommand: always CLI run_cli diff --git a/etc/gniza-web.service b/etc/gniza-web.service new file mode 100644 index 0000000..10d6caf --- /dev/null +++ b/etc/gniza-web.service @@ -0,0 +1,15 @@ +[Unit] +Description=GNIZA Web Dashboard +After=network.target + +[Service] +Type=simple +ExecStart=/usr/bin/python3 -m web +WorkingDirectory=/usr/local/gniza +Environment=GNIZA_DIR=/usr/local/gniza +Environment=GNIZA_CONFIG_DIR=/usr/local/gniza/etc +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=multi-user.target diff --git a/scripts/install.sh b/scripts/install.sh index ceea0bb..eae8c74 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -89,6 +89,10 @@ if [[ -d "$SOURCE_DIR/tui" ]]; then cp -r "$SOURCE_DIR/tui" "$INSTALL_DIR/" fi +if [[ -d "$SOURCE_DIR/web" ]]; then + cp -r "$SOURCE_DIR/web" "$INSTALL_DIR/" +fi + if [[ -d "$SOURCE_DIR/scripts" ]]; then cp -r "$SOURCE_DIR/scripts" "$INSTALL_DIR/" fi @@ -102,14 +106,14 @@ chmod +x "$INSTALL_DIR/bin/gniza" # ── Install Python TUI dependencies ───────────────────────── if command -v python3 &>/dev/null; then - info "Installing Python TUI dependencies (textual, textual-serve)..." - if python3 -m pip install --break-system-packages textual textual-serve 2>/dev/null; then + info "Installing Python TUI dependencies (textual, textual-serve, flask)..." + if python3 -m pip install --break-system-packages textual textual-serve flask 2>/dev/null; then info "Python TUI dependencies installed." - elif python3 -m pip install textual textual-serve 2>/dev/null; then + elif python3 -m pip install textual textual-serve flask 2>/dev/null; then info "Python TUI dependencies installed." else warn "Could not install Python TUI dependencies. TUI/web mode may not work." - warn "Install manually: pip3 install textual textual-serve" + warn "Install manually: pip3 install textual textual-serve flask" fi else warn "python3 not found. TUI mode will not be available." @@ -155,6 +159,32 @@ for example in target.conf.example remote.conf.example schedule.conf.example; do fi done +# ── Web dashboard setup ───────────────────────────────────── +_update_conf_key() { + local file="$1" key="$2" val="$3" + if grep -q "^${key}=" "$file" 2>/dev/null; then + sed -i "s|^${key}=.*|${key}=\"${val}\"|" "$file" + else + echo "${key}=\"${val}\"" >> "$file" + fi +} + +read -rp "Enable web dashboard? (y/n) [n]: " enable_web +if [[ "${enable_web,,}" == "y" ]]; then + _update_conf_key "$CONFIG_DIR/gniza.conf" "WEB_ENABLED" "yes" + # Generate random API key + api_key=$(python3 -c "import secrets; print(secrets.token_urlsafe(32))") + _update_conf_key "$CONFIG_DIR/gniza.conf" "WEB_API_KEY" "$api_key" + echo "Web API key: $api_key" + echo "Save this key — you'll need it to log into the dashboard." + # Install systemd service + if [[ "$MODE" == "root" ]]; then + "$INSTALL_DIR/bin/gniza" web install-service + else + warn "Systemd service installation requires root. Start manually: gniza web start" + fi +fi + # ── Done ───────────────────────────────────────────────────── echo "" echo "${C_GREEN}${C_BOLD}Installation complete!${C_RESET}" diff --git a/scripts/uninstall.sh b/scripts/uninstall.sh index 8d12144..90e3fcb 100755 --- a/scripts/uninstall.sh +++ b/scripts/uninstall.sh @@ -66,6 +66,15 @@ else info "No crontab entries to check" fi +# ── Remove web service ─────────────────────────────────────── +if systemctl is-active gniza-web &>/dev/null || [[ -f /etc/systemd/system/gniza-web.service ]]; then + echo "Removing GNIZA web service..." + systemctl stop gniza-web 2>/dev/null || true + systemctl disable gniza-web 2>/dev/null || true + rm -f /etc/systemd/system/gniza-web.service + systemctl daemon-reload +fi + # ── Remove symlink ─────────────────────────────────────────── if [[ -L "$BIN_LINK" ]]; then rm -f "$BIN_LINK" diff --git a/tui/models.py b/tui/models.py index 596d453..f106f78 100644 --- a/tui/models.py +++ b/tui/models.py @@ -203,6 +203,10 @@ class AppSettings: ssh_retries: str = "3" rsync_extra_opts: str = "" work_dir: str = "/usr/local/gniza/workdir" + web_enabled: str = "no" + web_port: str = "8080" + web_host: str = "0.0.0.0" + web_api_key: str = "" @classmethod def from_conf(cls, data: dict[str, str]) -> "AppSettings": @@ -224,6 +228,10 @@ class AppSettings: ssh_retries=data.get("SSH_RETRIES", "3"), rsync_extra_opts=data.get("RSYNC_EXTRA_OPTS", ""), work_dir=data.get("WORK_DIR", "/usr/local/gniza/workdir"), + web_enabled=data.get("WEB_ENABLED", "no"), + web_port=data.get("WEB_PORT", "8080"), + web_host=data.get("WEB_HOST", "0.0.0.0"), + web_api_key=data.get("WEB_API_KEY", ""), ) def to_conf(self) -> dict[str, str]: @@ -245,4 +253,8 @@ class AppSettings: "SSH_RETRIES": self.ssh_retries, "RSYNC_EXTRA_OPTS": self.rsync_extra_opts, "WORK_DIR": self.work_dir, + "WEB_ENABLED": self.web_enabled, + "WEB_PORT": self.web_port, + "WEB_HOST": self.web_host, + "WEB_API_KEY": self.web_api_key, } diff --git a/tui/screens/settings.py b/tui/screens/settings.py index bd73fc8..09ee6db 100644 --- a/tui/screens/settings.py +++ b/tui/screens/settings.py @@ -61,6 +61,19 @@ class SettingsScreen(Screen): yield Input(value=settings.rsync_extra_opts, id="set-rsyncopts") yield Static("Work Directory:") yield Input(value=settings.work_dir, placeholder="/usr/local/gniza/workdir", id="set-workdir") + yield Static("Web Dashboard", classes="section-label") + yield Static("Enabled:") + yield Select( + [("Yes", "yes"), ("No", "no")], + id="set-web-enabled", + value=settings.web_enabled, + ) + yield Static("Port:") + yield Input(value=settings.web_port, id="set-web-port") + yield Static("Host:") + yield Input(value=settings.web_host, id="set-web-host") + yield Static("API Key:") + yield Input(value=settings.web_api_key, password=True, id="set-web-key") with Horizontal(id="set-buttons"): yield Button("Save", variant="primary", id="btn-save") yield Button("Back", id="btn-back") @@ -94,6 +107,10 @@ class SettingsScreen(Screen): ssh_retries=self.query_one("#set-sshretries", Input).value.strip() or "3", rsync_extra_opts=self.query_one("#set-rsyncopts", Input).value.strip(), work_dir=self.query_one("#set-workdir", Input).value.strip() or "/usr/local/gniza/workdir", + web_enabled=self._get_select_val("#set-web-enabled", "no"), + web_port=self.query_one("#set-web-port", Input).value.strip() or "8080", + web_host=self.query_one("#set-web-host", Input).value.strip() or "0.0.0.0", + web_api_key=self.query_one("#set-web-key", Input).value, ) conf_path = CONFIG_DIR / "gniza.conf" write_conf(conf_path, settings.to_conf()) diff --git a/web/__init__.py b/web/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/web/__main__.py b/web/__main__.py new file mode 100644 index 0000000..3279646 --- /dev/null +++ b/web/__main__.py @@ -0,0 +1,28 @@ +import os +import sys +from pathlib import Path + +from web.app import create_app, parse_conf + +CONFIG_DIR = Path(os.environ.get("GNIZA_CONFIG_DIR", "/usr/local/gniza/etc")) + + +def main(): + # Read defaults from config + conf = parse_conf(CONFIG_DIR / "gniza.conf") + host = conf.get("WEB_HOST", "0.0.0.0") + port = int(conf.get("WEB_PORT", "8080")) + + # CLI overrides + for arg in sys.argv[1:]: + if arg.startswith("--port="): + port = int(arg.split("=", 1)[1]) + elif arg.startswith("--host="): + host = arg.split("=", 1)[1] + + app = create_app() + app.run(host=host, port=port) + + +if __name__ == "__main__": + main() diff --git a/web/app.py b/web/app.py new file mode 100644 index 0000000..47a5187 --- /dev/null +++ b/web/app.py @@ -0,0 +1,240 @@ +import os +import re +import subprocess +import secrets +from pathlib import Path +from functools import wraps + +from flask import ( + Flask, render_template, request, redirect, url_for, + session, jsonify, flash, +) + +CONFIG_DIR = Path(os.environ.get("GNIZA_CONFIG_DIR", "/usr/local/gniza/etc")) +LOG_DIR = Path(os.environ.get("LOG_DIR", "/var/log/gniza")) + + +def _gniza_bin(): + 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" + + +def parse_conf(filepath): + data = {} + if not filepath.is_file(): + return data + kv_re = re.compile(r'^([A-Z_][A-Z0-9_]*)="(.*)"$') + for line in filepath.read_text().splitlines(): + line = line.strip() + m = kv_re.match(line) + if m: + data[m.group(1)] = m.group(2) + return data + + +def list_conf_dir(subdir): + d = CONFIG_DIR / subdir + if not d.is_dir(): + return [] + return sorted(p.stem for p in d.glob("*.conf")) + + +def _get_api_key(): + conf = parse_conf(CONFIG_DIR / "gniza.conf") + return conf.get("WEB_API_KEY", "") + + +def login_required(f): + @wraps(f) + def decorated(*args, **kwargs): + if not session.get("authenticated"): + if request.is_json or request.path.startswith("/api/"): + return jsonify({"error": "unauthorized"}), 401 + return redirect(url_for("login")) + return f(*args, **kwargs) + return decorated + + +def create_app(): + app = Flask( + __name__, + template_folder=str(Path(__file__).resolve().parent / "templates"), + ) + + api_key = _get_api_key() + if not api_key: + api_key = secrets.token_urlsafe(32) + print(f"WARNING: No WEB_API_KEY configured. Generated temporary key: {api_key}") + print("Set WEB_API_KEY in gniza.conf to persist this key.") + + app.secret_key = api_key + + @app.route("/login", methods=["GET", "POST"]) + def login(): + if request.method == "POST": + token = request.form.get("token", "") + current_key = _get_api_key() or api_key + if secrets.compare_digest(token, current_key): + session["authenticated"] = True + return redirect(url_for("dashboard")) + flash("Invalid API key.") + return render_template("login.html") + + @app.route("/logout") + def logout(): + session.clear() + return redirect(url_for("login")) + + @app.route("/") + @login_required + def dashboard(): + targets = _load_targets() + remotes = _load_remotes() + schedules = _load_schedules() + last_log = _last_log_info() + return render_template( + "dashboard.html", + targets=targets, + remotes=remotes, + schedules=schedules, + last_log=last_log, + ) + + @app.route("/api/targets") + @login_required + def api_targets(): + return jsonify(_load_targets()) + + @app.route("/api/remotes") + @login_required + def api_remotes(): + return jsonify(_load_remotes()) + + @app.route("/api/schedules") + @login_required + def api_schedules(): + return jsonify(_load_schedules()) + + @app.route("/api/logs") + @login_required + def api_logs(): + name = request.args.get("name") + if name: + safe_name = Path(name).name + log_path = LOG_DIR / safe_name + if not log_path.is_file() or not str(log_path.resolve()).startswith(str(LOG_DIR.resolve())): + return jsonify({"error": "not found"}), 404 + return jsonify({"name": safe_name, "content": log_path.read_text(errors="replace")}) + files = [] + if LOG_DIR.is_dir(): + for p in sorted(LOG_DIR.glob("gniza-*.log"), key=lambda x: x.stat().st_mtime, reverse=True): + files.append({"name": p.name, "size": p.stat().st_size, "mtime": p.stat().st_mtime}) + return jsonify(files) + + @app.route("/api/status") + @login_required + def api_status(): + targets = _load_targets() + enabled = sum(1 for t in targets if t.get("enabled") == "yes") + last = _last_log_info() + return jsonify({ + "targets_total": len(targets), + "targets_enabled": enabled, + "remotes_total": len(list_conf_dir("remotes.d")), + "schedules_total": len(list_conf_dir("schedules.d")), + "last_log": last, + }) + + @app.route("/api/backup", methods=["POST"]) + @login_required + def api_backup(): + if request.is_json: + target = (request.json or {}).get("target", "") + else: + target = request.form.get("target", "") + if not target: + return jsonify({"error": "target parameter required"}), 400 + safe_target = re.sub(r'[^a-zA-Z0-9_.-]', '', target) + if not safe_target: + return jsonify({"error": "invalid target name"}), 400 + try: + subprocess.Popen( + [_gniza_bin(), "--cli", "backup", f"--target={safe_target}"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + except Exception as e: + return jsonify({"error": str(e)}), 500 + return jsonify({"status": "started", "target": safe_target}) + + return app + + +def _load_targets(): + targets = [] + for name in list_conf_dir("targets.d"): + conf = parse_conf(CONFIG_DIR / "targets.d" / f"{name}.conf") + targets.append({ + "name": conf.get("TARGET_NAME", name), + "folders": conf.get("TARGET_FOLDERS", ""), + "remote": conf.get("TARGET_REMOTE", ""), + "enabled": conf.get("TARGET_ENABLED", "yes"), + }) + return targets + + +def _load_remotes(): + remotes = [] + for name in list_conf_dir("remotes.d"): + conf = parse_conf(CONFIG_DIR / "remotes.d" / f"{name}.conf") + remotes.append({ + "name": name, + "type": conf.get("REMOTE_TYPE", "ssh"), + "host": conf.get("REMOTE_HOST", ""), + "base": conf.get("REMOTE_BASE", ""), + }) + return remotes + + +def _load_schedules(): + schedules = [] + for name in list_conf_dir("schedules.d"): + conf = parse_conf(CONFIG_DIR / "schedules.d" / f"{name}.conf") + schedules.append({ + "name": name, + "schedule": conf.get("SCHEDULE", "daily"), + "time": conf.get("SCHEDULE_TIME", ""), + "active": conf.get("SCHEDULE_ACTIVE", "yes"), + "targets": conf.get("TARGETS", ""), + }) + return schedules + + +def _last_log_info(): + if not LOG_DIR.is_dir(): + return None + logs = sorted(LOG_DIR.glob("gniza-*.log"), key=lambda x: x.stat().st_mtime, reverse=True) + if not logs: + return None + latest = logs[0] + lines = latest.read_text(errors="replace").splitlines() + last_lines = lines[-10:] if len(lines) > 10 else lines + status = "unknown" + for line in reversed(lines): + if "completed successfully" in line.lower() or "backup done" in line.lower(): + status = "success" + break + if "error" in line.lower() or "failed" in line.lower(): + status = "error" + break + return { + "name": latest.name, + "mtime": latest.stat().st_mtime, + "status": status, + "tail": "\n".join(last_lines), + } diff --git a/web/templates/dashboard.html b/web/templates/dashboard.html new file mode 100644 index 0000000..50d483c --- /dev/null +++ b/web/templates/dashboard.html @@ -0,0 +1,239 @@ + + +
+ + +| Name | Remote | Status | |
|---|---|---|---|
| {{ t.name }} | +{{ t.remote or '-' }} | ++ {% if t.enabled == 'yes' %} + enabled + {% else %} + disabled + {% endif %} + | ++ {% if t.enabled == 'yes' %} + + {% endif %} + | +
No targets configured.
+ {% endif %} +| Name | Type | Host |
|---|---|---|
| {{ r.name }} | +{{ r.type }} | +{{ r.host or r.base }} | +
No remotes configured.
+ {% endif %} +| Name | Schedule | Time | Status |
|---|---|---|---|
| {{ s.name }} | +{{ s.schedule }} | +{{ s.time or '-' }} | ++ {% if s.active == 'yes' %} + active + {% else %} + inactive + {% endif %} + | +
No schedules configured.
+ {% endif %} ++ {{ last_log.name }} + {{ last_log.status }} +
+No log files found.
+ {% endif %} +