diff --git a/app/routers/qualys.py b/app/routers/qualys.py index 27d2e66..20e7956 100644 --- a/app/routers/qualys.py +++ b/app/routers/qualys.py @@ -519,20 +519,20 @@ async def qualys_agents_page(request: Request, db=Depends(get_db)): @router.post("/qualys/agents/refresh") async def qualys_agents_refresh(request: Request, db=Depends(get_db)): + from fastapi.responses import JSONResponse user = get_current_user(request) if not user: - return RedirectResponse(url="/login") + return JSONResponse({"ok": False, "msg": "Non authentifié"}, status_code=401) perms = get_user_perms(db, user) if not can_edit(perms, "qualys"): - return RedirectResponse(url="/qualys/agents") + return JSONResponse({"ok": False, "msg": "Permission refusée"}, status_code=403) from ..services.qualys_service import refresh_all_agents try: stats = refresh_all_agents(db) - msg = f"refresh_ok_{stats.get('created',0)}_{stats.get('updated',0)}" + return JSONResponse({"ok": True, "msg": f"{stats.get('created',0)} créés, {stats.get('updated',0)} mis à jour", "stats": stats}) except Exception as e: import traceback; traceback.print_exc() - msg = "refresh_error" - return RedirectResponse(url=f"/qualys/agents?msg={msg}", status_code=303) + return JSONResponse({"ok": False, "msg": str(e)[:200]}, status_code=500) @router.get("/qualys/agents/export-no-agent") @@ -872,15 +872,20 @@ async def qualys_deploy_page(request: Request, db=Depends(get_db)): packages = list_packages() servers = db.execute(text(""" - SELECT s.id, s.hostname, s.os_family, s.etat, s.ssh_user, s.ssh_port, s.ssh_method, - d.name as domain, e.name as env + SELECT s.id, s.hostname, s.os_family, s.os_version, s.etat, s.ssh_user, s.ssh_port, s.ssh_method, + d.name as domain, e.name as env, + qa.agent_version, qa.agent_status, qa.last_checkin FROM servers s LEFT JOIN domain_environments de ON s.domain_env_id = de.id LEFT JOIN domains d ON de.domain_id = d.id LEFT JOIN environments e ON de.environment_id = e.id + LEFT JOIN qualys_assets qa ON qa.server_id = s.id ORDER BY s.hostname """)).fetchall() servers = [dict(r._mapping) for r in servers] + for s in servers: + if s.get("last_checkin"): + s["last_checkin"] = str(s["last_checkin"])[:19] ctx = base_context(request, db, user) ctx.update({ @@ -896,106 +901,96 @@ async def qualys_deploy_page(request: Request, db=Depends(get_db)): @router.post("/qualys/deploy/run") -async def qualys_deploy_run(request: Request, db=Depends(get_db), - server_ids: str = Form(""), - activation_id: str = Form(""), - customer_id: str = Form(""), - server_uri: str = Form(""), - package_deb: str = Form(""), - package_rpm: str = Form("")): +async def qualys_deploy_run(request: Request, db=Depends(get_db)): + from fastapi.responses import JSONResponse user = get_current_user(request) if not user: - return RedirectResponse(url="/login") + return JSONResponse({"ok": False, "msg": "Non authentifié"}, status_code=401) perms = get_user_perms(db, user) if not can_edit(perms, "qualys"): - return RedirectResponse(url="/dashboard") + return JSONResponse({"ok": False, "msg": "Permission refusée"}, status_code=403) - from ..services.agent_deploy_service import deploy_agent + from ..services.agent_deploy_service import start_deploy_job from ..services.secrets_service import get_secret - ids = [int(x) for x in server_ids.split(",") if x.strip().isdigit()] + body = await request.json() + server_ids = body.get("server_ids", "") + activation_id = body.get("activation_id", "") + customer_id = body.get("customer_id", "") + server_uri = body.get("server_uri", "") + package_deb = body.get("package_deb", "") + package_rpm = body.get("package_rpm", "") + force_downgrade = body.get("force_downgrade", False) + + ids = [int(x) for x in str(server_ids).split(",") if x.strip().isdigit()] if not ids: - return RedirectResponse(url="/qualys/deploy?msg=no_servers", status_code=303) + return JSONResponse({"ok": False, "msg": "Aucun serveur sélectionné"}) ssh_key = get_secret(db, "ssh_key_file") or "/opt/patchcenter/keys/id_ed25519" - - # Get servers placeholders = ",".join(str(i) for i in ids) - servers = db.execute(text(f""" - SELECT id, hostname, os_family, ssh_user, ssh_port, ssh_method + rows = db.execute(text(f""" + SELECT id, hostname, os_family, os_version, ssh_user, ssh_port, ssh_method FROM servers WHERE id IN ({placeholders}) """)).fetchall() + servers = [{"hostname": r.hostname, "os_family": r.os_family, + "os_version": r.os_version, "ssh_user": r.ssh_user, "ssh_port": r.ssh_port} for r in rows] - results = [] - log_lines = [] + job_id = start_deploy_job(servers, ssh_key, package_deb, package_rpm, + activation_id, customer_id, server_uri, force_downgrade=force_downgrade) - for srv in servers: - # Choose package based on OS - if "debian" in (srv.os_family or "").lower() or srv.os_family == "linux": - pkg = package_deb - else: - pkg = package_rpm - - if not pkg or not os.path.exists(pkg): - results.append({"hostname": srv.hostname, "status": "FAILED", "detail": f"Package introuvable: {pkg}"}) - continue - - def on_line(msg, h=srv.hostname): - log_lines.append(f"[{h}] {msg}") - - result = deploy_agent( - hostname=srv.hostname, - ssh_user=srv.ssh_user or "root", - ssh_key_path=ssh_key, - ssh_port=srv.ssh_port or 22, - os_family=srv.os_family, - package_path=pkg, - activation_id=activation_id, - customer_id=customer_id, - server_uri=server_uri, - on_line=on_line, - ) - results.append(result) - - # Save to app state for display - request.app.state.last_deploy_results = results - request.app.state.last_deploy_log = log_lines - - # Audit log from ..services.audit_service import log_action - ok = sum(1 for r in results if r["status"] in ("SUCCESS", "ALREADY_INSTALLED")) - fail = sum(1 for r in results if r["status"] not in ("SUCCESS", "ALREADY_INSTALLED")) - log_action(db, request, user, "qualys_deploy", f"{ok} OK, {fail} fail sur {len(results)} serveurs") + log_action(db, request, user, "qualys_deploy", + entity_type="deploy_job", + details={"job_id": job_id, "servers": len(servers)}) db.commit() - ctx = base_context(request, db, user) - ctx.update({ - "app_name": APP_NAME, - "results": results, - "log_lines": log_lines, - "total": len(results), - "ok": ok, - "failed": fail, + return JSONResponse({"ok": True, "job_id": job_id, "total": len(servers)}) + + +@router.get("/qualys/deploy/status/{job_id}") +async def qualys_deploy_status(request: Request, job_id: str, db=Depends(get_db)): + from fastapi.responses import JSONResponse + user = get_current_user(request) + if not user: + return JSONResponse({"ok": False, "msg": "Non authentifié"}, status_code=401) + + from ..services.agent_deploy_service import get_deploy_job + job = get_deploy_job(job_id) + if not job: + return JSONResponse({"ok": False, "msg": "Job introuvable"}, status_code=404) + + import time + elapsed = int(time.time() - job["started_at"]) + return JSONResponse({ + "ok": True, + "job_id": job_id, + "total": job["total"], + "done": job["done"], + "finished": job["finished"], + "elapsed": elapsed, + "servers": job["servers"], + "log": job["log"][-50:], # dernières 50 lignes }) - return templates.TemplateResponse("qualys_deploy_results.html", ctx) @router.post("/qualys/deploy/check") -async def qualys_deploy_check(request: Request, db=Depends(get_db), - server_ids: str = Form("")): +async def qualys_deploy_check(request: Request, db=Depends(get_db)): + from fastapi.responses import JSONResponse user = get_current_user(request) if not user: - return RedirectResponse(url="/login") + return JSONResponse({"ok": False, "msg": "Non authentifié"}, status_code=401) perms = get_user_perms(db, user) if not can_view(perms, "qualys"): - return RedirectResponse(url="/dashboard") + return JSONResponse({"ok": False, "msg": "Permission refusée"}, status_code=403) from ..services.agent_deploy_service import check_agent from ..services.secrets_service import get_secret - ids = [int(x) for x in server_ids.split(",") if x.strip().isdigit()] + body = await request.json() + server_ids = body.get("server_ids", "") + ids = [int(x) for x in str(server_ids).split(",") if x.strip().isdigit()] if not ids: - return RedirectResponse(url="/qualys/deploy?msg=no_servers", status_code=303) + return JSONResponse({"ok": False, "msg": "Aucun serveur sélectionné"}) ssh_key = get_secret(db, "ssh_key_file") or "/opt/patchcenter/keys/id_ed25519" placeholders = ",".join(str(i) for i in ids) @@ -1006,13 +1001,7 @@ async def qualys_deploy_check(request: Request, db=Depends(get_db), r = check_agent(srv.hostname, srv.ssh_user or "root", ssh_key, srv.ssh_port or 22) results.append(r) - ctx = base_context(request, db, user) - ctx.update({ - "app_name": APP_NAME, - "results": results, - "total": len(results), - "active": sum(1 for r in results if r["status"] == "ACTIVE"), - "not_installed": sum(1 for r in results if r["status"] == "NOT_INSTALLED"), - "failed": sum(1 for r in results if r["status"] == "CONNECTION_FAILED"), - }) - return templates.TemplateResponse("qualys_deploy_results.html", ctx) + return JSONResponse({"ok": True, "results": results, "total": len(results), + "active": sum(1 for r in results if r["status"] == "ACTIVE"), + "not_installed": sum(1 for r in results if r["status"] == "NOT_INSTALLED"), + "failed": sum(1 for r in results if r["status"] == "CONNECTION_FAILED")}) diff --git a/app/services/agent_deploy_service.py b/app/services/agent_deploy_service.py index 34719b3..86e8ae8 100644 --- a/app/services/agent_deploy_service.py +++ b/app/services/agent_deploy_service.py @@ -80,11 +80,21 @@ def check_agent(hostname, ssh_user, ssh_key_path, ssh_port=22): result = {"hostname": hostname} - # Check if installed - code, out, _ = _run_cmd(client, "which qualys-cloud-agent 2>/dev/null || rpm -q qualys-cloud-agent 2>/dev/null || dpkg -l qualys-cloud-agent 2>/dev/null | grep '^ii'") - if code != 0 and not out.strip(): + # Check if installed (rpm -q returns 0 only if installed, dpkg -s returns 0 only if installed) + installed = False + code, out, _ = _run_cmd(client, "rpm -q qualys-cloud-agent 2>/dev/null") + if code == 0 and "not installed" not in out.lower() and "n'est pas install" not in out.lower(): + installed = True + else: + code, out, _ = _run_cmd(client, "dpkg -s qualys-cloud-agent 2>/dev/null") + if code == 0 and "install ok installed" in out.lower(): + installed = True + + if not installed: result["status"] = "NOT_INSTALLED" result["detail"] = "Agent non installe" + result["version"] = "" + result["service_status"] = "" client.close() return result @@ -94,9 +104,8 @@ def check_agent(hostname, ssh_user, ssh_key_path, ssh_port=22): result["service_status"] = status # Get version via package manager - code, out, _ = _run_cmd(client, "rpm -q qualys-cloud-agent 2>/dev/null || dpkg -l qualys-cloud-agent 2>/dev/null | grep '^ii' | awk '{print $3}'") + code, out, _ = _run_cmd(client, "rpm -q --qf '%{VERSION}-%{RELEASE}' qualys-cloud-agent 2>/dev/null || dpkg-query -W -f='${Version}' qualys-cloud-agent 2>/dev/null") version = out.strip() - # Extract just version number m = re.search(r'(\d+\.\d+\.\d+[\.\-]\d+)', version) result["version"] = m.group(1) if m else version[:50] @@ -118,65 +127,129 @@ def check_agent(hostname, ssh_user, ssh_key_path, ssh_port=22): return result +def _compare_versions(v1, v2): + """Compare deux versions. Retourne -1 (v1v2)""" + def to_parts(v): + return [int(x) for x in re.split(r'[.\-]', v) if x.isdigit()] + p1, p2 = to_parts(v1 or ""), to_parts(v2 or "") + for a, b in zip(p1, p2): + if a < b: return -1 + if a > b: return 1 + if len(p1) < len(p2): return -1 + if len(p1) > len(p2): return 1 + return 0 + + def deploy_agent(hostname, ssh_user, ssh_key_path, ssh_port, os_family, package_path, activation_id, customer_id, server_uri, - on_line=None): - """Deploie l'agent Qualys sur un serveur""" + on_line=None, on_stage=None, force_downgrade=False): + """Deploie l'agent Qualys sur un serveur (install, upgrade ou downgrade)""" def emit(msg): if on_line: on_line(msg) log.info(f"[{hostname}] {msg}") + def stage(name, detail=""): + if on_stage: + on_stage(name, detail) + + stage("connecting", f"Connexion SSH {ssh_user}@{hostname}:{ssh_port}") emit(f"Connexion SSH {ssh_user}@{hostname}:{ssh_port}...") client, error = _get_ssh_client(hostname, ssh_user, ssh_key_path, ssh_port) if not client: emit(f"ERREUR connexion: {error}") + stage("failed", error) return {"hostname": hostname, "status": "FAILED", "detail": error} result = {"hostname": hostname, "status": "PENDING"} + pkg_name = os.path.basename(package_path) + pkg_version = _extract_version(pkg_name) + is_deb = pkg_name.endswith(".deb") try: # 1. Detect OS if not provided if not os_family: code, out, _ = _run_cmd(client, "cat /etc/os-release 2>/dev/null | head -3") - os_family = "linux" # default + os_family = "linux" if "debian" in out.lower() or "ubuntu" in out.lower(): os_family = "debian" elif "red hat" in out.lower() or "centos" in out.lower() or "rocky" in out.lower(): os_family = "rhel" emit(f"OS detecte: {os_family}") - # 2. Check if already installed - code, out, _ = _run_cmd(client, "systemctl is-active qualys-cloud-agent 2>/dev/null") - if out.strip() == "active": - emit("Agent deja installe et actif - skip") - result["status"] = "ALREADY_INSTALLED" - result["detail"] = "Agent deja actif" - client.close() - return result + # 2. Check installed version + stage("checking", "Verification agent existant") + installed_version = None + code, out, _ = _run_cmd(client, "rpm -q qualys-cloud-agent 2>/dev/null") + if code == 0 and "not installed" not in out.lower() and "n'est pas install" not in out.lower(): + m = re.search(r'(\d+\.\d+\.\d+[\.\-]\d+)', out) + installed_version = m.group(1) if m else None + else: + code, out, _ = _run_cmd(client, "dpkg-query -W -f='${Version}' qualys-cloud-agent 2>/dev/null") + if code == 0 and out.strip(): + m = re.search(r'(\d+\.\d+\.\d+[\.\-]\d+)', out) + installed_version = m.group(1) if m else None + + if installed_version: + cmp = _compare_versions(pkg_version, installed_version) + emit(f"Version installee: {installed_version}, package: {pkg_version}") + if cmp == 0: + # Meme version + code, out, _ = _run_cmd(client, "systemctl is-active qualys-cloud-agent 2>/dev/null") + if out.strip() == "active": + emit(f"Meme version ({installed_version}) et agent actif - skip") + stage("already_installed", f"v{installed_version} deja active") + result["status"] = "ALREADY_INSTALLED" + result["detail"] = f"v{installed_version} deja installee et active" + client.close() + return result + else: + emit(f"Meme version mais service {out.strip()} - reinstallation") + elif cmp < 0: + # Downgrade + if not force_downgrade: + emit(f"DOWNGRADE refuse: {installed_version} → {pkg_version}") + stage("downgrade_refused", f"v{installed_version} > v{pkg_version}") + result["status"] = "DOWNGRADE_REFUSED" + result["detail"] = f"Version installee ({installed_version}) plus recente que le package ({pkg_version}). Cochez 'Forcer le downgrade'." + client.close() + return result + else: + emit(f"DOWNGRADE force: {installed_version} → {pkg_version}") + else: + emit(f"UPGRADE: {installed_version} → {pkg_version}") # 3. Copy package - pkg_name = os.path.basename(package_path) - remote_path = f"/tmp/{pkg_name}" - emit(f"Copie {pkg_name} ({os.path.getsize(package_path)//1024//1024} Mo)...") + pkg_size = os.path.getsize(package_path) // 1024 // 1024 + stage("copying", f"Copie {pkg_name} ({pkg_size} Mo)") + emit(f"Copie {pkg_name} ({pkg_size} Mo)...") sftp = client.open_sftp() - sftp.put(package_path, remote_path) + sftp.put(package_path, remote_path=f"/tmp/{pkg_name}") sftp.close() emit("Copie terminee") - # 4. Install - is_deb = pkg_name.endswith(".deb") + remote_path = f"/tmp/{pkg_name}" + + # 4. Install / Upgrade / Downgrade if is_deb: + stage("installing", "Installation (dpkg)") emit("Installation (dpkg)...") code, out, err = _run_cmd(client, f"dpkg --install {remote_path}", sudo=True, timeout=120) else: - emit("Installation (rpm)...") - code, out, err = _run_cmd(client, f"rpm -ivh --nosignature {remote_path}", sudo=True, timeout=120) + if force_downgrade and installed_version: + stage("installing", f"Downgrade (rpm) {installed_version} → {pkg_version}") + emit(f"Downgrade (rpm --oldpackage)...") + code, out, err = _run_cmd(client, f"rpm -Uvh --nosignature --oldpackage {remote_path}", sudo=True, timeout=120) + else: + stage("installing", "Installation/Upgrade (rpm)") + emit("Installation/Upgrade (rpm -Uvh)...") + code, out, err = _run_cmd(client, f"rpm -Uvh --nosignature {remote_path}", sudo=True, timeout=120) if code != 0 and "already installed" not in (out + err).lower(): emit(f"ERREUR installation (code {code}): {err[:200]}") + stage("failed", f"Installation echouee: {err[:100]}") result["status"] = "INSTALL_FAILED" result["detail"] = err[:200] client.close() @@ -184,6 +257,7 @@ def deploy_agent(hostname, ssh_user, ssh_key_path, ssh_port, os_family, emit("Installation OK") # 5. Activate + stage("activating", "Activation de l'agent") emit("Activation de l'agent...") activate_cmd = ( f"/usr/local/qualys/cloud-agent/bin/qualys-cloud-agent.sh " @@ -195,6 +269,7 @@ def deploy_agent(hostname, ssh_user, ssh_key_path, ssh_port, os_family, code, out, err = _run_cmd(client, activate_cmd, sudo=True, timeout=60) if code != 0: emit(f"ERREUR activation (code {code}): {err[:200]}") + stage("failed", f"Activation echouee: {err[:100]}") result["status"] = "ACTIVATE_FAILED" result["detail"] = err[:200] client.close() @@ -202,17 +277,22 @@ def deploy_agent(hostname, ssh_user, ssh_key_path, ssh_port, os_family, emit("Activation OK") # 6. Restart service + stage("restarting", "Redemarrage du service") emit("Redemarrage du service...") _run_cmd(client, "systemctl restart qualys-cloud-agent", sudo=True) # 7. Verify + stage("verifying", "Verification du service") code, out, _ = _run_cmd(client, "systemctl is-active qualys-cloud-agent") + action = "Upgrade" if installed_version else "Install" if out.strip() == "active": - emit("Agent deploye et actif !") + emit(f"Agent {action.lower()} OK et actif !") + stage("success", f"{action} OK — v{pkg_version} active") result["status"] = "SUCCESS" - result["detail"] = "Agent deploye avec succes" + result["detail"] = f"{action} v{pkg_version} OK" else: emit(f"Agent installe mais statut: {out.strip()}") + stage("partial", f"Service: {out.strip()}") result["status"] = "PARTIAL" result["detail"] = f"Installe, service: {out.strip()}" @@ -221,8 +301,108 @@ def deploy_agent(hostname, ssh_user, ssh_key_path, ssh_port, os_family, except Exception as e: emit(f"ERREUR: {e}") + stage("failed", str(e)[:100]) result["status"] = "FAILED" result["detail"] = str(e)[:200] client.close() return result + + +# ═══════════════════════════════════════════════ +# Background job manager +# ═══════════════════════════════════════════════ +import threading +import uuid +import time + +_deploy_jobs = {} # job_id -> job dict + + +def start_deploy_job(servers, ssh_key, package_deb, package_rpm, + activation_id, customer_id, server_uri, force_downgrade=False): + """Lance un job de deploiement en arriere-plan. Retourne le job_id.""" + job_id = str(uuid.uuid4())[:8] + job = { + "id": job_id, + "started_at": time.time(), + "total": len(servers), + "done": 0, + "servers": {}, + "log": [], + "finished": False, + } + for srv in servers: + job["servers"][srv["hostname"]] = { + "hostname": srv["hostname"], + "stage": "pending", + "detail": "En attente", + "status": None, + "result": None, + } + _deploy_jobs[job_id] = job + + def _run(): + threads = [] + for srv in servers: + t = threading.Thread(target=_deploy_one, args=(job, srv, ssh_key, + package_deb, package_rpm, activation_id, customer_id, server_uri, force_downgrade)) + t.daemon = True + threads.append(t) + t.start() + for t in threads: + t.join() + job["finished"] = True + job["finished_at"] = time.time() + + master = threading.Thread(target=_run, daemon=True) + master.start() + return job_id + + +def _deploy_one(job, srv, ssh_key, package_deb, package_rpm, + activation_id, customer_id, server_uri, force_downgrade=False): + hostname = srv["hostname"] + + def on_stage(stage, detail=""): + job["servers"][hostname]["stage"] = stage + job["servers"][hostname]["detail"] = detail + + def on_line(msg): + job["log"].append(f"[{hostname}] {msg}") + + osv = (srv.get("os_version") or "").lower() + if any(k in osv for k in ("centos", "red hat", "rhel", "rocky", "oracle", "fedora", "alma")): + pkg = package_rpm + else: + pkg = package_deb + + if not pkg or not os.path.exists(pkg): + job["servers"][hostname]["stage"] = "failed" + job["servers"][hostname]["detail"] = f"Package introuvable: {pkg}" + job["servers"][hostname]["status"] = "FAILED" + job["done"] += 1 + return + + result = deploy_agent( + hostname=hostname, ssh_user=srv.get("ssh_user") or "root", + ssh_key_path=ssh_key, ssh_port=srv.get("ssh_port") or 22, + os_family=srv.get("os_family"), package_path=pkg, + activation_id=activation_id, customer_id=customer_id, + server_uri=server_uri, on_line=on_line, on_stage=on_stage, + force_downgrade=force_downgrade, + ) + job["servers"][hostname]["status"] = result["status"] + job["servers"][hostname]["result"] = result + job["done"] += 1 + + +def get_deploy_job(job_id): + """Retourne l'etat d'un job de deploiement.""" + return _deploy_jobs.get(job_id) + + +def list_deploy_jobs(): + """Liste les jobs recents (< 1h).""" + now = time.time() + return {jid: j for jid, j in _deploy_jobs.items() if now - j["started_at"] < 3600} diff --git a/app/templates/qualys_agents.html b/app/templates/qualys_agents.html index 4b6f4e2..ba5669f 100644 --- a/app/templates/qualys_agents.html +++ b/app/templates/qualys_agents.html @@ -7,26 +7,71 @@

Activation keys et versions des agents déployés

-
- -
+ Déployer Recherche
-{% if 'refresh_ok' in msg %} -
- Données rafraîchies depuis Qualys. + + -{% elif msg == 'refresh_error' %} -
- Erreur lors du rafraîchissement. -
-{% endif %} + + + + + +
@@ -156,7 +201,7 @@ {{ s.domain or '-' }} {{ s.env or '-' }} {% if s.zone == 'DMZ' %}DMZ{% else %}{{ s.zone or '-' }}{% endif %} - {{ (s.etat or '-')[:8] }} + {{ (s.etat or '-')[:8] }} {% endfor %} diff --git a/app/templates/qualys_deploy.html b/app/templates/qualys_deploy.html index b684aac..e838f2e 100644 --- a/app/templates/qualys_deploy.html +++ b/app/templates/qualys_deploy.html @@ -78,28 +78,20 @@
-
-
- - - - - - - -
-
- - -
+
+ + +
@@ -109,10 +101,11 @@ Hostname - OS + OS / Version Domaine Env État + Agent installé SSH @@ -129,13 +122,20 @@ {% if s.os_family == 'linux' %}Linux {% else %}{{ s.os_family or '?' }}{% endif %} + {% if s.os_version %}
{{ s.os_version }}
{% endif %} {{ s.domain or '-' }} {{ s.env or '-' }} - {% if s.etat == 'en_production' %}Prod + {% if s.etat == 'production' %}Prod {% else %}{{ s.etat or '-' }}{% endif %} + + {% if s.agent_version %} + {{ s.agent_version }} + {{ s.agent_status or '?' }} + {% else %}{% endif %} + {{ s.ssh_user or 'root' }}:{{ s.ssh_port or 22 }} {% endfor %} @@ -143,4 +143,248 @@
+ + + + + + + + + + + + + {% endblock %}