Add web dashboard with systemd service support
- Flask web dashboard with dark theme matching TUI - Login with API key authentication - Dashboard shows targets, remotes, schedules, last backup status - Trigger backups from web UI per target - View logs via /api/logs endpoint - systemd service: gniza web install-service / remove-service / status - CLI: gniza web start [--port=PORT] [--host=HOST] - TUI settings: web enabled, port, host, API key fields - Install script: optional web dashboard setup with auto-generated API key - Uninstall script: removes systemd service Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
41
bin/gniza
41
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
|
||||
|
||||
15
etc/gniza-web.service
Normal file
15
etc/gniza-web.service
Normal file
@@ -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
|
||||
@@ -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}"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
0
web/__init__.py
Normal file
0
web/__init__.py
Normal file
28
web/__main__.py
Normal file
28
web/__main__.py
Normal file
@@ -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()
|
||||
240
web/app.py
Normal file
240
web/app.py
Normal file
@@ -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),
|
||||
}
|
||||
239
web/templates/dashboard.html
Normal file
239
web/templates/dashboard.html
Normal file
@@ -0,0 +1,239 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>GNIZA Dashboard</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
background: #1a1a2e;
|
||||
color: #e0e0e0;
|
||||
font-family: 'Courier New', monospace;
|
||||
padding: 1rem;
|
||||
}
|
||||
header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid #00cc00;
|
||||
padding-bottom: 0.75rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
header h1 {
|
||||
color: #00cc00;
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
header a {
|
||||
color: #aaa;
|
||||
text-decoration: none;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
header a:hover { color: #00cc00; }
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
@media (max-width: 800px) {
|
||||
.grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
.card {
|
||||
background: #16213e;
|
||||
border: 1px solid #333;
|
||||
border-radius: 6px;
|
||||
padding: 1rem;
|
||||
}
|
||||
.card h2 {
|
||||
color: #00cc00;
|
||||
font-size: 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
border-bottom: 1px solid #333;
|
||||
padding-bottom: 0.4rem;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
th {
|
||||
text-align: left;
|
||||
color: #00cc00;
|
||||
padding: 0.3rem 0.5rem;
|
||||
border-bottom: 1px solid #333;
|
||||
}
|
||||
td {
|
||||
padding: 0.3rem 0.5rem;
|
||||
border-bottom: 1px solid #222;
|
||||
}
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 3px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
.badge-yes { background: #00cc00; color: #1a1a2e; }
|
||||
.badge-no { background: #555; color: #ccc; }
|
||||
.badge-success { background: #00cc00; color: #1a1a2e; }
|
||||
.badge-error { background: #cc3333; color: #fff; }
|
||||
.badge-unknown { background: #666; color: #ccc; }
|
||||
.btn-backup {
|
||||
background: #0f3460;
|
||||
color: #00cc00;
|
||||
border: 1px solid #00cc00;
|
||||
padding: 0.2rem 0.6rem;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.btn-backup:hover { background: #00cc00; color: #1a1a2e; }
|
||||
.btn-backup:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.log-tail {
|
||||
background: #0a0a1a;
|
||||
padding: 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
color: #aaa;
|
||||
}
|
||||
.empty { color: #666; font-style: italic; }
|
||||
.full-width { grid-column: 1 / -1; }
|
||||
.status-msg {
|
||||
margin-top: 0.3rem;
|
||||
font-size: 0.8rem;
|
||||
color: #00cc00;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>GNIZA Backup Dashboard</h1>
|
||||
<a href="/logout">Logout</a>
|
||||
</header>
|
||||
|
||||
<div class="grid">
|
||||
<div class="card">
|
||||
<h2>Targets</h2>
|
||||
{% if targets %}
|
||||
<table>
|
||||
<tr><th>Name</th><th>Remote</th><th>Status</th><th></th></tr>
|
||||
{% for t in targets %}
|
||||
<tr>
|
||||
<td>{{ t.name }}</td>
|
||||
<td>{{ t.remote or '-' }}</td>
|
||||
<td>
|
||||
{% if t.enabled == 'yes' %}
|
||||
<span class="badge badge-yes">enabled</span>
|
||||
{% else %}
|
||||
<span class="badge badge-no">disabled</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if t.enabled == 'yes' %}
|
||||
<button class="btn-backup" onclick="triggerBackup(this, '{{ t.name }}')">Backup</button>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="empty">No targets configured.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Remotes</h2>
|
||||
{% if remotes %}
|
||||
<table>
|
||||
<tr><th>Name</th><th>Type</th><th>Host</th></tr>
|
||||
{% for r in remotes %}
|
||||
<tr>
|
||||
<td>{{ r.name }}</td>
|
||||
<td>{{ r.type }}</td>
|
||||
<td>{{ r.host or r.base }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="empty">No remotes configured.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Schedules</h2>
|
||||
{% if schedules %}
|
||||
<table>
|
||||
<tr><th>Name</th><th>Schedule</th><th>Time</th><th>Status</th></tr>
|
||||
{% for s in schedules %}
|
||||
<tr>
|
||||
<td>{{ s.name }}</td>
|
||||
<td>{{ s.schedule }}</td>
|
||||
<td>{{ s.time or '-' }}</td>
|
||||
<td>
|
||||
{% if s.active == 'yes' %}
|
||||
<span class="badge badge-yes">active</span>
|
||||
{% else %}
|
||||
<span class="badge badge-no">inactive</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="empty">No schedules configured.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Last Backup</h2>
|
||||
{% if last_log %}
|
||||
<p>
|
||||
<strong>{{ last_log.name }}</strong>
|
||||
<span class="badge badge-{{ last_log.status }}">{{ last_log.status }}</span>
|
||||
</p>
|
||||
<div class="log-tail">{{ last_log.tail }}</div>
|
||||
{% else %}
|
||||
<p class="empty">No log files found.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function triggerBackup(btn, target) {
|
||||
btn.disabled = true;
|
||||
btn.textContent = '...';
|
||||
var fd = new FormData();
|
||||
fd.append('target', target);
|
||||
fetch('/api/backup', { method: 'POST', body: fd })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.error) {
|
||||
btn.textContent = 'Error';
|
||||
btn.style.borderColor = '#cc3333';
|
||||
btn.style.color = '#cc3333';
|
||||
} else {
|
||||
btn.textContent = 'Started';
|
||||
}
|
||||
setTimeout(function() {
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Backup';
|
||||
btn.style.borderColor = '';
|
||||
btn.style.color = '';
|
||||
}, 3000);
|
||||
})
|
||||
.catch(function() {
|
||||
btn.textContent = 'Error';
|
||||
setTimeout(function() {
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Backup';
|
||||
}, 3000);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
94
web/templates/login.html
Normal file
94
web/templates/login.html
Normal file
@@ -0,0 +1,94 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>GNIZA - Login</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
background: #1a1a2e;
|
||||
color: #e0e0e0;
|
||||
font-family: 'Courier New', monospace;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
}
|
||||
.login-box {
|
||||
background: #16213e;
|
||||
border: 1px solid #00cc00;
|
||||
border-radius: 8px;
|
||||
padding: 2rem;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
h1 {
|
||||
color: #00cc00;
|
||||
text-align: center;
|
||||
margin-bottom: 1.5rem;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
.flash {
|
||||
background: #3a1a1a;
|
||||
color: #ff6666;
|
||||
padding: 0.5rem;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #aaa;
|
||||
}
|
||||
input[type="password"] {
|
||||
width: 100%;
|
||||
padding: 0.6rem;
|
||||
background: #0f3460;
|
||||
border: 1px solid #333;
|
||||
border-radius: 4px;
|
||||
color: #e0e0e0;
|
||||
font-family: inherit;
|
||||
font-size: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
input[type="password"]:focus {
|
||||
outline: none;
|
||||
border-color: #00cc00;
|
||||
}
|
||||
button {
|
||||
width: 100%;
|
||||
padding: 0.6rem;
|
||||
background: #00cc00;
|
||||
color: #1a1a2e;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-family: inherit;
|
||||
font-size: 1rem;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
}
|
||||
button:hover {
|
||||
background: #00ff00;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="login-box">
|
||||
<h1>GNIZA Backup</h1>
|
||||
{% with messages = get_flashed_messages() %}
|
||||
{% if messages %}
|
||||
{% for msg in messages %}
|
||||
<div class="flash">{{ msg }}</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
<form method="POST">
|
||||
<label for="token">API Key</label>
|
||||
<input type="password" name="token" id="token" placeholder="Enter your API key" autofocus>
|
||||
<button type="submit">Login</button>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user