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:
shuki
2026-03-06 05:34:02 +02:00
parent 0d5977ab22
commit cf00ecdd4b
11 changed files with 726 additions and 7 deletions

View File

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

View File

@@ -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}"

View File

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

View File

@@ -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,
}

View File

@@ -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
View File

28
web/__main__.py Normal file
View 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
View 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),
}

View 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
View 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>