feat: interface admin portail.alpinux.org/admin/ — déclencher mkdocs build
Mini-app Flask (admin/) accessible à https://portail.alpinux.org/admin/ : - Authentification AlpID OIDC, accès restreint au groupe « admins » Keycloak - Bouton « Lancer mkdocs build » avec confirmation - Exécution de deploy-wiki.sh en arrière-plan (thread), log capturé en direct - Polling JS toutes les 2s pendant le build (status + log + historique) - Affichage du journal en terminal sombre avec suivi automatique - Historique des 20 derniers builds (date, déclencheur, résultat, durée) - ProxyFix pour X-Forwarded-Proto / X-Script-Name (Apache reverse proxy) Infrastructure : - scripts/portail.alpinux.org.admin.conf : bloc ProxyPass pour ISPConfig - scripts/alpinux-admin.service : systemd Gunicorn port 5002 - admin/.env.example, admin/requirements.txt Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
00bd942110
commit
60eb8bc952
9 changed files with 560 additions and 0 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -4,6 +4,9 @@ dynamic/__pycache__/
|
||||||
dynamic/routes/__pycache__/
|
dynamic/routes/__pycache__/
|
||||||
dynamic/venv/
|
dynamic/venv/
|
||||||
dynamic/.env
|
dynamic/.env
|
||||||
|
admin/__pycache__/
|
||||||
|
admin/venv/
|
||||||
|
admin/.env
|
||||||
|
|
||||||
# Assets binaires — générés par scripts/build-assets.py
|
# Assets binaires — générés par scripts/build-assets.py
|
||||||
# Hébergés sur https://static.alpinux.org/logo/
|
# Hébergés sur https://static.alpinux.org/logo/
|
||||||
|
|
|
||||||
11
admin/.env.example
Normal file
11
admin/.env.example
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
SECRET_KEY=changez-moi-avec-une-valeur-aleatoire-longue
|
||||||
|
|
||||||
|
ALPID_CLIENT_ID=alpinux-admin
|
||||||
|
ALPID_CLIENT_SECRET=
|
||||||
|
ALPID_DISCOVERY_URL=https://alpid.alpinux.org/realms/alpinux/.well-known/openid-configuration
|
||||||
|
|
||||||
|
# Noms des groupes Keycloak autorisés (séparés par virgule)
|
||||||
|
ADMIN_GROUPS=admins
|
||||||
|
|
||||||
|
# Chemin du script de déploiement (défaut dans builds.py si non défini)
|
||||||
|
# DEPLOY_SCRIPT=/home/alpinux/site/scripts/deploy-wiki.sh
|
||||||
120
admin/app.py
Normal file
120
admin/app.py
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
import os
|
||||||
|
from flask import Flask, redirect, url_for, session, request, jsonify, render_template, abort
|
||||||
|
from authlib.integrations.flask_client import OAuth
|
||||||
|
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||||
|
|
||||||
|
import builds
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.secret_key = os.environ["SECRET_KEY"]
|
||||||
|
|
||||||
|
# Gère X-Forwarded-Proto et X-Script-Name envoyés par Apache
|
||||||
|
app.wsgi_app = ProxyFix(app.wsgi_app, x_proto=1, x_prefix=1)
|
||||||
|
|
||||||
|
# ── OIDC AlpID ────────────────────────────────────────────────────
|
||||||
|
oauth = OAuth(app)
|
||||||
|
oauth.register(
|
||||||
|
name="alpid",
|
||||||
|
server_metadata_url=os.environ["ALPID_DISCOVERY_URL"],
|
||||||
|
client_id=os.environ["ALPID_CLIENT_ID"],
|
||||||
|
client_secret=os.environ["ALPID_CLIENT_SECRET"],
|
||||||
|
client_kwargs={"scope": "openid profile email groups"},
|
||||||
|
)
|
||||||
|
|
||||||
|
ADMIN_GROUPS = set(os.environ.get("ADMIN_GROUPS", "admins").split(","))
|
||||||
|
|
||||||
|
|
||||||
|
def _user():
|
||||||
|
return session.get("user")
|
||||||
|
|
||||||
|
|
||||||
|
def _require_admin():
|
||||||
|
user = _user()
|
||||||
|
if not user:
|
||||||
|
session["next_url"] = request.url
|
||||||
|
return redirect(url_for("login"))
|
||||||
|
if not user.get("is_admin"):
|
||||||
|
abort(403)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ── Auth ──────────────────────────────────────────────────────────
|
||||||
|
@app.route("/auth/login")
|
||||||
|
def login():
|
||||||
|
return oauth.alpid.authorize_redirect(url_for("callback", _external=True))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/auth/callback")
|
||||||
|
def callback():
|
||||||
|
token = oauth.alpid.authorize_access_token()
|
||||||
|
info = token.get("userinfo") or oauth.alpid.userinfo(token=token)
|
||||||
|
|
||||||
|
# Keycloak expose les groupes dans le claim "groups" (à activer dans le mapper)
|
||||||
|
groups = set(info.get("groups", []))
|
||||||
|
is_admin = bool(groups & ADMIN_GROUPS)
|
||||||
|
|
||||||
|
session["user"] = {
|
||||||
|
"sub": info["sub"],
|
||||||
|
"name": info.get("name") or info.get("preferred_username", "Admin"),
|
||||||
|
"email": info.get("email", ""),
|
||||||
|
"is_admin": is_admin,
|
||||||
|
}
|
||||||
|
return redirect(session.pop("next_url", url_for("index")))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/auth/logout")
|
||||||
|
def logout():
|
||||||
|
session.clear()
|
||||||
|
return redirect(url_for("index"))
|
||||||
|
|
||||||
|
|
||||||
|
# ── Pages ─────────────────────────────────────────────────────────
|
||||||
|
@app.route("/")
|
||||||
|
def index():
|
||||||
|
redir = _require_admin()
|
||||||
|
if redir:
|
||||||
|
return redir
|
||||||
|
state = builds.get_state()
|
||||||
|
log = builds.get_log()
|
||||||
|
return render_template("index.html", user=_user(), state=state, log=log)
|
||||||
|
|
||||||
|
|
||||||
|
# ── API JSON ──────────────────────────────────────────────────────
|
||||||
|
@app.route("/api/build", methods=["POST"])
|
||||||
|
def api_build():
|
||||||
|
redir = _require_admin()
|
||||||
|
if redir:
|
||||||
|
return jsonify({"error": "non authentifié"}), 401
|
||||||
|
user = _user()
|
||||||
|
ok = builds.start_build(triggered_by=user["name"])
|
||||||
|
if ok:
|
||||||
|
return jsonify({"started": True})
|
||||||
|
return jsonify({"started": False, "reason": "Un build est déjà en cours"}), 409
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/status")
|
||||||
|
def api_status():
|
||||||
|
redir = _require_admin()
|
||||||
|
if redir:
|
||||||
|
return jsonify({"error": "non authentifié"}), 401
|
||||||
|
state = builds.get_state()
|
||||||
|
return jsonify({
|
||||||
|
"running": state.get("running", False),
|
||||||
|
"last_success": state.get("last_success"),
|
||||||
|
"last_duration": state.get("last_duration_s"),
|
||||||
|
"started_at": state.get("started_at"),
|
||||||
|
"triggered_by": state.get("triggered_by"),
|
||||||
|
"history": state.get("history", [])[:5],
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/log")
|
||||||
|
def api_log():
|
||||||
|
redir = _require_admin()
|
||||||
|
if redir:
|
||||||
|
return jsonify({"error": "non authentifié"}), 401
|
||||||
|
return jsonify({"log": builds.get_log()})
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app.run(debug=False, port=5002)
|
||||||
102
admin/builds.py
Normal file
102
admin/builds.py
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
"""
|
||||||
|
Gestion des builds : exécution, état, historique.
|
||||||
|
Un seul build peut tourner à la fois.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
STATE_FILE = Path("/var/lib/alpinux-admin/builds.json")
|
||||||
|
LOG_FILE = Path("/var/lib/alpinux-admin/current.log")
|
||||||
|
DEPLOY_SCRIPT = Path("/home/alpinux/site/scripts") / "deploy-wiki.sh"
|
||||||
|
|
||||||
|
_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def _load_state() -> dict:
|
||||||
|
if STATE_FILE.exists():
|
||||||
|
try:
|
||||||
|
return json.loads(STATE_FILE.read_text())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return {"running": False, "history": []}
|
||||||
|
|
||||||
|
|
||||||
|
def _save_state(state: dict):
|
||||||
|
STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
STATE_FILE.write_text(json.dumps(state, indent=2))
|
||||||
|
|
||||||
|
|
||||||
|
def get_state() -> dict:
|
||||||
|
return _load_state()
|
||||||
|
|
||||||
|
|
||||||
|
def get_log(lines: int = 200) -> str:
|
||||||
|
if LOG_FILE.exists():
|
||||||
|
all_lines = LOG_FILE.read_text(encoding="utf-8", errors="replace").splitlines()
|
||||||
|
return "\n".join(all_lines[-lines:])
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def is_running() -> bool:
|
||||||
|
return _load_state().get("running", False)
|
||||||
|
|
||||||
|
|
||||||
|
def start_build(triggered_by: str) -> bool:
|
||||||
|
"""Lance le build dans un thread. Retourne False si déjà en cours."""
|
||||||
|
with _lock:
|
||||||
|
state = _load_state()
|
||||||
|
if state.get("running"):
|
||||||
|
return False
|
||||||
|
state["running"] = True
|
||||||
|
state["started_at"] = datetime.now().isoformat(timespec="seconds")
|
||||||
|
state["triggered_by"] = triggered_by
|
||||||
|
_save_state(state)
|
||||||
|
|
||||||
|
LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
LOG_FILE.write_text("")
|
||||||
|
|
||||||
|
threading.Thread(target=_run_build, args=(triggered_by,), daemon=True).start()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _run_build(triggered_by: str):
|
||||||
|
started = time.time()
|
||||||
|
success = False
|
||||||
|
try:
|
||||||
|
with open(LOG_FILE, "w", buffering=1) as log:
|
||||||
|
log.write(f"=== Build démarré le {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} par {triggered_by} ===\n\n")
|
||||||
|
proc = subprocess.Popen(
|
||||||
|
["bash", str(DEPLOY_SCRIPT)],
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.STDOUT,
|
||||||
|
text=True,
|
||||||
|
bufsize=1,
|
||||||
|
)
|
||||||
|
for line in proc.stdout:
|
||||||
|
log.write(line)
|
||||||
|
log.flush()
|
||||||
|
proc.wait()
|
||||||
|
success = proc.returncode == 0
|
||||||
|
log.write(f"\n=== Terminé (code {proc.returncode}) en {int(time.time()-started)}s ===\n")
|
||||||
|
except Exception as exc:
|
||||||
|
with open(LOG_FILE, "a") as log:
|
||||||
|
log.write(f"\n=== ERREUR : {exc} ===\n")
|
||||||
|
|
||||||
|
with _lock:
|
||||||
|
state = _load_state()
|
||||||
|
state["running"] = False
|
||||||
|
state["last_success"] = success
|
||||||
|
state["last_duration_s"] = int(time.time() - started)
|
||||||
|
entry = {
|
||||||
|
"at": datetime.now().isoformat(timespec="seconds"),
|
||||||
|
"triggered_by": triggered_by,
|
||||||
|
"success": success,
|
||||||
|
"duration_s": int(time.time() - started),
|
||||||
|
}
|
||||||
|
state.setdefault("history", []).insert(0, entry)
|
||||||
|
state["history"] = state["history"][:20]
|
||||||
|
_save_state(state)
|
||||||
4
admin/requirements.txt
Normal file
4
admin/requirements.txt
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
flask>=3.0
|
||||||
|
authlib>=1.3
|
||||||
|
requests>=2.31
|
||||||
|
gunicorn>=21.0
|
||||||
71
admin/static/admin.css
Normal file
71
admin/static/admin.css
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
:root {
|
||||||
|
--blue: #1a6bbf;
|
||||||
|
--blue-dark: #0f4e8f;
|
||||||
|
--blue-light:#e8f1fb;
|
||||||
|
--bg: #f3f6fb;
|
||||||
|
--text: #1a1a2e;
|
||||||
|
--muted: #666;
|
||||||
|
--radius: 10px;
|
||||||
|
--shadow: 0 2px 12px rgba(26,107,191,.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: 'Segoe UI', system-ui, sans-serif; background: var(--bg); color: var(--text); min-height: 100vh; }
|
||||||
|
code { background: #e8eef7; padding: .1rem .4rem; border-radius: 4px; font-size: .88em; }
|
||||||
|
a { color: var(--blue); }
|
||||||
|
|
||||||
|
/* ── Header ── */
|
||||||
|
header { background: var(--blue-dark); color: #fff; }
|
||||||
|
.header-inner { max-width: 900px; margin: 0 auto; padding: .8rem 1.5rem; display: flex; align-items: center; gap: 1rem; }
|
||||||
|
.brand { display: flex; align-items: center; gap: .6rem; color: #fff; font-size: 1.1rem; text-decoration: none; }
|
||||||
|
.brand strong { font-weight: 800; }
|
||||||
|
.brand-sub { opacity: .7; font-weight: 300; }
|
||||||
|
.brand img { border-radius: 5px; }
|
||||||
|
.header-user { margin-left: auto; display: flex; align-items: center; gap: 1rem; color: rgba(255,255,255,.85); font-size: .9rem; }
|
||||||
|
.btn-logout { color: rgba(255,255,255,.75); font-size: .85rem; border: 1px solid rgba(255,255,255,.4); border-radius: 4px; padding: .3rem .7rem; text-decoration: none; }
|
||||||
|
.btn-logout:hover { background: rgba(255,255,255,.1); color: #fff; }
|
||||||
|
|
||||||
|
/* ── Main ── */
|
||||||
|
main { max-width: 900px; margin: 2rem auto; padding: 0 1.5rem 3rem; display: flex; flex-direction: column; gap: 1.5rem; }
|
||||||
|
|
||||||
|
/* ── Build card ── */
|
||||||
|
.build-card { background: #fff; border-radius: var(--radius); box-shadow: var(--shadow); padding: 1.8rem 2rem; }
|
||||||
|
.build-card-header { display: flex; align-items: flex-start; gap: 1rem; justify-content: space-between; flex-wrap: wrap; margin-bottom: .8rem; }
|
||||||
|
.build-card-header h2 { font-size: 1.1rem; margin-bottom: .2rem; }
|
||||||
|
.subtitle { font-size: .85rem; color: var(--muted); }
|
||||||
|
.build-meta { display: flex; gap: 1.5rem; font-size: .85rem; color: var(--muted); margin-bottom: 1.2rem; flex-wrap: wrap; }
|
||||||
|
|
||||||
|
.btn { display: inline-flex; align-items: center; gap: .5rem; padding: .65rem 1.6rem; border-radius: 6px; font-size: .95rem; font-weight: 600; cursor: pointer; border: none; transition: all .15s; }
|
||||||
|
.btn-build { background: var(--blue); color: #fff; font-size: 1rem; }
|
||||||
|
.btn-build:hover:not(:disabled) { background: var(--blue-dark); }
|
||||||
|
.btn-build:disabled { opacity: .55; cursor: not-allowed; }
|
||||||
|
|
||||||
|
/* ── Status badges ── */
|
||||||
|
.status-badge { display: inline-block; padding: .25rem .7rem; border-radius: 20px; font-size: .82rem; font-weight: 700; white-space: nowrap; }
|
||||||
|
.status-badge.running { background: #fff3d4; color: #8a5c00; }
|
||||||
|
.status-badge.success { background: #d4f0dc; color: #1a6b35; }
|
||||||
|
.status-badge.failure { background: #fde8e8; color: #8a0000; }
|
||||||
|
.status-badge.idle { background: var(--blue-light); color: var(--blue-dark); }
|
||||||
|
|
||||||
|
/* ── Log ── */
|
||||||
|
.log-section { background: #111827; border-radius: var(--radius); overflow: hidden; box-shadow: var(--shadow); }
|
||||||
|
.log-header { display: flex; align-items: center; justify-content: space-between; padding: .7rem 1.2rem; background: #1f2937; color: #d1d5db; }
|
||||||
|
.log-header h3 { font-size: .9rem; font-weight: 600; }
|
||||||
|
.scroll-toggle { display: flex; align-items: center; gap: .4rem; font-size: .82rem; color: #9ca3af; cursor: pointer; }
|
||||||
|
.scroll-toggle input { cursor: pointer; accent-color: var(--blue); }
|
||||||
|
.log-output { color: #a3e635; font-family: 'Courier New', monospace; font-size: .82rem; line-height: 1.55; padding: 1rem 1.2rem; max-height: 400px; overflow-y: auto; white-space: pre-wrap; word-break: break-word; }
|
||||||
|
|
||||||
|
/* ── History ── */
|
||||||
|
.history-section { background: #fff; border-radius: var(--radius); box-shadow: var(--shadow); padding: 1.5rem 1.8rem; }
|
||||||
|
.history-section h3 { font-size: 1rem; margin-bottom: 1rem; color: var(--blue-dark); }
|
||||||
|
.history-table { width: 100%; border-collapse: collapse; font-size: .88rem; }
|
||||||
|
.history-table th { text-align: left; padding: .5rem .8rem; background: var(--blue-light); color: var(--blue-dark); font-weight: 600; }
|
||||||
|
.history-table td { padding: .5rem .8rem; border-bottom: 1px solid var(--blue-light); }
|
||||||
|
.history-table tr:last-child td { border-bottom: none; }
|
||||||
|
.empty { text-align: center; color: var(--muted); padding: 1.2rem; }
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.build-card-header { flex-direction: column; }
|
||||||
|
.header-inner { flex-wrap: wrap; }
|
||||||
|
.history-table th:nth-child(4), .history-table td:nth-child(4) { display: none; }
|
||||||
|
}
|
||||||
215
admin/templates/index.html
Normal file
215
admin/templates/index.html
Normal file
|
|
@ -0,0 +1,215 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Admin — Alpinux</title>
|
||||||
|
<link rel="icon" type="image/x-icon" href="https://static.alpinux.org/logo/favicon.ico">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='admin.css') }}">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<header>
|
||||||
|
<div class="header-inner">
|
||||||
|
<a href="/" class="brand">
|
||||||
|
<img src="https://static.alpinux.org/logo/alpinux-logo.png" alt="Alpinux" width="36" height="36">
|
||||||
|
<span>A<strong>l</strong>p<strong>inux</strong> <span class="brand-sub">Admin</span></span>
|
||||||
|
</a>
|
||||||
|
<div class="header-user">
|
||||||
|
<span>{{ user.name }}</span>
|
||||||
|
<a href="{{ url_for('logout') }}" class="btn-logout">Déconnexion</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
|
||||||
|
<!-- ── Carte de build ─────────────────────────────────────────── -->
|
||||||
|
<section class="build-card" id="build-card">
|
||||||
|
<div class="build-card-header">
|
||||||
|
<div>
|
||||||
|
<h2>📖 Wiki — <code>wiki.alpinux.org</code></h2>
|
||||||
|
<p class="subtitle">Lance <code>git pull</code> + <code>mkdocs build --strict</code> sur le serveur</p>
|
||||||
|
</div>
|
||||||
|
<div class="build-status" id="build-status">
|
||||||
|
{% if state.running %}
|
||||||
|
<span class="status-badge running">⏳ En cours…</span>
|
||||||
|
{% elif state.last_success is none %}
|
||||||
|
<span class="status-badge idle">En attente</span>
|
||||||
|
{% elif state.last_success %}
|
||||||
|
<span class="status-badge success">✓ Succès</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="status-badge failure">✗ Échec</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="build-meta" id="build-meta">
|
||||||
|
{% if state.triggered_by %}
|
||||||
|
<span>Dernier déclencheur : <strong>{{ state.triggered_by }}</strong></span>
|
||||||
|
{% endif %}
|
||||||
|
{% if state.last_duration_s %}
|
||||||
|
<span>Durée : <strong>{{ state.last_duration_s }}s</strong></span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="btn btn-build" id="btn-build"
|
||||||
|
{% if state.running %}disabled{% endif %}
|
||||||
|
onclick="triggerBuild()">
|
||||||
|
{% if state.running %}
|
||||||
|
⏳ Build en cours…
|
||||||
|
{% else %}
|
||||||
|
🚀 Lancer mkdocs build
|
||||||
|
{% endif %}
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ── Log ───────────────────────────────────────────────────── -->
|
||||||
|
<section class="log-section">
|
||||||
|
<div class="log-header">
|
||||||
|
<h3>Journal du dernier build</h3>
|
||||||
|
<label class="scroll-toggle">
|
||||||
|
<input type="checkbox" id="auto-scroll" checked>
|
||||||
|
Suivi auto
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<pre class="log-output" id="log-output">{{ log }}</pre>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ── Historique ────────────────────────────────────────────── -->
|
||||||
|
<section class="history-section">
|
||||||
|
<h3>Historique des builds</h3>
|
||||||
|
<table class="history-table" id="history-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Date</th><th>Déclenché par</th><th>Résultat</th><th>Durée</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="history-body">
|
||||||
|
{% for entry in state.history %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ entry.at }}</td>
|
||||||
|
<td>{{ entry.triggered_by }}</td>
|
||||||
|
<td>
|
||||||
|
{% if entry.success %}
|
||||||
|
<span class="status-badge success">✓ Succès</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="status-badge failure">✗ Échec</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ entry.duration_s }}s</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="4" class="empty">Aucun build enregistré</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const POLL_MS = 2000;
|
||||||
|
let polling = false;
|
||||||
|
|
||||||
|
function triggerBuild() {
|
||||||
|
if (!confirm('Lancer mkdocs build sur le serveur ?')) return;
|
||||||
|
document.getElementById('btn-build').disabled = true;
|
||||||
|
document.getElementById('btn-build').textContent = '⏳ Démarrage…';
|
||||||
|
|
||||||
|
fetch('/api/build', { method: 'POST' })
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.started) {
|
||||||
|
startPolling();
|
||||||
|
} else {
|
||||||
|
alert(data.reason || 'Impossible de démarrer le build.');
|
||||||
|
document.getElementById('btn-build').disabled = false;
|
||||||
|
document.getElementById('btn-build').textContent = '🚀 Lancer mkdocs build';
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
document.getElementById('btn-build').disabled = false;
|
||||||
|
document.getElementById('btn-build').textContent = '🚀 Lancer mkdocs build';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function startPolling() {
|
||||||
|
if (polling) return;
|
||||||
|
polling = true;
|
||||||
|
poll();
|
||||||
|
}
|
||||||
|
|
||||||
|
function poll() {
|
||||||
|
Promise.all([
|
||||||
|
fetch('/api/status').then(r => r.json()),
|
||||||
|
fetch('/api/log').then(r => r.json()),
|
||||||
|
]).then(([status, logData]) => {
|
||||||
|
updateStatus(status);
|
||||||
|
updateLog(logData.log || '');
|
||||||
|
|
||||||
|
if (status.running) {
|
||||||
|
setTimeout(poll, POLL_MS);
|
||||||
|
} else {
|
||||||
|
polling = false;
|
||||||
|
updateHistory(status.history || []);
|
||||||
|
}
|
||||||
|
}).catch(() => {
|
||||||
|
if (polling) setTimeout(poll, POLL_MS * 2);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStatus(s) {
|
||||||
|
const badge = document.getElementById('build-status');
|
||||||
|
const btn = document.getElementById('btn-build');
|
||||||
|
const meta = document.getElementById('build-meta');
|
||||||
|
|
||||||
|
if (s.running) {
|
||||||
|
badge.innerHTML = '<span class="status-badge running">⏳ En cours…</span>';
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = '⏳ Build en cours…';
|
||||||
|
} else if (s.last_success === true) {
|
||||||
|
badge.innerHTML = '<span class="status-badge success">✓ Succès</span>';
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = '🚀 Lancer mkdocs build';
|
||||||
|
} else if (s.last_success === false) {
|
||||||
|
badge.innerHTML = '<span class="status-badge failure">✗ Échec</span>';
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = '🚀 Lancer mkdocs build';
|
||||||
|
}
|
||||||
|
|
||||||
|
let metaHtml = '';
|
||||||
|
if (s.triggered_by) metaHtml += `<span>Dernier déclencheur : <strong>${s.triggered_by}</strong></span>`;
|
||||||
|
if (s.last_duration) metaHtml += `<span>Durée : <strong>${s.last_duration}s</strong></span>`;
|
||||||
|
meta.innerHTML = metaHtml;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateLog(text) {
|
||||||
|
const el = document.getElementById('log-output');
|
||||||
|
el.textContent = text || '(aucun log)';
|
||||||
|
if (document.getElementById('auto-scroll').checked) {
|
||||||
|
el.scrollTop = el.scrollHeight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateHistory(entries) {
|
||||||
|
const tbody = document.getElementById('history-body');
|
||||||
|
if (!entries.length) return;
|
||||||
|
tbody.innerHTML = entries.map(e => `
|
||||||
|
<tr>
|
||||||
|
<td>${e.at}</td>
|
||||||
|
<td>${e.triggered_by}</td>
|
||||||
|
<td><span class="status-badge ${e.success ? 'success' : 'failure'}">${e.success ? '✓ Succès' : '✗ Échec'}</span></td>
|
||||||
|
<td>${e.duration_s}s</td>
|
||||||
|
</tr>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Démarre le polling si un build tourne déjà au chargement de la page */
|
||||||
|
{% if state.running %}startPolling();{% endif %}
|
||||||
|
|
||||||
|
/* Scroll initial vers la fin du log */
|
||||||
|
window.addEventListener('load', function () {
|
||||||
|
const log = document.getElementById('log-output');
|
||||||
|
log.scrollTop = log.scrollHeight;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
24
scripts/alpinux-admin.service
Normal file
24
scripts/alpinux-admin.service
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
# Systemd unit pour l'app d'administration Alpinux
|
||||||
|
# Copier dans /etc/systemd/system/alpinux-admin.service
|
||||||
|
# puis : sudo systemctl enable --now alpinux-admin
|
||||||
|
|
||||||
|
[Unit]
|
||||||
|
Description=Alpinux Admin — interface de déploiement (Flask + Gunicorn)
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
User=alpinux
|
||||||
|
Group=alpinux
|
||||||
|
WorkingDirectory=/home/alpinux/site/admin
|
||||||
|
EnvironmentFile=/etc/alpinux-admin/config.env
|
||||||
|
ExecStart=/home/alpinux/site/admin/venv/bin/gunicorn \
|
||||||
|
--workers 1 \
|
||||||
|
--bind 127.0.0.1:5002 \
|
||||||
|
--access-logfile /var/log/alpinux-admin/access.log \
|
||||||
|
--error-logfile /var/log/alpinux-admin/error.log \
|
||||||
|
app:app
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
10
scripts/portail.alpinux.org.admin.conf
Normal file
10
scripts/portail.alpinux.org.admin.conf
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
# Bloc à ajouter dans le VirtualHost HTTPS de portail.alpinux.org
|
||||||
|
# (dans ISPConfig : Sites > portail.alpinux.org > Directives Apache personnalisées)
|
||||||
|
#
|
||||||
|
# L'app admin Flask tourne sur Gunicorn à 127.0.0.1:5002
|
||||||
|
|
||||||
|
# ── Admin Alpinux : /admin/ → Gunicorn port 5002 ────────────────
|
||||||
|
ProxyPass /admin/ http://127.0.0.1:5002/
|
||||||
|
ProxyPassReverse /admin/ http://127.0.0.1:5002/
|
||||||
|
RequestHeader set X-Forwarded-Proto "https"
|
||||||
|
RequestHeader set X-Script-Name "/admin"
|
||||||
Loading…
Reference in a new issue