Implement user authentication and admin features in eMMC provisioning dashboard: add SQLite database for user management, create admin log functionality, and enhance session handling. Update README to reflect new public and admin access levels, and improve deployment scripts to support cloud-init images and portal files management.

This commit is contained in:
nearxos
2026-02-19 15:17:47 +02:00
parent 987e71c36e
commit 39aa042dc9
9 changed files with 1167 additions and 61 deletions

View File

@@ -1,41 +1,53 @@
# CM4 Provisioning Dashboard
Flask web UI to monitor the eMMC deployment process and show device connection steps.
Flask web UI for CM4 eMMC provisioning: **public home** (deploy only) and **admin** (login required) for images, cloud-init, portal files, and users.
- **Connection steps**: Numbered instructions for putting the reTerminal in boot mode and connecting it.
- **Live status**: Idle / Connecting (rpiboot) / Flashing / Backup / Done / Error, with optional progress.
- **Backup / Restore**: Toggle between **Flash** (deploy golden image) and **Backup** (save eMMC to a timestamped file when device is connected in boot mode). List and download saved backups. For each raw `.img` backup you can click **Shrink** (PiShrink) or **Compress** (shrink + xz) to reduce size.
- **Build cloud-init image**: Download the latest Raspberry Pi OS Lite (arm64) from the official repository and inject cloud-init NoCloud files so the image is ready for first-boot configuration. The result appears in the backups list; set it as golden to deploy. *Requires* loop devices and mount (if the dashboard runs in an LXC, the container may need privileged mode or loop support).
- **Recent log**: Tail of the flash log (from the host, via the shared bind mount).
## Public home (`/`)
The dashboard reads `/var/lib/cm4-provisioning/status.json` and `flash.log`, which the flash script (running on the Proxmox host) updates. When the dashboard runs inside the LXC, that directory is bind-mounted from the host, so it sees the same files.
- **No login.** Anyone can:
- See current status (idle / connecting / flashing / backup / done / error).
- **Deploy** or **Backup** when a device is connected (USB boot or network).
- See which image is set as **golden** (used for Deploy).
- View recent log and “How to connect” steps.
Layout: compact two columns (status + deploy on the left; golden info, log, and connect on the right).
## Admin (`/admin`)
**Login required.** First user: open `/login`, enter any username and a password (min 6 characters) to create the first admin account.
Admin can:
- **Backup images** (in `backups/`): upload, rename, delete, shrink/compress, **set as golden**, download.
- **Cloud-init images** (in `cloudinit-images/`): list, set as golden, rename, delete, download.
- **Build cloud-init image**: download latest Raspberry Pi OS (arm64), inject cloud-init, edit user-data/meta-data/network-config, use templates. Output goes to cloud-init images; optionally set as golden after build.
- **Portal files** (in `portal-files/`): upload files that are served at `/files/<name>` so cloud-init can `wget` or `curl` them on first boot (e.g. `curl -fsSL "http://SERVER/files/bootstrap.sh" -o /tmp/bootstrap.sh`).
- **Golden image**: any backup or cloud-init image can be set as golden (no single “golden file”; choose from either list).
- **Admin users**: add users, change password.
- **Activity logs**: view recent admin actions.
Backup images and cloud-init images live in **separate folders** (`backups/` and `cloudinit-images/`).
## Run locally (development)
```bash
cd dashboard
pip install flask # or use venv
pip install -r requirements.txt # Flask, werkzeug
python3 app.py
# Open http://localhost:5000
# Open http://localhost:5000 (home), http://localhost:5000/admin (login first)
```
## Run in LXC (Proxmox)
1. Copy the dashboard into the container (e.g. to `/opt/cm4-provisioning/dashboard`).
2. Install Flask if needed: `apt install -y python3-flask` or `pip install flask`.
3. Install the systemd unit and enable it:
```bash
cp /opt/cm4-provisioning/dashboard/cm4-dashboard.service /etc/systemd/system/
systemctl daemon-reload
systemctl enable --now cm4-dashboard
```
4. Open `http://<LXC-IP>:5000` (or port-forward from the Proxmox host).
Deploy with `scripts/deploy-to-proxmox.sh`; it installs the dashboard and pushes `home.html`, `login.html`, `admin.html`. Optional: create `/opt/cm4-provisioning/dashboard.env` with `CM4_DASHBOARD_SECRET_KEY=<random>` so session logins persist across restarts (deploy script can create this automatically).
## Environment (optional)
- `CM4_STATUS_FILE` path to status JSON (default: `/var/lib/cm4-provisioning/status.json`).
- `CM4_LOG_FILE` path to flash log (default: `/var/lib/cm4-provisioning/flash.log`).
- `CM4_BACKUPS_DIR` path to backups directory (default: `…/backups`).
- `CM4_BUILD_STATUS_FILE` path to build-cloudinit status JSON (default: `…/build_cloudinit_status.json`).
- `CM4_PROVISIONING_DIR` base path (default: `/var/lib/cm4-provisioning`).
- `CM4_BACKUPS_DIR` backups directory (default: `…/backups`).
- `CM4_CLOUDINIT_IMAGES_DIR` cloud-init built images (default: `…/cloudinit-images`).
- `CM4_PORTAL_FILES_DIR` files served at `/files/` (default: `…/portal-files`).
- `CM4_GOLDEN_IMAGE` path to golden image (symlink to chosen backup or cloud-init image).
- `CM4_DASHBOARD_SECRET_KEY` secret for session cookies (set in production).
- `CM4_DASHBOARD_DB` SQLite path for admin users and logs (default: `…/dashboard.db`).
- `CM4_STATUS_FILE`, `CM4_LOG_FILE`, etc. as before for status and build.

View File

@@ -1,39 +1,36 @@
#!/usr/bin/env python3
"""
Flask dashboard for CM4 eMMC provisioning.
Monitors deployment status, shows device connection steps, backup/restore.
Supports USB boot mode (ask Backup/Deploy) and network-booted devices (register, then Backup/Deploy).
Public home: deploy only (status, logs, how to connect). No login.
Admin: login required — backups, cloud-init, portal files, set golden, users.
"""
import json
import os
import re
import shutil
import subprocess
import sqlite3
import time
import urllib.request
from functools import wraps
from pathlib import Path
from flask import Flask, render_template, jsonify, request, send_file, Response
from flask import Flask, render_template, jsonify, request, send_file, redirect, url_for, session
from werkzeug.security import generate_password_hash, check_password_hash
app = Flask(__name__)
app.secret_key = os.environ.get("CM4_DASHBOARD_SECRET_KEY", os.urandom(24).hex())
@app.after_request
def no_cache(response):
"""Prevent browser from caching the dashboard so deploys are visible immediately."""
if request.path == "/" or request.path.startswith("/api/"):
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
response.headers["Pragma"] = "no-cache"
return response
# --- Paths ---
BASE_DIR = Path(os.environ.get("CM4_PROVISIONING_DIR", "/var/lib/cm4-provisioning"))
STATUS_FILE = os.environ.get("CM4_STATUS_FILE", str(BASE_DIR / "status.json"))
LOG_FILE = os.environ.get("CM4_LOG_FILE", str(BASE_DIR / "flash.log"))
ACTION_REQUEST_FILE = os.environ.get("CM4_ACTION_REQUEST_FILE", str(BASE_DIR / "action_request"))
DEVICE_SOURCE_FILE = os.environ.get("CM4_DEVICE_SOURCE_FILE", str(BASE_DIR / "device_source"))
BACKUPS_DIR = Path(os.environ.get("CM4_BACKUPS_DIR", str(BASE_DIR / "backups")))
CLOUDINIT_IMAGES_DIR = Path(os.environ.get("CM4_CLOUDINIT_IMAGES_DIR", str(BASE_DIR / "cloudinit-images")))
PORTAL_FILES_DIR = Path(os.environ.get("CM4_PORTAL_FILES_DIR", str(BASE_DIR / "portal-files")))
GOLDEN_IMAGE = Path(os.environ.get("CM4_GOLDEN_IMAGE", str(BASE_DIR / "golden.img")))
NETWORK_DEVICES_FILE = Path(os.environ.get("CM4_NETWORK_DEVICES_FILE", str(BASE_DIR / "network_devices.json")))
BUILD_STATUS_FILE = Path(os.environ.get("CM4_BUILD_STATUS_FILE", str(BASE_DIR / "build_cloudinit_status.json")))
@@ -41,6 +38,117 @@ BUILD_REQUEST_FILE = Path(os.environ.get("CM4_BUILD_REQUEST_FILE", str(BASE_DIR
SHRINK_REQUEST_FILE = Path(os.environ.get("CM4_SHRINK_REQUEST_FILE", str(BASE_DIR / "shrink_request.json")))
SHRINK_STATUS_FILE = Path(os.environ.get("CM4_SHRINK_STATUS_FILE", str(BASE_DIR / "shrink_status.json")))
CLOUDINIT_TEMPLATES_FILE = Path(os.environ.get("CM4_CLOUDINIT_TEMPLATES_FILE", str(BASE_DIR / "cloudinit_templates.json")))
DB_PATH = Path(os.environ.get("CM4_DASHBOARD_DB", str(BASE_DIR / "dashboard.db")))
# --- Database (admin users + activity logs) ---
def get_db():
conn = sqlite3.connect(str(DB_PATH))
conn.row_factory = sqlite3.Row
return conn
def init_db():
os.makedirs(DB_PATH.parent, exist_ok=True)
conn = get_db()
conn.executescript("""
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
created_at REAL NOT NULL
);
CREATE TABLE IF NOT EXISTS admin_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
action TEXT NOT NULL,
details TEXT,
created_at REAL NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id)
);
CREATE INDEX IF NOT EXISTS idx_logs_created ON admin_logs(created_at DESC);
""")
conn.commit()
conn.close()
def admin_log(action, details=None):
user_id = session.get("user_id")
conn = get_db()
conn.execute(
"INSERT INTO admin_logs (user_id, action, details, created_at) VALUES (?, ?, ?, ?)",
(user_id, action, details, time.time()),
)
conn.commit()
conn.close()
def require_admin(f):
@wraps(f)
def wrapped(*args, **kwargs):
if not session.get("admin_logged_in"):
if request.is_json or request.path.startswith("/api/"):
return jsonify({"ok": False, "error": "Login required"}), 401
return redirect(url_for("login", next=request.url))
return f(*args, **kwargs)
return wrapped
def get_user_by_username(username):
conn = get_db()
row = conn.execute("SELECT id, username, password_hash FROM users WHERE username = ?", (username,)).fetchone()
conn.close()
return dict(row) if row else None
def create_user(username, password):
conn = get_db()
try:
conn.execute(
"INSERT INTO users (username, password_hash, created_at) VALUES (?, ?, ?)",
(username, generate_password_hash(password), time.time()),
)
conn.commit()
return True
except sqlite3.IntegrityError:
return False
finally:
conn.close()
def list_users():
conn = get_db()
rows = conn.execute("SELECT id, username, created_at FROM users ORDER BY username").fetchall()
conn.close()
return [{"id": r["id"], "username": r["username"], "created_at": r["created_at"]} for r in rows]
def change_password(user_id, new_password):
conn = get_db()
conn.execute(
"UPDATE users SET password_hash = ? WHERE id = ?",
(generate_password_hash(new_password), user_id),
)
conn.commit()
conn.close()
def get_recent_logs(limit=100):
conn = get_db()
rows = conn.execute(
"SELECT l.id, l.action, l.details, l.created_at, u.username FROM admin_logs l LEFT JOIN users u ON l.user_id = u.id ORDER BY l.created_at DESC LIMIT ?",
(limit,),
).fetchall()
conn.close()
return [{"id": r["id"], "action": r["action"], "details": r["details"], "created_at": r["created_at"], "username": r["username"]} for r in rows]
@app.after_request
def no_cache(response):
if request.path == "/" or request.path.startswith("/api/") or request.path.startswith("/admin") or request.path.startswith("/login"):
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
response.headers["Pragma"] = "no-cache"
return response
# Default cloud-init user-data for Raspberry Pi OS (NoCloud on boot partition)
DEFAULT_USER_DATA = """#cloud-config
@@ -119,6 +227,7 @@ def _save_network_devices(data):
BACKUPS_META_FILE = BACKUPS_DIR / "backups_meta.json"
CLOUDINIT_META_FILE = CLOUDINIT_IMAGES_DIR / "cloudinit_meta.json"
def _load_backups_meta():
@@ -150,6 +259,26 @@ def _safe_backup_name(name):
return True
def _load_cloudinit_meta():
try:
if CLOUDINIT_META_FILE.is_file():
with open(CLOUDINIT_META_FILE, "r") as f:
return json.load(f)
except (json.JSONDecodeError, OSError):
pass
return {}
def _save_cloudinit_meta(data):
try:
CLOUDINIT_IMAGES_DIR.mkdir(parents=True, exist_ok=True)
with open(CLOUDINIT_META_FILE, "w") as f:
json.dump(data, f, indent=2)
return True
except (PermissionError, OSError):
return False
def list_backups():
if not BACKUPS_DIR.is_dir():
return []
@@ -172,9 +301,109 @@ def list_backups():
return out
def list_cloudinit_images():
if not CLOUDINIT_IMAGES_DIR.is_dir():
return []
meta = _load_cloudinit_meta()
out = []
for p in sorted(CLOUDINIT_IMAGES_DIR.iterdir(), key=lambda x: x.stat().st_mtime, reverse=True):
if p.is_file() and p.name != "cloudinit_meta.json" and p.name.endswith((".img", ".img.gz", ".img.xz")):
try:
st = p.stat()
m = meta.get(p.name, {})
out.append({
"name": p.name,
"display_name": m.get("name") or p.name,
"description": m.get("description") or "",
"size": st.st_size,
"mtime": st.st_mtime,
})
except OSError:
pass
return out
def _golden_current_source():
"""Return (source, name) if golden is a symlink to backups or cloudinit, else (None, None)."""
if not GOLDEN_IMAGE.exists():
return None, None
try:
target = GOLDEN_IMAGE.resolve()
try:
target.relative_to(BACKUPS_DIR.resolve())
return "backups", target.name
except ValueError:
pass
try:
target.relative_to(CLOUDINIT_IMAGES_DIR.resolve())
return "cloudinit", target.name
except ValueError:
pass
except OSError:
pass
return None, None
@app.route("/")
def index():
return render_template("index.html")
return render_template("home.html")
@app.route("/login", methods=["GET", "POST"])
def login():
if request.method == "GET":
if session.get("admin_logged_in"):
return redirect(url_for("admin"))
return render_template("login.html")
username = (request.form.get("username") or "").strip()
password = (request.form.get("password") or "")
if not username:
return render_template("login.html", error="Username required")
user = get_user_by_username(username)
if not user:
# First user: allow self-registration
if not list_users():
if not password or len(password) < 6:
return render_template("login.html", error="First user: choose a password (min 6 characters)")
create_user(username, password)
session["admin_logged_in"] = True
session["user_id"] = get_user_by_username(username)["id"]
session["username"] = username
admin_log("first_admin_created", username)
return redirect(request.args.get("next") or url_for("admin"))
return render_template("login.html", error="Invalid username or password")
if not check_password_hash(user["password_hash"], password):
return render_template("login.html", error="Invalid username or password")
session["admin_logged_in"] = True
session["user_id"] = user["id"]
session["username"] = user["username"]
admin_log("login", username)
return redirect(request.args.get("next") or url_for("admin"))
@app.route("/logout")
def logout():
if session.get("admin_logged_in"):
admin_log("logout", session.get("username"))
session.clear()
return redirect(url_for("index"))
@app.route("/admin")
@require_admin
def admin():
return render_template("admin.html", username=session.get("username", "Admin"))
# Serve portal files for wget (e.g. cloud-init first boot). No auth.
@app.route("/files/<path:filename>")
def serve_portal_file(filename):
if ".." in filename or "/" in filename or "\\" in filename:
return jsonify({"error": "invalid path"}), 400
path = PORTAL_FILES_DIR / filename
if not path.is_file():
return jsonify({"error": "not found"}), 404
return send_file(path, as_attachment=False, download_name=filename)
@app.route("/api/status")
@@ -331,11 +560,74 @@ def api_backup_upload():
@app.route("/api/backups")
@require_admin
def api_backups():
return jsonify({"backups": list_backups(), "backups_dir": str(BACKUPS_DIR)})
@app.route("/api/cloudinit-images")
@require_admin
def api_cloudinit_images():
return jsonify({"images": list_cloudinit_images(), "cloudinit_dir": str(CLOUDINIT_IMAGES_DIR)})
@app.route("/api/portal-files")
@require_admin
def api_portal_files_list():
if not PORTAL_FILES_DIR.is_dir():
return jsonify({"files": [], "base_url": request.host_url.rstrip("/") + "/files/"})
files = []
for p in sorted(PORTAL_FILES_DIR.iterdir(), key=lambda x: x.stat().st_mtime, reverse=True):
if p.is_file() and ".." not in p.name:
try:
files.append({"name": p.name, "size": p.stat().st_size, "mtime": p.stat().st_mtime})
except OSError:
pass
base = request.host_url.rstrip("/") + "/files/"
return jsonify({"files": files, "base_url": base})
@app.route("/api/portal-files/upload", methods=["POST"])
@require_admin
def api_portal_files_upload():
if "file" not in request.files and "upload" not in request.files:
return jsonify({"ok": False, "error": "no file (use field 'file' or 'upload')"}), 400
f = request.files.get("file") or request.files.get("upload")
if not f or not f.filename:
return jsonify({"ok": False, "error": "no file selected"}), 400
name = re.sub(r"[^\w\-.]", "_", f.filename)[:120] or "upload"
if ".." in name or name.startswith("/"):
return jsonify({"ok": False, "error": "invalid filename"}), 400
path = PORTAL_FILES_DIR / name
try:
PORTAL_FILES_DIR.mkdir(parents=True, exist_ok=True)
f.save(str(path))
admin_log("portal_upload", name)
return jsonify({"ok": True, "name": name, "url": request.host_url.rstrip("/") + "/files/" + name})
except (OSError, IOError) as e:
if path.exists():
path.unlink(missing_ok=True)
return jsonify({"ok": False, "error": str(e)}), 500
@app.route("/api/portal-files/<path:name>", methods=["DELETE"])
@require_admin
def api_portal_file_delete(name):
if ".." in name or "/" in name or "\\" in name:
return jsonify({"ok": False, "error": "invalid name"}), 400
path = PORTAL_FILES_DIR / name
if not path.is_file():
return jsonify({"ok": False, "error": "not found"}), 404
try:
path.unlink()
admin_log("portal_delete", name)
return jsonify({"ok": True})
except OSError as e:
return jsonify({"ok": False, "error": str(e)}), 500
@app.route("/api/backups/upload", methods=["POST"])
@require_admin
def api_backups_upload():
"""Upload an image file from the dashboard (multipart form)."""
if "file" not in request.files and "image" not in request.files:
@@ -370,9 +662,27 @@ def api_backups_upload():
return jsonify({"ok": False, "error": str(e)}), 500
def _set_golden_from_path(path):
"""Set golden image to point to given path (file in backups or cloudinit dir)."""
GOLDEN_IMAGE.parent.mkdir(parents=True, exist_ok=True)
if GOLDEN_IMAGE.exists():
GOLDEN_IMAGE.unlink()
path_resolved = path.resolve()
try:
path_resolved.relative_to(BACKUPS_DIR.resolve())
except ValueError:
try:
path_resolved.relative_to(CLOUDINIT_IMAGES_DIR.resolve())
except ValueError:
shutil.copy2(path, GOLDEN_IMAGE)
return
os.symlink(path_resolved, GOLDEN_IMAGE)
@app.route("/api/backups/<path:name>/set-as-golden", methods=["POST"])
@require_admin
def api_backup_set_as_golden(name):
"""Use this backup as the golden image (symlink when in backups dir to avoid copying)."""
"""Use this backup as the golden image (symlink)."""
if not _safe_backup_name(name):
return jsonify({"ok": False, "error": "invalid backup name"}), 400
path = BACKUPS_DIR / name
@@ -380,18 +690,27 @@ def api_backup_set_as_golden(name):
return jsonify({"ok": False, "error": "backup not found"}), 404
try:
BACKUPS_DIR.mkdir(parents=True, exist_ok=True)
GOLDEN_IMAGE.parent.mkdir(parents=True, exist_ok=True)
if GOLDEN_IMAGE.exists():
GOLDEN_IMAGE.unlink()
# Symlink to the backup so we use the same file instead of duplicating
path_resolved = path.resolve()
try:
path_resolved.relative_to(BACKUPS_DIR.resolve())
os.symlink(path_resolved, GOLDEN_IMAGE)
except ValueError:
# Backup not under BACKUPS_DIR (shouldn't happen for list); fall back to copy
shutil.copy2(path, GOLDEN_IMAGE)
return jsonify({"ok": True, "message": f"Golden image set from {name}"})
_set_golden_from_path(path)
admin_log("set_golden", f"backups/{name}")
return jsonify({"ok": True, "message": f"Golden image set from backup {name}"})
except (OSError, IOError) as e:
return jsonify({"ok": False, "error": str(e)}), 500
@app.route("/api/cloudinit-images/<path:name>/set-as-golden", methods=["POST"])
@require_admin
def api_cloudinit_set_as_golden(name):
"""Use this cloud-init image as the golden image (symlink)."""
if not _safe_backup_name(name):
return jsonify({"ok": False, "error": "invalid name"}), 400
path = CLOUDINIT_IMAGES_DIR / name
if not path.is_file():
return jsonify({"ok": False, "error": "image not found"}), 404
try:
CLOUDINIT_IMAGES_DIR.mkdir(parents=True, exist_ok=True)
_set_golden_from_path(path)
admin_log("set_golden", f"cloudinit/{name}")
return jsonify({"ok": True, "message": f"Golden image set from cloud-init image {name}"})
except (OSError, IOError) as e:
return jsonify({"ok": False, "error": str(e)}), 500
@@ -424,7 +743,81 @@ def _request_host_shrink(name, action="shrink", format="xz"):
return False, "Shrink timed out (run on host may still be in progress)"
@app.route("/api/cloudinit-images/<path:name>", methods=["GET"])
@require_admin
def api_cloudinit_download(name):
if not _safe_backup_name(name):
return jsonify({"error": "invalid name"}), 400
path = CLOUDINIT_IMAGES_DIR / name
if not path.is_file():
return jsonify({"error": "not found"}), 404
return send_file(path, as_attachment=True, download_name=name)
@app.route("/api/cloudinit-images/<path:name>", methods=["PATCH"])
@require_admin
def api_cloudinit_update(name):
if not _safe_backup_name(name):
return jsonify({"ok": False, "error": "invalid name"}), 400
path = CLOUDINIT_IMAGES_DIR / name
if not path.is_file():
return jsonify({"ok": False, "error": "not found"}), 404
body = request.get_json(force=True, silent=True) or {}
meta = _load_cloudinit_meta()
entry = meta.get(name, {})
new_filename = (body.get("filename") or "").strip()
if new_filename:
if not _safe_backup_name(new_filename):
return jsonify({"ok": False, "error": "invalid new filename"}), 400
new_path = CLOUDINIT_IMAGES_DIR / new_filename
if new_path.exists() and new_path != path:
return jsonify({"ok": False, "error": "target filename already exists"}), 409
try:
path.rename(new_path)
except OSError as e:
return jsonify({"ok": False, "error": str(e)}), 500
meta[new_filename] = {"name": entry.get("name") or name, "description": entry.get("description") or ""}
meta.pop(name, None)
name = new_filename
path = CLOUDINIT_IMAGES_DIR / name
else:
if "name" in body:
entry["name"] = (body.get("name") or "").strip() or path.name
if "description" in body:
entry["description"] = (body.get("description") or "").strip()
meta[name] = entry
if not _save_cloudinit_meta(meta):
return jsonify({"ok": False, "error": "could not save metadata"}), 500
return jsonify({"ok": True, "name": name})
@app.route("/api/cloudinit-images/<path:name>", methods=["DELETE"])
@require_admin
def api_cloudinit_delete(name):
if not _safe_backup_name(name):
return jsonify({"ok": False, "error": "invalid name"}), 400
path = CLOUDINIT_IMAGES_DIR / name
if not path.is_file():
return jsonify({"ok": False, "error": "not found"}), 404
try:
if GOLDEN_IMAGE.exists() and GOLDEN_IMAGE.is_symlink():
try:
if GOLDEN_IMAGE.resolve() == path.resolve():
GOLDEN_IMAGE.unlink()
except OSError:
pass
path.unlink()
meta = _load_cloudinit_meta()
meta.pop(name, None)
_save_cloudinit_meta(meta)
admin_log("cloudinit_delete", name)
return jsonify({"ok": True, "message": f"Deleted {name}"})
except (OSError, IOError) as e:
return jsonify({"ok": False, "error": str(e)}), 500
@app.route("/api/backups/<path:name>/shrink", methods=["POST"])
@require_admin
def api_backup_shrink(name):
"""Request PiShrink on host for a raw .img backup (shrinks in place). Dashboard polls host status."""
if not _safe_backup_name(name):
@@ -441,6 +834,7 @@ def api_backup_shrink(name):
@app.route("/api/backups/<path:name>/compress", methods=["POST"])
@require_admin
def api_backup_compress(name):
"""Request PiShrink with compression on host. Produces .img.gz or .img.xz. Dashboard polls host status."""
if not _safe_backup_name(name):
@@ -464,6 +858,7 @@ def api_backup_compress(name):
@app.route("/api/backups/<path:name>", methods=["PATCH"])
@require_admin
def api_backup_update(name):
"""Update backup metadata (display name, description) or rename the file."""
if not _safe_backup_name(name):
@@ -504,6 +899,7 @@ def api_backup_update(name):
@app.route("/api/backups/<path:name>", methods=["DELETE"])
@require_admin
def api_backup_delete(name):
"""Delete a backup file. If it is the current golden image (symlink), the golden link is removed."""
if not _safe_backup_name(name):
@@ -528,6 +924,7 @@ def api_backup_delete(name):
@app.route("/api/backups/<path:name>", methods=["GET"])
@require_admin
def api_backup_download(name):
if not _safe_backup_name(name):
return jsonify({"error": "invalid name"}), 400
@@ -611,12 +1008,14 @@ def _save_cloudinit_templates(data):
@app.route("/api/build-cloudinit-status")
@require_admin
def api_build_cloudinit_status():
"""Return current build status (phase, message, output_name, error)."""
return jsonify(_build_status_read())
@app.route("/api/build-cloudinit", methods=["POST"])
@require_admin
def api_build_cloudinit():
"""Start building a cloud-init image: write request file; host runs build (has loop devices)."""
st = _build_status_read()
@@ -653,6 +1052,7 @@ def api_build_cloudinit():
@app.route("/api/raspios-latest-url")
@require_admin
def api_raspios_latest_url():
"""Return the URL of the latest Raspberry Pi OS (arm64) image. Query: variant=lite|full."""
variant = (request.args.get("variant") or "lite").strip().lower()
@@ -665,6 +1065,7 @@ def api_raspios_latest_url():
@app.route("/api/cloudinit-templates", methods=["GET"])
@require_admin
def api_cloudinit_templates_list():
"""List saved cloud-init templates."""
data = _load_cloudinit_templates()
@@ -672,6 +1073,7 @@ def api_cloudinit_templates_list():
@app.route("/api/cloudinit-templates", methods=["POST"])
@require_admin
def api_cloudinit_templates_create():
"""Save a new cloud-init template."""
body = request.get_json(force=True, silent=True) or {}
@@ -695,6 +1097,7 @@ def api_cloudinit_templates_create():
@app.route("/api/cloudinit-templates/<tid>")
@require_admin
def api_cloudinit_templates_get(tid):
"""Get one template by id."""
data = _load_cloudinit_templates()
@@ -705,6 +1108,7 @@ def api_cloudinit_templates_get(tid):
@app.route("/api/cloudinit-templates/<tid>", methods=["DELETE"])
@require_admin
def api_cloudinit_templates_delete(tid):
"""Delete a template."""
data = _load_cloudinit_templates()
@@ -719,15 +1123,72 @@ def api_cloudinit_templates_delete(tid):
@app.route("/api/golden-info")
def api_golden_info():
"""Return whether golden image exists and its size/mtime for UI."""
"""Return whether golden image exists, size/mtime, and which file it points to (for UI)."""
if not GOLDEN_IMAGE.is_file():
return jsonify({"present": False})
try:
st = GOLDEN_IMAGE.stat()
return jsonify({"present": True, "size": st.st_size, "mtime": st.st_mtime})
src, src_name = _golden_current_source()
out = {"present": True, "size": st.st_size, "mtime": st.st_mtime}
if src and src_name:
out["source"] = src
out["name"] = src_name
return jsonify(out)
except OSError:
return jsonify({"present": False})
@app.route("/api/admin/users", methods=["GET"])
@require_admin
def api_admin_users():
return jsonify({"users": list_users()})
@app.route("/api/admin/users", methods=["POST"])
@require_admin
def api_admin_add_user():
body = request.get_json(force=True, silent=True) or {}
username = (body.get("username") or "").strip()
password = (body.get("password") or "")
if not username:
return jsonify({"ok": False, "error": "username required"}), 400
if len(password) < 6:
return jsonify({"ok": False, "error": "password must be at least 6 characters"}), 400
if create_user(username, password):
admin_log("user_created", username)
return jsonify({"ok": True, "message": f"User {username} created"})
return jsonify({"ok": False, "error": "username already exists"}), 409
@app.route("/api/admin/users/<int:user_id>/password", methods=["POST"])
@require_admin
def api_admin_change_password(user_id):
body = request.get_json(force=True, silent=True) or {}
new_password = body.get("password") or ""
if len(new_password) < 6:
return jsonify({"ok": False, "error": "password must be at least 6 characters"}), 400
conn = get_db()
row = conn.execute("SELECT id FROM users WHERE id = ?", (user_id,)).fetchone()
conn.close()
if not row:
return jsonify({"ok": False, "error": "user not found"}), 404
change_password(user_id, new_password)
admin_log("password_changed", str(user_id))
return jsonify({"ok": True, "message": "Password updated"})
@app.route("/api/admin/logs")
@require_admin
def api_admin_logs():
limit = min(int(request.args.get("limit", 100)), 500)
return jsonify({"logs": get_recent_logs(limit)})
# Ensure DB exists when app is loaded (e.g. by gunicorn or systemd)
try:
init_db()
except Exception:
pass
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000, debug=False)

View File

@@ -11,6 +11,7 @@ ExecStart=/usr/bin/python3 -m flask --app app run --host=0.0.0.0 --port=5000
Restart=on-failure
RestartSec=5
Environment=FLASK_ENV=production
EnvironmentFile=-/opt/cm4-provisioning/dashboard.env
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,2 @@
Flask>=2.0
werkzeug>=2.0

View File

@@ -0,0 +1,356 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Admin · CM4 Provisioning</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&family=Outfit:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
:root {
--bg-primary: #0a0e14; --bg-secondary: #11151c; --bg-tertiary: #1a1f2b; --bg-card: #151a24;
--accent: #00d4aa; --accent-dim: #00b894; --text: #e6e8eb; --text-dim: #8b949e; --text-muted: #5c6370;
--border: #2d333b; --danger: #f87171; --warn: #fbbf24; --success: #00d4aa; --radius: 10px; --radius-sm: 6px;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Outfit', sans-serif; background: var(--bg-primary); color: var(--text); min-height: 100vh; font-size: 14px; line-height: 1.5; }
.wrap { max-width: 1000px; margin: 0 auto; padding: 1rem; }
.header { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 0.75rem; margin-bottom: 1rem; padding-bottom: 0.75rem; border-bottom: 1px solid var(--border); }
.header h1 { font-size: 1.25rem; font-weight: 700; }
.header span { color: var(--text-dim); font-size: 0.9rem; }
.header a { color: var(--text-dim); text-decoration: none; margin-left: 0.5rem; }
.header a:hover { color: var(--accent); }
.section { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 1rem 1.25rem; margin-bottom: 1rem; }
.section-title { font-size: 0.7rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-dim); margin-bottom: 0.75rem; display: flex; align-items: center; flex-wrap: wrap; gap: 0.5rem; }
.btn { padding: 0.4rem 0.8rem; font-size: 0.8rem; font-weight: 500; font-family: inherit; border-radius: var(--radius-sm); cursor: pointer; border: none; transition: background 0.15s, color 0.15s; }
.btn-outline { background: transparent; border: 1px solid var(--border); color: var(--text); }
.btn-outline:hover { border-color: var(--accent); color: var(--accent); }
.btn-primary { background: var(--accent); color: var(--bg-primary); }
.btn-primary:hover { background: var(--accent-dim); }
.btn-sm { padding: 0.3rem 0.5rem; font-size: 0.75rem; }
.btn-danger { background: rgba(248,113,113,0.2); color: var(--danger); }
.btn-danger:hover { background: rgba(248,113,113,0.3); }
table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
th { text-align: left; font-weight: 600; font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-dim); padding: 0.4rem 0; border-bottom: 1px solid var(--border); }
td { padding: 0.5rem 0; border-bottom: 1px solid var(--border); }
tr:last-child td { border-bottom: none; }
.mono { font-family: 'JetBrains Mono', monospace; font-size: 0.8em; color: var(--text-muted); }
.empty-msg { text-align: center; padding: 1rem; font-size: 0.9rem; color: var(--text-muted); }
details { margin-top: 0.5rem; }
summary { cursor: pointer; font-weight: 500; color: var(--text-dim); padding: 0.4rem 0; }
summary::-webkit-details-marker { display: none; }
summary::before { content: '▶'; display: inline-block; margin-right: 0.4rem; font-size: 0.6rem; transition: transform 0.2s; }
details[open] summary::before { transform: rotate(90deg); }
.actions-cell { white-space: nowrap; }
.actions-cell .btn { margin-right: 0.25rem; margin-bottom: 0.2rem; }
input[type="text"], input[type="password"], textarea, select { background: var(--bg-tertiary); border: 1px solid var(--border); color: var(--text); padding: 0.35rem 0.5rem; border-radius: 4px; font: inherit; }
textarea { width: 100%; min-height: 80px; resize: vertical; font-family: 'JetBrains Mono', monospace; font-size: 0.8rem; }
.golden-badge { font-size: 0.7rem; color: var(--accent); font-weight: 600; }
.portal-url { font-size: 0.8rem; color: var(--text-muted); word-break: break-all; margin-top: 0.5rem; }
</style>
</head>
<body>
<div class="wrap">
<header class="header">
<h1>Admin</h1>
<span>Logged in as <strong>{{ username }}</strong></span>
<a href="/">Deploy</a>
<a href="/logout">Log out</a>
</header>
<!-- Golden image -->
<div class="section">
<h2 class="section-title">Current deploy image (golden)</h2>
<p id="goldenInfo" class="mono" style="font-size:0.9rem;">Loading…</p>
</div>
<!-- Backups -->
<div class="section">
<h2 class="section-title">
Backup images
<button type="button" class="btn btn-outline btn-sm" id="refreshBackupsBtn">Refresh</button>
<button type="button" class="btn btn-outline btn-sm" id="uploadBackupBtn">Upload backup</button>
<input type="file" id="uploadBackupInput" accept=".img,.img.gz,.img.xz" style="display:none;" />
</h2>
<p id="backupsDirHint" class="mono" style="font-size:0.75rem; margin-bottom:0.5rem; color:var(--text-muted);"></p>
<table id="backupsTable">
<thead><tr><th>Name</th><th>Description</th><th>Size</th><th>Date</th><th>Actions</th></tr></thead>
<tbody id="backupsBody"></tbody>
</table>
<p id="backupsEmpty" class="empty-msg" style="display:none;">No backups. Capture from device on home page or upload here.</p>
</div>
<!-- Cloud-init images -->
<div class="section">
<h2 class="section-title">Cloud-init images <button type="button" class="btn btn-outline btn-sm" id="refreshCloudinitBtn">Refresh</button></h2>
<table id="cloudinitTable">
<thead><tr><th>Name</th><th>Description</th><th>Size</th><th>Date</th><th>Actions</th></tr></thead>
<tbody id="cloudinitBody"></tbody>
</table>
<p id="cloudinitEmpty" class="empty-msg" style="display:none;">No cloud-init images. Build one below.</p>
</div>
<!-- Build cloud-init (download latest + edit) -->
<div class="section">
<h2 class="section-title">Download & build cloud-init image</h2>
<p style="font-size:0.9rem; color:var(--text-dim); margin-bottom:0.75rem;">Download latest Raspberry Pi OS (arm64), inject cloud-init. Output goes to <strong>Cloud-init images</strong> above.</p>
<div style="margin-bottom:0.5rem;">
<label>Variant: </label>
<select id="buildVariant"><option value="lite">Lite</option><option value="full">Full</option></select>
<span id="buildRaspiosUrl" class="mono" style="margin-left:0.5rem;"></span>
</div>
<div style="margin-bottom:0.5rem;"><label><input type="checkbox" id="buildSetGolden" /> Set as golden after build</label></div>
<div style="margin-bottom:0.75rem;"><button type="button" id="buildCloudInitBtn" class="btn btn-primary">Download & build</button></div>
<div id="buildCloudInitStatus" class="mono" style="min-height:1.2em;"></div>
<details style="margin-top:0.75rem;">
<summary>Edit cloud-init (user-data, meta-data, network-config)</summary>
<div style="margin-top:0.75rem;">
<p>Templates: <select id="buildTemplateSelect"><option value="">— Load —</option></select>
<button type="button" id="buildTemplateLoad" class="btn btn-outline btn-sm">Load</button>
<button type="button" id="buildTemplateSave" class="btn btn-outline btn-sm">Save as template…</button></p>
<ul id="buildTemplateList" style="list-style:none; padding:0; font-size:0.85rem; margin-bottom:0.5rem;"></ul>
<label>user-data (YAML)</label>
<textarea id="buildUserData" rows="6" placeholder="#cloud-config..."></textarea>
<label style="display:block; margin-top:0.5rem;">meta-data (optional)</label>
<textarea id="buildMetaData" rows="2"></textarea>
<label style="display:block; margin-top:0.5rem;">network-config (optional)</label>
<textarea id="buildNetworkConfig" rows="4"></textarea>
</div>
</details>
</div>
<!-- Portal files (wget in cloud-init) -->
<div class="section">
<h2 class="section-title">Portal files (for wget in cloud-init) <button type="button" class="btn btn-outline btn-sm" id="uploadPortalBtn">Upload file</button><input type="file" id="uploadPortalInput" style="display:none;" /></h2>
<p class="mono" style="font-size:0.8rem; margin-bottom:0.5rem;">Files here are served at <strong id="portalBaseUrl">/files/</strong> — use in user-data e.g. <code>curl -fsSL "http://THIS_SERVER/files/bootstrap.sh" -o /tmp/bootstrap.sh</code></p>
<table id="portalTable"><thead><tr><th>File</th><th>Size</th><th>Actions</th></tr></thead><tbody id="portalBody"></tbody></table>
<p id="portalEmpty" class="empty-msg" style="display:none;">No files. Upload scripts or configs for first-boot.</p>
</div>
<!-- Users -->
<div class="section">
<h2 class="section-title">Admin users <button type="button" class="btn btn-outline btn-sm" id="addUserBtn">Add user</button></h2>
<div id="addUserForm" style="display:none; margin-bottom:0.75rem; padding:0.75rem; background:var(--bg-tertiary); border-radius:var(--radius-sm);">
<input type="text" id="newUsername" placeholder="Username" />
<input type="password" id="newPassword" placeholder="Password (min 6)" style="margin-left:0.25rem;" />
<button type="button" id="addUserSubmit" class="btn btn-primary btn-sm" style="margin-left:0.25rem;">Create</button>
<button type="button" id="addUserCancel" class="btn btn-outline btn-sm">Cancel</button>
</div>
<table id="usersTable"><thead><tr><th>Username</th><th>Created</th><th>Actions</th></tr></thead><tbody id="usersBody"></tbody></table>
</div>
<!-- Activity logs -->
<div class="section">
<h2 class="section-title">Activity logs <button type="button" class="btn btn-outline btn-sm" id="refreshLogsBtn">Refresh</button></h2>
<div id="adminLogs" class="mono" style="font-size:0.8rem; max-height:200px; overflow:auto;"></div>
</div>
</div>
<script>
function authFetch(url, opts) {
opts = opts || {};
return fetch(url, opts).then(function(r) {
if (r.status === 401) { window.location = '/login?next=' + encodeURIComponent(window.location.pathname); return Promise.reject(new Error('Login required')); }
return r;
});
}
function escapeHtml(s) { var d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
function fmtSize(n) { if (n >= 1e9) return (n/1e9).toFixed(1)+' GB'; if (n >= 1e6) return (n/1e6).toFixed(1)+' MB'; return (n/1e3).toFixed(0)+' KB'; }
function fmtDate(ts) { return new Date(ts*1000).toLocaleString(); }
function fetchGolden() {
authFetch('/api/golden-info').then(function(r){ return r.json(); }).then(function(d){
var el = document.getElementById('goldenInfo');
if (!d.present) { el.textContent = 'No golden image set. Set one from Backup images or Cloud-init images above.'; return; }
el.innerHTML = fmtSize(d.size) + ', updated ' + fmtDate(d.mtime) + (d.name ? ' — <span class="golden-badge">' + (d.source === 'cloudinit' ? 'Cloud-init' : 'Backup') + ': ' + escapeHtml(d.name) + '</span>' : '');
}).catch(function(){});
}
function renderBackups(backups, backupsDir) {
var tbody = document.getElementById('backupsBody');
var empty = document.getElementById('backupsEmpty');
var dirHint = document.getElementById('backupsDirHint');
if (backupsDir) dirHint.textContent = 'Dir: ' + backupsDir;
tbody.innerHTML = '';
if (!backups || backups.length === 0) { empty.style.display = 'block'; return; }
empty.style.display = 'none';
backups.forEach(function(b){
var isRaw = b.name.endsWith('.img') && !b.name.endsWith('.img.gz') && !b.name.endsWith('.img.xz');
var shrink = isRaw ? '<button type="button" class="btn btn-outline btn-sm set-golden-backup" data-name="'+escapeHtml(b.name)+'">Set golden</button> <button type="button" class="btn btn-outline btn-sm shrink-btn" data-name="'+escapeHtml(b.name)+'">Shrink</button> <button type="button" class="btn btn-outline btn-sm compress-btn" data-name="'+escapeHtml(b.name)+'">Compress</button> ' : '<button type="button" class="btn btn-outline btn-sm set-golden-backup" data-name="'+escapeHtml(b.name)+'">Set golden</button> ';
var tr = document.createElement('tr');
tr.innerHTML = '<td>'+escapeHtml(b.display_name||b.name)+'</td><td>'+escapeHtml(b.description||'')+'</td><td class="mono">'+fmtSize(b.size)+'</td><td class="mono">'+fmtDate(b.mtime)+'</td><td class="actions-cell">'+shrink+'<a href="/api/backups/'+encodeURIComponent(b.name)+'" download class="btn btn-outline btn-sm">Download</a> <button type="button" class="btn btn-outline btn-sm rename-backup" data-name="'+escapeHtml(b.name)+'">Rename</button> <button type="button" class="btn btn-outline btn-sm delete-backup" data-name="'+escapeHtml(b.name)+'">Delete</button></td>';
tbody.appendChild(tr);
});
tbody.querySelectorAll('.set-golden-backup').forEach(function(btn){
btn.onclick = function(){ var n = btn.getAttribute('data-name'); if (!confirm('Set as golden?\n'+n)) return; authFetch('/api/backups/'+encodeURIComponent(n)+'/set-as-golden', { method: 'POST' }).then(function(r){ return r.json(); }).then(function(d){ if(d.ok) { fetchBackups(); fetchGolden(); } else alert(d.error); }).catch(function(){}); };
});
tbody.querySelectorAll('.shrink-btn').forEach(function(btn){
btn.onclick = function(){ var n = btn.getAttribute('data-name'); if (!confirm('Shrink (PiShrink)? '+n)) return; btn.disabled=true; authFetch('/api/backups/'+encodeURIComponent(n)+'/shrink', { method: 'POST' }).then(function(r){ return r.json(); }).then(function(d){ if(d.ok) fetchBackups(); else alert(d.error); }).finally(function(){ btn.disabled=false; }); };
});
tbody.querySelectorAll('.compress-btn').forEach(function(btn){
btn.onclick = function(){ var n = btn.getAttribute('data-name'); if (!confirm('Compress to .img.xz? '+n)) return; btn.disabled=true; authFetch('/api/backups/'+encodeURIComponent(n)+'/compress', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({format:'xz'}) }).then(function(r){ return r.json(); }).then(function(d){ if(d.ok) fetchBackups(); else alert(d.error); }).finally(function(){ btn.disabled=false; }); };
});
tbody.querySelectorAll('.rename-backup').forEach(function(btn){
btn.onclick = function(){ var n = btn.getAttribute('data-name'); var newName = prompt('New filename', n); if (!newName || !newName.trim()) return; authFetch('/api/backups/'+encodeURIComponent(n), { method: 'PATCH', headers: {'Content-Type':'application/json'}, body: JSON.stringify({filename: newName.trim()}) }).then(function(r){ return r.json(); }).then(function(d){ if(d.ok) fetchBackups(); else alert(d.error); }); };
});
tbody.querySelectorAll('.delete-backup').forEach(function(btn){
btn.onclick = function(){ var n = btn.getAttribute('data-name'); if (!confirm('Delete '+n+'?')) return; authFetch('/api/backups/'+encodeURIComponent(n), { method: 'DELETE' }).then(function(r){ return r.json(); }).then(function(d){ if(d.ok) { fetchBackups(); fetchGolden(); } else alert(d.error); }); };
});
}
function fetchBackups() {
authFetch('/api/backups').then(function(r){ return r.json(); }).then(function(d){ renderBackups(d.backups || [], d.backups_dir); }).catch(function(){});
}
function renderCloudinit(images) {
var tbody = document.getElementById('cloudinitBody');
var empty = document.getElementById('cloudinitEmpty');
tbody.innerHTML = '';
if (!images || images.length === 0) { empty.style.display = 'block'; return; }
empty.style.display = 'none';
images.forEach(function(b){
var tr = document.createElement('tr');
tr.innerHTML = '<td>'+escapeHtml(b.display_name||b.name)+'</td><td>'+escapeHtml(b.description||'')+'</td><td class="mono">'+fmtSize(b.size)+'</td><td class="mono">'+fmtDate(b.mtime)+'</td><td class="actions-cell"><button type="button" class="btn btn-outline btn-sm set-golden-cloudinit" data-name="'+escapeHtml(b.name)+'">Set golden</button> <a href="/api/cloudinit-images/'+encodeURIComponent(b.name)+'" download class="btn btn-outline btn-sm">Download</a> <button type="button" class="btn btn-outline btn-sm rename-cloudinit" data-name="'+escapeHtml(b.name)+'">Rename</button> <button type="button" class="btn btn-outline btn-sm delete-cloudinit" data-name="'+escapeHtml(b.name)+'">Delete</button></td>';
tbody.appendChild(tr);
});
tbody.querySelectorAll('.set-golden-cloudinit').forEach(function(btn){
btn.onclick = function(){ var n = btn.getAttribute('data-name'); if (!confirm('Set as golden?\n'+n)) return; authFetch('/api/cloudinit-images/'+encodeURIComponent(n)+'/set-as-golden', { method: 'POST' }).then(function(r){ return r.json(); }).then(function(d){ if(d.ok) { fetchCloudinit(); fetchGolden(); } else alert(d.error); }).catch(function(){}); };
});
tbody.querySelectorAll('.rename-cloudinit').forEach(function(btn){
btn.onclick = function(){ var n = btn.getAttribute('data-name'); var newName = prompt('New filename', n); if (!newName || !newName.trim()) return; authFetch('/api/cloudinit-images/'+encodeURIComponent(n), { method: 'PATCH', headers: {'Content-Type':'application/json'}, body: JSON.stringify({filename: newName.trim()}) }).then(function(r){ return r.json(); }).then(function(d){ if(d.ok) fetchCloudinit(); else alert(d.error); }); };
});
tbody.querySelectorAll('.delete-cloudinit').forEach(function(btn){
btn.onclick = function(){ var n = btn.getAttribute('data-name'); if (!confirm('Delete '+n+'?')) return; authFetch('/api/cloudinit-images/'+encodeURIComponent(n), { method: 'DELETE' }).then(function(r){ return r.json(); }).then(function(d){ if(d.ok) { fetchCloudinit(); fetchGolden(); } else alert(d.error); }); };
});
}
function fetchCloudinit() {
authFetch('/api/cloudinit-images').then(function(r){ return r.json(); }).then(function(d){ renderCloudinit(d.images || []); }).catch(function(){});
}
function renderPortal(files, baseUrl) {
var tbody = document.getElementById('portalBody');
var empty = document.getElementById('portalEmpty');
document.getElementById('portalBaseUrl').textContent = baseUrl || '';
tbody.innerHTML = '';
if (!files || files.length === 0) { empty.style.display = 'block'; return; }
empty.style.display = 'none';
files.forEach(function(f){
var tr = document.createElement('tr');
tr.innerHTML = '<td><a href="'+escapeHtml(baseUrl+f.name)+'" target="_blank" rel="noopener">'+escapeHtml(f.name)+'</a></td><td class="mono">'+fmtSize(f.size)+'</td><td><button type="button" class="btn btn-outline btn-sm delete-portal" data-name="'+escapeHtml(f.name)+'">Delete</button></td>';
tbody.appendChild(tr);
});
tbody.querySelectorAll('.delete-portal').forEach(function(btn){
btn.onclick = function(){ var n = btn.getAttribute('data-name'); if (!confirm('Delete '+n+'?')) return; authFetch('/api/portal-files/'+encodeURIComponent(n), { method: 'DELETE' }).then(function(r){ return r.json(); }).then(function(d){ if(d.ok) fetchPortal(); else alert(d.error); }); };
});
}
function fetchPortal() {
authFetch('/api/portal-files').then(function(r){ return r.json(); }).then(function(d){ renderPortal(d.files || [], d.base_url); }).catch(function(){});
}
function renderUsers(users) {
var tbody = document.getElementById('usersBody');
tbody.innerHTML = '';
(users || []).forEach(function(u){
var tr = document.createElement('tr');
tr.innerHTML = '<td>'+escapeHtml(u.username)+'</td><td class="mono">'+fmtDate(u.created_at)+'</td><td><button type="button" class="btn btn-outline btn-sm chpwd-btn" data-id="'+u.id+'" data-username="'+escapeHtml(u.username)+'">Change password</button></td>';
tbody.appendChild(tr);
});
tbody.querySelectorAll('.chpwd-btn').forEach(function(btn){
btn.onclick = function(){ var id = btn.getAttribute('data-id'); var username = btn.getAttribute('data-username'); var pw = prompt('New password for '+username+' (min 6 characters)'); if (!pw || pw.length < 6) { if(pw!==null) alert('Min 6 characters'); return; } authFetch('/api/admin/users/'+id+'/password', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({password: pw}) }).then(function(r){ return r.json(); }).then(function(d){ if(d.ok) alert('Password updated'); else alert(d.error); }); };
});
}
function fetchUsers() {
authFetch('/api/admin/users').then(function(r){ return r.json(); }).then(function(d){ renderUsers(d.users); }).catch(function(){});
}
function renderLogs(logs) {
var el = document.getElementById('adminLogs');
if (!logs || logs.length === 0) { el.textContent = 'No logs.'; return; }
el.innerHTML = logs.map(function(l){ return fmtDate(l.created_at) + ' ' + (l.username || '') + ' ' + escapeHtml(l.action) + (l.details ? ' ' + escapeHtml(l.details) : ''); }).join('\n');
}
function fetchLogs() {
authFetch('/api/admin/logs').then(function(r){ return r.json(); }).then(function(d){ renderLogs(d.logs); }).catch(function(){});
}
function fetchBuildStatus() {
authFetch('/api/build-cloudinit-status').then(function(r){ return r.json(); }).then(function(d){
var el = document.getElementById('buildCloudInitStatus');
var btn = document.getElementById('buildCloudInitBtn');
var busy = ['resolving','downloading','decompressing','injecting','finalizing'].indexOf(d.phase) >= 0;
if(btn) btn.disabled = busy;
if(d.phase === 'idle' && !d.message) el.textContent = '';
else if(d.phase === 'done') { el.textContent = 'Done: '+ (d.output_name||'') + ' — see Cloud-init images above.'; fetchCloudinit(); fetchGolden(); }
else if(d.phase === 'error') el.textContent = 'Error: ' + (d.error||'');
else el.textContent = (d.phase||'') + ': ' + (d.message||'');
if(busy) setTimeout(fetchBuildStatus, 5000);
}).catch(function(){});
}
function startBuild() {
var btn = document.getElementById('buildCloudInitBtn');
if(btn) btn.disabled = true;
var body = { variant: document.getElementById('buildVariant').value, set_as_golden_after: document.getElementById('buildSetGolden').checked };
var ud = document.getElementById('buildUserData').value.trim();
var md = document.getElementById('buildMetaData').value.trim();
var nc = document.getElementById('buildNetworkConfig').value.trim();
if(ud) body.user_data = ud; if(md) body.meta_data = md; if(nc) body.network_config = nc;
authFetch('/api/build-cloudinit', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body) }).then(function(r){ return r.json(); }).then(function(d){
if(d.ok) { document.getElementById('buildCloudInitStatus').textContent = 'Build started…'; setTimeout(fetchBuildStatus, 2000); }
else { alert(d.error); if(btn) btn.disabled = false; }
}).catch(function(){ if(btn) btn.disabled = false; });
}
function fetchTemplates() {
authFetch('/api/cloudinit-templates').then(function(r){ return r.json(); }).then(function(d){
var list = d.templates || [];
var sel = document.getElementById('buildTemplateSelect');
sel.innerHTML = '<option value="">— Load —</option>';
list.forEach(function(t){ var o = document.createElement('option'); o.value = t.id; o.textContent = t.name; sel.appendChild(o); });
var ul = document.getElementById('buildTemplateList');
ul.innerHTML = list.map(function(t){ return '<li>'+escapeHtml(t.name)+' <button type="button" class="btn btn-outline btn-sm load-tpl" data-id="'+t.id+'">Load</button> <button type="button" class="btn btn-outline btn-sm del-tpl" data-id="'+t.id+'">Delete</button></li>'; }).join('') || '<li>No templates</li>';
ul.querySelectorAll('.load-tpl').forEach(function(b){ b.onclick = function(){ authFetch('/api/cloudinit-templates/'+b.getAttribute('data-id')).then(function(r){ return r.json(); }).then(function(t){ document.getElementById('buildUserData').value = t.user_data||''; document.getElementById('buildMetaData').value = t.meta_data||''; document.getElementById('buildNetworkConfig').value = t.network_config||''; }); }; });
ul.querySelectorAll('.del-tpl').forEach(function(b){ b.onclick = function(){ if(!confirm('Delete template?')) return; authFetch('/api/cloudinit-templates/'+b.getAttribute('data-id'), { method: 'DELETE' }).then(function(r){ return r.json(); }).then(function(d){ if(d.ok) fetchTemplates(); }); }; });
}).catch(function(){});
}
function loadTemplateFromSelect() { var s = document.getElementById('buildTemplateSelect'); if(s && s.value) authFetch('/api/cloudinit-templates/'+s.value).then(function(r){ return r.json(); }).then(function(t){ document.getElementById('buildUserData').value = t.user_data||''; document.getElementById('buildMetaData').value = t.meta_data||''; document.getElementById('buildNetworkConfig').value = t.network_config||''; }); }
function saveTemplate() { var name = prompt('Template name'); if(!name || !name.trim()) return; var body = { name: name.trim(), user_data: document.getElementById('buildUserData').value, meta_data: document.getElementById('buildMetaData').value, network_config: document.getElementById('buildNetworkConfig').value }; authFetch('/api/cloudinit-templates', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body) }).then(function(r){ return r.json(); }).then(function(d){ if(d.ok) { fetchTemplates(); alert('Saved'); } else alert(d.error); }); }
document.getElementById('refreshBackupsBtn').onclick = fetchBackups;
document.getElementById('uploadBackupBtn').onclick = function(){ document.getElementById('uploadBackupInput').click(); };
document.getElementById('uploadBackupInput').onchange = function(){
var f = this.files && this.files[0];
if(!f) return;
var fd = new FormData(); fd.append('file', f);
authFetch('/api/backups/upload', { method: 'POST', body: fd }).then(function(r){ return r.json(); }).then(function(d){ if(d.ok) { fetchBackups(); fetchGolden(); alert('Uploaded '+d.name); } else alert(d.error); }).catch(function(){});
this.value = '';
};
document.getElementById('refreshCloudinitBtn').onclick = fetchCloudinit;
document.getElementById('buildCloudInitBtn').onclick = startBuild;
document.getElementById('buildVariant').onchange = function(){ authFetch('/api/raspios-latest-url?variant='+encodeURIComponent(document.getElementById('buildVariant').value)).then(function(r){ return r.json(); }).then(function(d){ document.getElementById('buildRaspiosUrl').textContent = d.ok && d.filename ? d.filename : (d.error||''); }); };
document.getElementById('buildTemplateLoad').onclick = loadTemplateFromSelect;
document.getElementById('buildTemplateSave').onclick = saveTemplate;
document.getElementById('uploadPortalBtn').onclick = function(){ document.getElementById('uploadPortalInput').click(); };
document.getElementById('uploadPortalInput').onchange = function(){
var f = this.files && this.files[0];
if(!f) return;
var fd = new FormData(); fd.append('file', f);
authFetch('/api/portal-files/upload', { method: 'POST', body: fd }).then(function(r){ return r.json(); }).then(function(d){ if(d.ok) { fetchPortal(); alert('Uploaded. URL: '+d.url); } else alert(d.error); }).catch(function(){});
this.value = '';
};
document.getElementById('refreshLogsBtn').onclick = fetchLogs;
document.getElementById('addUserBtn').onclick = function(){ document.getElementById('addUserForm').style.display = 'block'; };
document.getElementById('addUserCancel').onclick = function(){ document.getElementById('addUserForm').style.display = 'none'; };
document.getElementById('addUserSubmit').onclick = function(){
var username = document.getElementById('newUsername').value.trim();
var password = document.getElementById('newPassword').value;
if(!username) { alert('Username required'); return; }
if(password.length < 6) { alert('Password min 6 characters'); return; }
authFetch('/api/admin/users', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({username: username, password: password}) }).then(function(r){ return r.json(); }).then(function(d){ if(d.ok) { document.getElementById('addUserForm').style.display = 'none'; document.getElementById('newUsername').value = ''; document.getElementById('newPassword').value = ''; fetchUsers(); } else alert(d.error); });
};
fetchBackups(); fetchCloudinit(); fetchPortal(); fetchUsers(); fetchLogs(); fetchGolden(); fetchBuildStatus(); fetchTemplates();
authFetch('/api/raspios-latest-url').then(function(r){ return r.json(); }).then(function(d){ document.getElementById('buildRaspiosUrl').textContent = d.ok && d.filename ? d.filename : (d.error||''); });
setInterval(fetchBuildStatus, 15000);
setInterval(fetchLogs, 30000);
</script>
</body>
</html>

View File

@@ -0,0 +1,224 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Deploy · CM4 Provisioning</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&family=Outfit:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
:root {
--bg-primary: #0a0e14;
--bg-secondary: #11151c;
--bg-card: #151a24;
--accent: #00d4aa;
--accent-dim: #00b894;
--text: #e6e8eb;
--text-dim: #8b949e;
--text-muted: #5c6370;
--border: #2d333b;
--danger: #f87171;
--warn: #fbbf24;
--success: #00d4aa;
--radius: 10px;
--radius-sm: 6px;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Outfit', sans-serif; background: var(--bg-primary); color: var(--text); min-height: 100vh; font-size: 14px; line-height: 1.45; }
.wrap { max-width: 1100px; margin: 0 auto; padding: 1rem; }
.header { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 0.75rem; margin-bottom: 1rem; padding-bottom: 0.75rem; border-bottom: 1px solid var(--border); }
.header h1 { font-size: 1.25rem; font-weight: 700; letter-spacing: -0.02em; }
.header a { color: var(--text-dim); text-decoration: none; font-size: 0.9rem; }
.header a:hover { color: var(--accent); }
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
@media (max-width: 700px) { .grid { grid-template-columns: 1fr; } }
.card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1rem;
}
.card-title { font-size: 0.7rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-dim); margin-bottom: 0.6rem; }
.status-row { display: flex; align-items: center; gap: 0.6rem; flex-wrap: wrap; }
.status-pill { font-size: 0.7rem; font-weight: 600; text-transform: uppercase; padding: 0.25rem 0.6rem; border-radius: 999px; }
.status-pill.idle { background: rgba(0,212,170,0.15); color: var(--accent); }
.status-pill.waiting_choice, .status-pill.rpiboot, .status-pill.flashing, .status-pill.backup { background: rgba(251,191,36,0.15); color: var(--warn); }
.status-pill.done { background: rgba(0,212,170,0.15); color: var(--success); }
.status-pill.error { background: rgba(248,113,113,0.15); color: var(--danger); }
.status-msg { flex: 1; min-width: 0; font-size: 0.9rem; }
.status-err { width: 100%; margin-top: 0.4rem; font-size: 0.8rem; color: var(--danger); }
.progress-track { margin-top: 0.5rem; height: 3px; background: var(--border); border-radius: 2px; overflow: hidden; }
.progress-fill { height: 100%; background: var(--accent); transition: width 0.3s; }
.progress-fill.indeterminate { width: 35%; animation: slide 1.2s ease-in-out infinite; }
@keyframes slide { 0% { transform: translateX(-100%); } 100% { transform: translateX(400%); } }
.device-item {
display: flex; align-items: center; justify-content: space-between; gap: 0.75rem;
padding: 0.6rem 0.75rem; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius-sm);
margin-bottom: 0.4rem;
}
.device-desc { font-size: 0.85rem; color: var(--text-dim); }
.btn { padding: 0.4rem 0.8rem; font-size: 0.8rem; font-weight: 500; font-family: inherit; border-radius: var(--radius-sm); cursor: pointer; border: none; transition: background 0.15s, color 0.15s; }
.btn-outline { background: transparent; border: 1px solid var(--border); color: var(--text); }
.btn-outline:hover { border-color: var(--accent); color: var(--accent); }
.btn-primary { background: var(--accent); color: var(--bg-primary); }
.btn-primary:hover { background: var(--accent-dim); }
.btn-sm { padding: 0.3rem 0.5rem; font-size: 0.75rem; }
.golden-info { font-size: 0.85rem; color: var(--text-dim); }
.golden-info strong { color: var(--text); }
.empty-msg { text-align: center; padding: 1rem; font-size: 0.85rem; color: var(--text-muted); }
details { margin-top: 0.5rem; border: 1px solid var(--border); border-radius: var(--radius-sm); overflow: hidden; }
summary { padding: 0.5rem 0.75rem; font-size: 0.85rem; font-weight: 500; color: var(--text-dim); cursor: pointer; list-style: none; }
summary::-webkit-details-marker { display: none; }
summary::before { content: '▶'; display: inline-block; margin-right: 0.4rem; font-size: 0.6rem; transition: transform 0.2s; }
details[open] summary::before { transform: rotate(90deg); }
details .inner { padding: 0.75rem; padding-top: 0; font-size: 0.8rem; color: var(--text-dim); border-top: 1px solid var(--border); }
.log-pre { font-family: 'JetBrains Mono', monospace; font-size: 0.75rem; background: var(--bg-secondary); border-radius: var(--radius-sm); padding: 0.5rem 0.75rem; max-height: 160px; overflow: auto; white-space: pre-wrap; word-break: break-all; color: var(--text-muted); }
.steps-list { list-style: none; }
.steps-list li { display: flex; gap: 0.5rem; padding: 0.3rem 0; }
.steps-list .num { flex-shrink: 0; width: 1.2rem; height: 1.2rem; background: var(--bg-secondary); color: var(--text-dim); border-radius: 50%; font-size: 0.7rem; font-weight: 600; display: inline-flex; align-items: center; justify-content: center; }
.steps-list strong { color: var(--text); }
.help-sub { font-weight: 600; color: var(--text); margin: 0.5rem 0 0.2rem 0; font-size: 0.85rem; }
</style>
</head>
<body>
<div class="wrap">
<header class="header">
<h1>CM4 eMMC Provisioning</h1>
<a href="/admin">Admin</a>
</header>
<div class="grid">
<!-- Left: Status + Deploy -->
<div>
<div class="card" style="margin-bottom: 1rem;">
<h2 class="card-title">Status</h2>
<div id="status" class="status-row">
<span id="statusPill" class="status-pill idle">Idle</span>
<span id="statusMsg" class="status-msg">Waiting for device</span>
</div>
<div id="statusErr" class="status-err" style="display:none;"></div>
<div id="statusClearWrap" style="display:none; margin-top:0.5rem;"><button type="button" id="statusClearBtn" class="btn btn-outline btn-sm">Clear message</button></div>
<div id="progressWrap" class="progress-track" style="display:none;"><div id="progressFill" class="progress-fill"></div></div>
</div>
<div class="card">
<h2 class="card-title">Capture or deploy</h2>
<p id="shrinkOptionWrap" style="display:none; margin-bottom: 0.5rem; font-size: 0.8rem;"><label><input type="checkbox" id="shrinkAfterBackup" /> Shrink after backup</label></p>
<div id="pendingDevices"></div>
<p id="noPending" class="empty-msg" style="display:none;">No device connected. Use USB boot mode or register over network.</p>
</div>
</div>
<!-- Right: Golden + Log + Connect -->
<div>
<div class="card" style="margin-bottom: 1rem;">
<h2 class="card-title">Current deploy image</h2>
<p id="goldenInfo" class="golden-info">Loading…</p>
</div>
<details class="card" style="padding: 0;">
<summary>Recent log</summary>
<div class="inner"><pre id="log" class="log-pre"></pre></div>
</details>
<details class="card" style="padding: 0; margin-top: 1rem;">
<summary>How to connect</summary>
<div class="inner">
<p class="help-sub">USB boot</p>
<ol class="steps-list">
<li><span class="num">1</span> Set reTerminal to <strong>boot mode</strong> (eMMC disable jumper).</li>
<li><span class="num">2</span> Connect USB to host; choose <strong>Backup</strong> or <strong>Deploy</strong> above.</li>
<li><span class="num">3</span> Remove jumper and power cycle when done.</li>
</ol>
<p class="help-sub">Network</p>
<ol class="steps-list">
<li><span class="num">1</span> Enable network boot; device must reach this server.</li>
<li><span class="num">2</span> Boot with provisioning client; choose <strong>Backup</strong> or <strong>Deploy</strong>.</li>
</ol>
</div>
</details>
</div>
</div>
</div>
<script>
const phaseLabels = { idle: 'Idle', rpiboot: 'Connecting', waiting_choice: 'Choose action', flashing: 'Flashing', backup: 'Backing up', done: 'Done', error: 'Error' };
function renderStatus(data) {
const phase = data.phase || 'idle';
document.getElementById('statusPill').className = 'status-pill ' + phase;
document.getElementById('statusPill').textContent = phaseLabels[phase] || phase;
document.getElementById('statusMsg').textContent = data.message || '';
const err = document.getElementById('statusErr');
if (data.error) { err.textContent = data.error; err.style.display = 'block'; } else { err.style.display = 'none'; }
var clearWrap = document.getElementById('statusClearWrap');
if (clearWrap) clearWrap.style.display = (phase === 'error' ? 'block' : 'none');
const progress = data.progress;
const inProgress = ['rpiboot', 'flashing', 'backup'].includes(phase);
const wrap = document.getElementById('progressWrap');
const fill = document.getElementById('progressFill');
if (inProgress || (phase === 'done' && progress != null)) {
wrap.style.display = 'block';
fill.classList.remove('indeterminate');
fill.style.width = (progress != null ? progress + '%' : '35%');
if (progress == null) fill.classList.add('indeterminate');
} else wrap.style.display = 'none';
}
function renderPending(usb, network) {
const container = document.getElementById('pendingDevices');
const noPending = document.getElementById('noPending');
container.innerHTML = '';
let hasAny = false;
const shrinkWrap = document.getElementById('shrinkOptionWrap');
if (usb) {
hasAny = true;
shrinkWrap.style.display = 'block';
const el = document.createElement('div');
el.className = 'device-item';
el.innerHTML = '<div class="device-desc">USB device — choose action</div><div><button type="button" class="btn btn-outline btn-sm" data-source="usb" data-action="backup">Backup</button> <button type="button" class="btn btn-primary btn-sm" data-source="usb" data-action="deploy">Deploy</button></div>';
container.appendChild(el);
} else shrinkWrap.style.display = 'none';
(network || []).forEach(function(d) {
hasAny = true;
const el = document.createElement('div');
el.className = 'device-item';
el.innerHTML = '<div class="device-desc">' + escapeHtml(d.ip || '') + ' · ' + escapeHtml(d.mac || '') + '</div><div><button type="button" class="btn btn-outline btn-sm" data-source="network" data-mac="' + escapeHtml(d.mac || '') + '" data-action="backup">Backup</button> <button type="button" class="btn btn-primary btn-sm" data-source="network" data-mac="' + escapeHtml(d.mac || '') + '" data-action="deploy">Deploy</button></div>';
container.appendChild(el);
});
noPending.style.display = hasAny ? 'none' : 'block';
container.querySelectorAll('button[data-action]').forEach(function(btn) {
btn.onclick = function() {
const body = { source: btn.getAttribute('data-source'), action: btn.getAttribute('data-action') };
if (body.source === 'network') body.mac = btn.getAttribute('data-mac');
if (body.action === 'backup' && document.getElementById('shrinkAfterBackup').checked) body.shrink = true;
fetch('/api/device-action', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
.then(function(r) { return r.json(); })
.then(function(data) { if (data.ok) { fetchPending(); fetchStatus(); } else alert(data.error || 'Failed'); })
.catch(function() { alert('Request failed'); });
};
});
}
function escapeHtml(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
function fmtSize(n) { if (n >= 1e9) return (n/1e9).toFixed(1)+' GB'; if (n >= 1e6) return (n/1e6).toFixed(1)+' MB'; return (n/1e3).toFixed(0)+' KB'; }
function fmtDate(ts) { return new Date(ts*1000).toLocaleString(); }
function fetchStatus() { fetch('/api/status').then(function(r){ return r.json(); }).then(renderStatus).catch(function(){ renderStatus({ phase: 'error', message: 'Could not load status.' }); }); }
function fetchPending() { fetch('/api/pending-devices').then(function(r){ return r.json(); }).then(function(d){ renderPending(d.usb || null, d.network || []); }).catch(function(){ renderPending(null, []); }); }
function fetchLog() { fetch('/api/log').then(function(r){ return r.json(); }).then(function(d){ document.getElementById('log').textContent = d.log || ''; }).catch(function(){}); }
function fetchGolden() {
fetch('/api/golden-info').then(function(r){ return r.json(); }).then(function(d){
const el = document.getElementById('goldenInfo');
if (!d.present) { el.innerHTML = 'No image set. An admin must set a backup or cloud-init image as golden for Deploy.'; return; }
let t = 'Ready for deploy: ' + fmtSize(d.size) + ', updated ' + fmtDate(d.mtime);
if (d.name) t += ' <br><strong>' + (d.source === 'cloudinit' ? 'Cloud-init' : 'Backup') + ':</strong> ' + escapeHtml(d.name);
el.innerHTML = t;
}).catch(function(){ document.getElementById('goldenInfo').textContent = 'Could not load.'; });
}
document.getElementById('statusClearBtn').addEventListener('click', function(){ fetch('/api/status-clear', { method: 'POST' }).then(function(r){ return r.json(); }).then(function(d){ if(d.ok) fetchStatus(); }); });
fetchStatus(); fetchLog(); fetchPending(); fetchGolden();
setInterval(fetchStatus, 2000);
setInterval(fetchLog, 4000);
setInterval(fetchPending, 2000);
setInterval(fetchGolden, 10000);
</script>
</body>
</html>

View File

@@ -0,0 +1,42 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Admin login · CM4 Provisioning</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
:root { --bg: #0a0e14; --bg-card: #151a24; --accent: #00d4aa; --text: #e6e8eb; --text-dim: #8b949e; --border: #2d333b; --danger: #f87171; --radius: 10px; }
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Outfit', sans-serif; background: var(--bg); color: var(--text); min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 1rem; }
.box { width: 100%; max-width: 360px; background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 2rem; }
h1 { font-size: 1.25rem; margin-bottom: 0.5rem; }
p.sub { font-size: 0.9rem; color: var(--text-dim); margin-bottom: 1.5rem; }
.err { font-size: 0.85rem; color: var(--danger); margin-bottom: 1rem; }
label { display: block; font-size: 0.85rem; font-weight: 500; margin-bottom: 0.35rem; color: var(--text-dim); }
input[type="text"], input[type="password"] { width: 100%; padding: 0.6rem 0.75rem; font: inherit; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--text); margin-bottom: 1rem; }
input:focus { outline: none; border-color: var(--accent); }
button { width: 100%; padding: 0.65rem; font: inherit; font-weight: 600; background: var(--accent); color: var(--bg); border: none; border-radius: 6px; cursor: pointer; }
button:hover { background: #00b894; }
a.back { display: inline-block; margin-top: 1rem; font-size: 0.9rem; color: var(--text-dim); text-decoration: none; }
a.back:hover { color: var(--accent); }
</style>
</head>
<body>
<div class="box">
<h1>Admin login</h1>
<p class="sub">CM4 eMMC provisioning dashboard</p>
{% if error %}<p class="err">{{ error }}</p>{% endif %}
<form method="post" action="{{ url_for('login') }}">
<label for="username">Username</label>
<input type="text" id="username" name="username" required autofocus autocomplete="username" value="{{ request.form.username or '' }}">
<label for="password">Password</label>
<input type="password" id="password" name="password" required autocomplete="current-password">
<button type="submit">Log in</button>
</form>
<a href="{{ url_for('index') }}" class="back">← Back to deploy</a>
</div>
</body>
</html>