Qualys: deploy agent background jobs + upgrade/downgrade + AJAX overlays

- Background job system pour deploiement (threads paralleles, progression live)
- Upgrade/downgrade: compare versions installee vs package, rpm -Uvh --oldpackage
- Checkbox "Forcer le downgrade" dans UI
- Choix auto DEB/RPM base sur os_version (centos/rhel/rocky/oracle -> RPM)
- Check agent: rpm -q / dpkg -s (evite faux positifs "agent installe mais inactif")
- Bouton "Rafraichir depuis Qualys" AJAX avec timer
- Agents page: colonne version installee + statut

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Pierre & Lumière 2026-04-12 18:50:56 +02:00
parent 8479d7280e
commit 5ea4100f4c
4 changed files with 609 additions and 151 deletions

View File

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

View File

@ -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 (v1<v2), 0 (egales), 1 (v1>v2)"""
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}

View File

@ -7,26 +7,71 @@
<p class="text-xs text-gray-500 mt-1">Activation keys et versions des agents déployés</p>
</div>
<div style="display:flex;gap:8px">
<form method="POST" action="/qualys/agents/refresh" style="display:inline">
<button type="submit" class="btn-primary px-4 py-2 text-sm"
onclick="this.disabled=true;this.textContent='Rafraîchissement...'">
Rafraîchir depuis Qualys
</button>
</form>
<button id="btn-refresh" class="btn-primary px-4 py-2 text-sm" onclick="refreshAgents()">
Rafraîchir depuis Qualys
</button>
<a href="/qualys/deploy" class="btn-sm bg-cyber-border text-gray-300 px-4 py-2">Déployer</a>
<a href="/qualys/search" class="btn-sm bg-cyber-border text-gray-300 px-4 py-2">Recherche</a>
</div>
</div>
{% if 'refresh_ok' in msg %}
<div style="background:#1a5a2e;color:#8f8;padding:8px 16px;border-radius:6px;margin-bottom:12px;font-size:0.85rem">
Données rafraîchies depuis Qualys.
<!-- Overlay chargement -->
<div id="refresh-overlay" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.7);z-index:9999;justify-content:center;align-items:center">
<div class="card p-6 text-center" style="min-width:320px">
<div style="margin-bottom:12px">
<svg style="display:inline;animation:spin 1s linear infinite;width:36px;height:36px" viewBox="0 0 24 24" fill="none" stroke="#00ffc8" stroke-width="2"><circle cx="12" cy="12" r="10" stroke-opacity="0.3"/><path d="M12 2a10 10 0 0 1 10 10" stroke-linecap="round"/></svg>
</div>
<div class="text-cyber-accent font-bold text-sm" id="refresh-title">Rafraîchissement en cours...</div>
<div class="text-gray-400 text-xs mt-2" id="refresh-detail">Synchronisation des agents depuis l'API Qualys</div>
<div class="text-gray-500 text-xs mt-3" id="refresh-timer">0s</div>
</div>
</div>
{% elif msg == 'refresh_error' %}
<div style="background:#5a1a1a;color:#ff3366;padding:8px 16px;border-radius:6px;margin-bottom:12px;font-size:0.85rem">
Erreur lors du rafraîchissement.
</div>
{% endif %}
<style>@keyframes spin{to{transform:rotate(360deg)}}</style>
<!-- Message résultat -->
<div id="refresh-msg" style="display:none;padding:8px 16px;border-radius:6px;margin-bottom:12px;font-size:0.85rem"></div>
<script>
function refreshAgents() {
var btn = document.getElementById('btn-refresh');
var overlay = document.getElementById('refresh-overlay');
var timer = document.getElementById('refresh-timer');
var msgDiv = document.getElementById('refresh-msg');
btn.disabled = true;
overlay.style.display = 'flex';
msgDiv.style.display = 'none';
var t0 = Date.now();
var iv = setInterval(function(){ timer.textContent = Math.floor((Date.now()-t0)/1000) + 's'; }, 1000);
fetch('/qualys/agents/refresh', {method:'POST', credentials:'same-origin'})
.then(function(r){ return r.json().then(function(d){ return {ok:r.ok, data:d}; }); })
.then(function(res){
clearInterval(iv);
overlay.style.display = 'none';
btn.disabled = false;
if(res.ok && res.data.ok){
msgDiv.style.background = '#1a5a2e';
msgDiv.style.color = '#8f8';
msgDiv.textContent = 'Données rafraîchies : ' + res.data.msg;
msgDiv.style.display = 'block';
setTimeout(function(){ location.reload(); }, 1500);
} else {
msgDiv.style.background = '#5a1a1a';
msgDiv.style.color = '#ff3366';
msgDiv.textContent = 'Erreur : ' + (res.data.msg || 'Erreur inconnue');
msgDiv.style.display = 'block';
}
})
.catch(function(err){
clearInterval(iv);
overlay.style.display = 'none';
btn.disabled = false;
msgDiv.style.background = '#5a1a1a';
msgDiv.style.color = '#ff3366';
msgDiv.textContent = 'Erreur réseau : ' + err.message;
msgDiv.style.display = 'block';
});
}
</script>
<!-- KPIs agents -->
<div style="display:flex;flex-wrap:nowrap;gap:8px;margin-bottom:16px;">
@ -156,7 +201,7 @@
<td class="p-2 text-center text-gray-400">{{ s.domain or '-' }}</td>
<td class="p-2 text-center">{{ s.env or '-' }}</td>
<td class="p-2 text-center">{% if s.zone == 'DMZ' %}<span class="badge badge-red">DMZ</span>{% else %}{{ s.zone or '-' }}{% endif %}</td>
<td class="p-2 text-center" title="{{ s.etat or '' }}"><span class="badge {% if s.etat == 'en_production' %}badge-green{% elif s.etat == 'decommissionne' %}badge-red{% elif s.etat == 'eteint' %}badge-gray{% else %}badge-yellow{% endif %}">{{ (s.etat or '-')[:8] }}</span></td>
<td class="p-2 text-center" title="{{ s.etat or '' }}"><span class="badge {% if s.etat == 'production' %}badge-green{% elif s.etat == 'obsolete' %}badge-red{% elif s.etat == 'stock' %}badge-gray{% else %}badge-yellow{% endif %}">{{ (s.etat or '-')[:8] }}</span></td>
</tr>
{% endfor %}
</tbody>

View File

@ -78,28 +78,20 @@
</div>
<!-- Actions -->
<div style="display:flex;gap:8px">
<form method="POST" action="/qualys/deploy/run" id="deployForm">
<input type="hidden" name="server_ids" :value="selectedIds.join(',')">
<input type="hidden" name="activation_id" :value="document.getElementById('activation_id').value">
<input type="hidden" name="customer_id" :value="document.getElementById('customer_id').value">
<input type="hidden" name="server_uri" :value="document.getElementById('server_uri').value">
<input type="hidden" name="package_deb" :value="document.getElementById('package_deb').value">
<input type="hidden" name="package_rpm" :value="document.getElementById('package_rpm').value">
<button type="submit" class="btn-primary px-4 py-2 text-sm"
:disabled="selectedIds.length === 0"
onclick="if(!confirm('Déployer l\'agent sur ' + selectedIds.length + ' serveur(s) ?')) return false; this.textContent='Déploiement en cours...'">
Déployer l'agent
</button>
</form>
<form method="POST" action="/qualys/deploy/check">
<input type="hidden" name="server_ids" :value="selectedIds.join(',')">
<button type="submit" style="padding:8px 16px;font-size:0.85rem;background:#334155;color:#e2e8f0;border:1px solid #475569;border-radius:6px;cursor:pointer"
:disabled="selectedIds.length === 0"
onclick="this.textContent='Vérification...'">
Vérifier l'agent
</button>
</form>
<div style="display:flex;gap:8px;align-items:center">
<button id="btn-deploy" class="btn-primary px-4 py-2 text-sm"
:disabled="selectedIds.length === 0"
@click="deployAgent(selectedIds)">
Déployer l'agent
</button>
<button id="btn-check" style="padding:8px 16px;font-size:0.85rem;background:#334155;color:#e2e8f0;border:1px solid #475569;border-radius:6px;cursor:pointer"
:disabled="selectedIds.length === 0"
@click="checkAgent(selectedIds)">
Vérifier l'agent
</button>
<label class="text-xs text-gray-400 ml-4" style="display:flex;align-items:center;gap:4px">
<input type="checkbox" id="force_downgrade"> Forcer le downgrade
</label>
</div>
<!-- Serveurs -->
@ -109,10 +101,11 @@
<th class="p-2 w-8"><input type="checkbox" @change="selectAll = $event.target.checked; selectedIds = selectAll ? servers.map(s => s.id) : []"
x-init="servers = {{ servers | tojson }}"></th>
<th class="p-2 text-left">Hostname</th>
<th class="p-2">OS</th>
<th class="p-2">OS / Version</th>
<th class="p-2">Domaine</th>
<th class="p-2">Env</th>
<th class="p-2">État</th>
<th class="p-2">Agent installé</th>
<th class="p-2">SSH</th>
</tr></thead>
<tbody>
@ -129,13 +122,20 @@
<td class="p-2 text-center">
{% if s.os_family == 'linux' %}<span class="badge badge-green">Linux</span>
{% else %}<span class="badge badge-blue">{{ s.os_family or '?' }}</span>{% endif %}
{% if s.os_version %}<div class="text-gray-400 mt-1" style="font-size:10px">{{ s.os_version }}</div>{% endif %}
</td>
<td class="p-2 text-center text-gray-400">{{ s.domain or '-' }}</td>
<td class="p-2 text-center">{{ s.env or '-' }}</td>
<td class="p-2 text-center">
{% if s.etat == 'en_production' %}<span class="badge badge-green">Prod</span>
{% if s.etat == 'production' %}<span class="badge badge-green">Prod</span>
{% else %}{{ s.etat or '-' }}{% endif %}
</td>
<td class="p-2 text-center">
{% if s.agent_version %}
<span class="font-mono text-cyber-green">{{ s.agent_version }}</span>
<span class="ml-1 badge {% if s.agent_status and 'ACTIVE' in (s.agent_status|upper) and 'INACTIVE' not in (s.agent_status|upper) %}badge-green{% elif s.agent_status and 'INACTIVE' in (s.agent_status|upper) %}badge-red{% else %}badge-gray{% endif %}" style="font-size:9px">{{ s.agent_status or '?' }}</span>
{% else %}<span class="text-gray-600"></span>{% endif %}
</td>
<td class="p-2 text-center text-gray-500">{{ s.ssh_user or 'root' }}:{{ s.ssh_port or 22 }}</td>
</tr>
{% endfor %}
@ -143,4 +143,248 @@
</table>
</div>
</div>
<!-- Zone progression / résultats -->
<div id="progress-zone" style="display:none" class="mt-4 space-y-4">
<div class="card p-4">
<div class="flex justify-between items-center mb-3">
<h3 class="text-sm font-bold text-cyber-accent" id="progress-title">Déploiement en cours...</h3>
<span class="text-xs text-gray-400 font-mono" id="progress-timer">0s</span>
</div>
<!-- Barre de progression globale -->
<div style="background:#1e293b;border-radius:4px;height:8px;margin-bottom:16px;overflow:hidden">
<div id="progress-bar" style="height:100%;background:#00ffc8;width:0%;transition:width 0.5s ease"></div>
</div>
<div class="text-xs text-gray-400 mb-3" id="progress-summary"></div>
<!-- Tableau progression par serveur -->
<table class="w-full table-cyber text-xs">
<thead><tr>
<th class="p-2 text-left">Hostname</th>
<th class="p-2">Étape</th>
<th class="p-2 text-left">Détail</th>
</tr></thead>
<tbody id="progress-body"></tbody>
</table>
</div>
<!-- Log détaillé (affiché une fois terminé) -->
<div id="progress-log" class="card p-4" style="display:none">
<h3 class="text-sm font-bold text-cyber-accent mb-2">Log détaillé</h3>
<div id="progress-log-content" style="background:#0a0a23;border-radius:6px;padding:12px;max-height:400px;overflow-y:auto;font-family:monospace;font-size:0.75rem;color:#00ff88;white-space:pre-wrap"></div>
</div>
</div>
<!-- Overlay vérification (check reste synchrone, c'est rapide) -->
<div id="check-overlay" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.75);z-index:9999;justify-content:center;align-items:center">
<div class="card p-6 text-center" style="min-width:360px">
<div style="margin-bottom:12px">
<svg style="display:inline;animation:opspin 1s linear infinite;width:40px;height:40px" viewBox="0 0 24 24" fill="none" stroke="#00ffc8" stroke-width="2"><circle cx="12" cy="12" r="10" stroke-opacity="0.3"/><path d="M12 2a10 10 0 0 1 10 10" stroke-linecap="round"/></svg>
</div>
<div class="text-cyber-accent font-bold text-sm" id="check-title">Vérification en cours...</div>
<div class="text-gray-400 text-xs mt-2" id="check-detail"></div>
<div class="text-gray-500 text-xs mt-3 font-mono" id="check-timer">0s</div>
</div>
</div>
<style>@keyframes opspin{to{transform:rotate(360deg)}}</style>
<!-- Zone résultats check -->
<div id="check-results" style="display:none" class="mt-4 space-y-4">
<div id="check-kpis" style="display:flex;gap:8px;margin-bottom:16px"></div>
<div id="check-table" class="card overflow-hidden"></div>
</div>
<script>
var _pollTimer = null;
var _checkTimer = null;
var STAGE_LABELS = {
'pending': {label: 'En attente', cls: 'badge-gray', icon: '&#9679;'},
'connecting': {label: 'Connexion SSH', cls: 'badge-yellow', icon: '&#8635;'},
'checking': {label: 'Vérification', cls: 'badge-yellow', icon: '&#8635;'},
'copying': {label: 'Copie package', cls: 'badge-yellow', icon: '&#8635;'},
'installing': {label: 'Installation', cls: 'badge-yellow', icon: '&#8635;'},
'activating': {label: 'Activation', cls: 'badge-yellow', icon: '&#8635;'},
'restarting': {label: 'Redémarrage', cls: 'badge-yellow', icon: '&#8635;'},
'verifying': {label: 'Vérification', cls: 'badge-yellow', icon: '&#8635;'},
'success': {label: 'Succès', cls: 'badge-green', icon: '&#10003;'},
'already_installed': {label: 'Déjà installé', cls: 'badge-blue', icon: '&#10003;'},
'downgrade_refused': {label: 'Downgrade refusé', cls: 'badge-yellow', icon: '&#9888;'},
'partial': {label: 'Partiel', cls: 'badge-yellow', icon: '&#9888;'},
'failed': {label: 'Échec', cls: 'badge-red', icon: '&#10007;'},
};
function _stageBadge(stage) {
var s = STAGE_LABELS[stage] || {label: stage, cls: 'badge-gray', icon: '?'};
var anim = (stage !== 'pending' && stage !== 'success' && stage !== 'failed' && stage !== 'partial' && stage !== 'already_installed')
? ' style="animation:pulse 1.5s ease-in-out infinite"' : '';
return '<span class="badge ' + s.cls + '"' + anim + '>' + s.icon + ' ' + s.label + '</span>';
}
function deployAgent(ids) {
if (!ids.length) return;
if (!confirm('Déployer l\'agent sur ' + ids.length + ' serveur(s) ?\n\nLe déploiement se fait en arrière-plan.')) return;
document.getElementById('btn-deploy').disabled = true;
fetch('/qualys/deploy/run', {
method: 'POST', credentials: 'same-origin',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
server_ids: ids.join(','),
activation_id: document.getElementById('activation_id').value,
customer_id: document.getElementById('customer_id').value,
server_uri: document.getElementById('server_uri').value,
package_deb: document.getElementById('package_deb').value,
package_rpm: document.getElementById('package_rpm').value,
force_downgrade: document.getElementById('force_downgrade').checked,
})
})
.then(function(r){
var ct = r.headers.get('content-type') || '';
if (ct.indexOf('json') === -1) throw new Error('Erreur serveur (HTTP ' + r.status + ')');
return r.json();
})
.then(function(data){
if (data.ok && data.job_id) {
startPolling(data.job_id, data.total);
} else {
alert('Erreur: ' + (data.msg || 'Erreur inconnue'));
document.getElementById('btn-deploy').disabled = false;
}
})
.catch(function(err){
alert('Erreur: ' + err.message);
document.getElementById('btn-deploy').disabled = false;
});
}
function startPolling(jobId, total) {
var zone = document.getElementById('progress-zone');
var title = document.getElementById('progress-title');
var timer = document.getElementById('progress-timer');
var bar = document.getElementById('progress-bar');
var summary = document.getElementById('progress-summary');
var tbody = document.getElementById('progress-body');
zone.style.display = 'block';
zone.scrollIntoView({behavior: 'smooth'});
title.textContent = 'Déploiement en cours... (' + total + ' serveur(s))';
function poll() {
fetch('/qualys/deploy/status/' + jobId, {credentials: 'same-origin'})
.then(function(r){ return r.json(); })
.then(function(data){
if (!data.ok) return;
timer.textContent = data.elapsed + 's';
var pct = data.total > 0 ? Math.round((data.done / data.total) * 100) : 0;
bar.style.width = pct + '%';
summary.textContent = data.done + ' / ' + data.total + ' terminé(s)';
// Build rows
var rows = '';
var hostnames = Object.keys(data.servers).sort();
hostnames.forEach(function(hn){
var s = data.servers[hn];
rows += '<tr class="border-t border-cyber-border/30">';
rows += '<td class="p-2 font-mono">' + hn + '</td>';
rows += '<td class="p-2 text-center">' + _stageBadge(s.stage) + '</td>';
rows += '<td class="p-2 text-gray-400 text-xs">' + (s.detail || '') + '</td>';
rows += '</tr>';
});
tbody.innerHTML = rows;
if (data.finished) {
if (_pollTimer) { clearInterval(_pollTimer); _pollTimer = null; }
var ok = 0, fail = 0;
hostnames.forEach(function(hn){
var st = data.servers[hn].stage;
if (st === 'success' || st === 'already_installed') ok++;
else if (st === 'failed') fail++;
});
title.innerHTML = 'Déploiement terminé — <span class="text-cyber-green">' + ok + ' OK</span> / <span class="text-cyber-red">' + fail + ' échec(s)</span>';
bar.style.background = fail > 0 ? '#ff3366' : '#00ffc8';
document.getElementById('btn-deploy').disabled = false;
// Show log
if (data.log && data.log.length > 0) {
document.getElementById('progress-log-content').textContent = data.log.join('\n');
document.getElementById('progress-log').style.display = 'block';
}
}
})
.catch(function(){});
}
poll();
_pollTimer = setInterval(poll, 2000);
}
// === Check (reste synchrone, c'est rapide ~5-15s par serveur) ===
function checkAgent(ids) {
if (!ids.length) return;
var ov = document.getElementById('check-overlay');
var timer = document.getElementById('check-timer');
document.getElementById('check-title').textContent = 'Vérification de ' + ids.length + ' serveur(s)...';
document.getElementById('check-detail').textContent = 'Connexion SSH et vérification du statut';
timer.textContent = '0s';
ov.style.display = 'flex';
var t0 = Date.now();
if (_checkTimer) clearInterval(_checkTimer);
_checkTimer = setInterval(function(){ timer.textContent = Math.floor((Date.now()-t0)/1000) + 's'; }, 1000);
fetch('/qualys/deploy/check', {
method: 'POST', credentials: 'same-origin',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({server_ids: ids.join(',')})
})
.then(function(r){
var ct = r.headers.get('content-type') || '';
if (ct.indexOf('json') === -1) throw new Error('Erreur serveur (HTTP ' + r.status + ')');
return r.json();
})
.then(function(data){
clearInterval(_checkTimer);
ov.style.display = 'none';
if (data.ok) showCheckResults(data);
else alert('Erreur: ' + (data.msg || ''));
})
.catch(function(err){
clearInterval(_checkTimer);
ov.style.display = 'none';
alert('Erreur: ' + err.message);
});
}
function showCheckResults(data) {
var zone = document.getElementById('check-results');
var kpis = document.getElementById('check-kpis');
var tbl = document.getElementById('check-table');
kpis.innerHTML =
'<div class="card p-3 text-center" style="flex:1"><div class="text-2xl font-bold text-cyber-accent">' + data.total + '</div><div class="text-xs text-gray-500">Total</div></div>' +
'<div class="card p-3 text-center" style="flex:1"><div class="text-2xl font-bold text-cyber-green">' + (data.active||0) + '</div><div class="text-xs text-gray-500">Actifs</div></div>' +
'<div class="card p-3 text-center" style="flex:1"><div class="text-2xl font-bold text-cyber-yellow">' + (data.not_installed||0) + '</div><div class="text-xs text-gray-500">Non installés</div></div>' +
'<div class="card p-3 text-center" style="flex:1"><div class="text-2xl font-bold text-cyber-red">' + (data.failed||0) + '</div><div class="text-xs text-gray-500">Connexion échouée</div></div>';
var rows = '';
data.results.forEach(function(r){
var cls = r.status==='ACTIVE'?'badge-green': r.status==='NOT_INSTALLED'?'badge-yellow': r.status==='INACTIVE'?'badge-yellow': 'badge-red';
rows += '<tr class="border-t border-cyber-border/30">';
rows += '<td class="p-2 font-mono">' + r.hostname + '</td>';
rows += '<td class="p-2 text-center"><span class="badge ' + cls + '">' + r.status + '</span></td>';
rows += '<td class="p-2 text-gray-400">' + (r.detail||'') + '</td>';
rows += '<td class="p-2 text-center font-mono text-gray-400">' + (r.version||'-') + '</td>';
rows += '<td class="p-2 text-center">' + (r.service_status||'-') + '</td>';
rows += '</tr>';
});
tbl.innerHTML = '<table class="w-full table-cyber text-xs"><thead><tr><th class="p-2 text-left">Hostname</th><th class="p-2">Statut</th><th class="p-2 text-left">Détail</th><th class="p-2">Version</th><th class="p-2">Service</th></tr></thead><tbody>' + rows + '</tbody></table>';
zone.style.display = 'block';
zone.scrollIntoView({behavior:'smooth'});
}
</script>
<style>
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.5} }
</style>
{% endblock %}