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:
parent
8479d7280e
commit
5ea4100f4c
@ -519,20 +519,20 @@ async def qualys_agents_page(request: Request, db=Depends(get_db)):
|
|||||||
|
|
||||||
@router.post("/qualys/agents/refresh")
|
@router.post("/qualys/agents/refresh")
|
||||||
async def qualys_agents_refresh(request: Request, db=Depends(get_db)):
|
async def qualys_agents_refresh(request: Request, db=Depends(get_db)):
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
user = get_current_user(request)
|
user = get_current_user(request)
|
||||||
if not user:
|
if not user:
|
||||||
return RedirectResponse(url="/login")
|
return JSONResponse({"ok": False, "msg": "Non authentifié"}, status_code=401)
|
||||||
perms = get_user_perms(db, user)
|
perms = get_user_perms(db, user)
|
||||||
if not can_edit(perms, "qualys"):
|
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
|
from ..services.qualys_service import refresh_all_agents
|
||||||
try:
|
try:
|
||||||
stats = refresh_all_agents(db)
|
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:
|
except Exception as e:
|
||||||
import traceback; traceback.print_exc()
|
import traceback; traceback.print_exc()
|
||||||
msg = "refresh_error"
|
return JSONResponse({"ok": False, "msg": str(e)[:200]}, status_code=500)
|
||||||
return RedirectResponse(url=f"/qualys/agents?msg={msg}", status_code=303)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/qualys/agents/export-no-agent")
|
@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()
|
packages = list_packages()
|
||||||
servers = db.execute(text("""
|
servers = db.execute(text("""
|
||||||
SELECT s.id, s.hostname, s.os_family, s.etat, s.ssh_user, s.ssh_port, s.ssh_method,
|
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
|
d.name as domain, e.name as env,
|
||||||
|
qa.agent_version, qa.agent_status, qa.last_checkin
|
||||||
FROM servers s
|
FROM servers s
|
||||||
LEFT JOIN domain_environments de ON s.domain_env_id = de.id
|
LEFT JOIN domain_environments de ON s.domain_env_id = de.id
|
||||||
LEFT JOIN domains d ON de.domain_id = d.id
|
LEFT JOIN domains d ON de.domain_id = d.id
|
||||||
LEFT JOIN environments e ON de.environment_id = e.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
|
ORDER BY s.hostname
|
||||||
""")).fetchall()
|
""")).fetchall()
|
||||||
servers = [dict(r._mapping) for r in servers]
|
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 = base_context(request, db, user)
|
||||||
ctx.update({
|
ctx.update({
|
||||||
@ -896,106 +901,96 @@ async def qualys_deploy_page(request: Request, db=Depends(get_db)):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/qualys/deploy/run")
|
@router.post("/qualys/deploy/run")
|
||||||
async def qualys_deploy_run(request: Request, db=Depends(get_db),
|
async def qualys_deploy_run(request: Request, db=Depends(get_db)):
|
||||||
server_ids: str = Form(""),
|
from fastapi.responses import JSONResponse
|
||||||
activation_id: str = Form(""),
|
|
||||||
customer_id: str = Form(""),
|
|
||||||
server_uri: str = Form(""),
|
|
||||||
package_deb: str = Form(""),
|
|
||||||
package_rpm: str = Form("")):
|
|
||||||
user = get_current_user(request)
|
user = get_current_user(request)
|
||||||
if not user:
|
if not user:
|
||||||
return RedirectResponse(url="/login")
|
return JSONResponse({"ok": False, "msg": "Non authentifié"}, status_code=401)
|
||||||
perms = get_user_perms(db, user)
|
perms = get_user_perms(db, user)
|
||||||
if not can_edit(perms, "qualys"):
|
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
|
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:
|
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"
|
ssh_key = get_secret(db, "ssh_key_file") or "/opt/patchcenter/keys/id_ed25519"
|
||||||
|
|
||||||
# Get servers
|
|
||||||
placeholders = ",".join(str(i) for i in ids)
|
placeholders = ",".join(str(i) for i in ids)
|
||||||
servers = db.execute(text(f"""
|
rows = db.execute(text(f"""
|
||||||
SELECT id, hostname, os_family, ssh_user, ssh_port, ssh_method
|
SELECT id, hostname, os_family, os_version, ssh_user, ssh_port, ssh_method
|
||||||
FROM servers WHERE id IN ({placeholders})
|
FROM servers WHERE id IN ({placeholders})
|
||||||
""")).fetchall()
|
""")).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 = []
|
job_id = start_deploy_job(servers, ssh_key, package_deb, package_rpm,
|
||||||
log_lines = []
|
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
|
from ..services.audit_service import log_action
|
||||||
ok = sum(1 for r in results if r["status"] in ("SUCCESS", "ALREADY_INSTALLED"))
|
log_action(db, request, user, "qualys_deploy",
|
||||||
fail = sum(1 for r in results if r["status"] not in ("SUCCESS", "ALREADY_INSTALLED"))
|
entity_type="deploy_job",
|
||||||
log_action(db, request, user, "qualys_deploy", f"{ok} OK, {fail} fail sur {len(results)} serveurs")
|
details={"job_id": job_id, "servers": len(servers)})
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
ctx = base_context(request, db, user)
|
return JSONResponse({"ok": True, "job_id": job_id, "total": len(servers)})
|
||||||
ctx.update({
|
|
||||||
"app_name": APP_NAME,
|
|
||||||
"results": results,
|
@router.get("/qualys/deploy/status/{job_id}")
|
||||||
"log_lines": log_lines,
|
async def qualys_deploy_status(request: Request, job_id: str, db=Depends(get_db)):
|
||||||
"total": len(results),
|
from fastapi.responses import JSONResponse
|
||||||
"ok": ok,
|
user = get_current_user(request)
|
||||||
"failed": fail,
|
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")
|
@router.post("/qualys/deploy/check")
|
||||||
async def qualys_deploy_check(request: Request, db=Depends(get_db),
|
async def qualys_deploy_check(request: Request, db=Depends(get_db)):
|
||||||
server_ids: str = Form("")):
|
from fastapi.responses import JSONResponse
|
||||||
user = get_current_user(request)
|
user = get_current_user(request)
|
||||||
if not user:
|
if not user:
|
||||||
return RedirectResponse(url="/login")
|
return JSONResponse({"ok": False, "msg": "Non authentifié"}, status_code=401)
|
||||||
perms = get_user_perms(db, user)
|
perms = get_user_perms(db, user)
|
||||||
if not can_view(perms, "qualys"):
|
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.agent_deploy_service import check_agent
|
||||||
from ..services.secrets_service import get_secret
|
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:
|
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"
|
ssh_key = get_secret(db, "ssh_key_file") or "/opt/patchcenter/keys/id_ed25519"
|
||||||
placeholders = ",".join(str(i) for i in ids)
|
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)
|
r = check_agent(srv.hostname, srv.ssh_user or "root", ssh_key, srv.ssh_port or 22)
|
||||||
results.append(r)
|
results.append(r)
|
||||||
|
|
||||||
ctx = base_context(request, db, user)
|
return JSONResponse({"ok": True, "results": results, "total": len(results),
|
||||||
ctx.update({
|
"active": sum(1 for r in results if r["status"] == "ACTIVE"),
|
||||||
"app_name": APP_NAME,
|
"not_installed": sum(1 for r in results if r["status"] == "NOT_INSTALLED"),
|
||||||
"results": results,
|
"failed": sum(1 for r in results if r["status"] == "CONNECTION_FAILED")})
|
||||||
"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)
|
|
||||||
|
|||||||
@ -80,11 +80,21 @@ def check_agent(hostname, ssh_user, ssh_key_path, ssh_port=22):
|
|||||||
|
|
||||||
result = {"hostname": hostname}
|
result = {"hostname": hostname}
|
||||||
|
|
||||||
# Check if installed
|
# Check if installed (rpm -q returns 0 only if installed, dpkg -s returns 0 only 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'")
|
installed = False
|
||||||
if code != 0 and not out.strip():
|
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["status"] = "NOT_INSTALLED"
|
||||||
result["detail"] = "Agent non installe"
|
result["detail"] = "Agent non installe"
|
||||||
|
result["version"] = ""
|
||||||
|
result["service_status"] = ""
|
||||||
client.close()
|
client.close()
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@ -94,9 +104,8 @@ def check_agent(hostname, ssh_user, ssh_key_path, ssh_port=22):
|
|||||||
result["service_status"] = status
|
result["service_status"] = status
|
||||||
|
|
||||||
# Get version via package manager
|
# 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()
|
version = out.strip()
|
||||||
# Extract just version number
|
|
||||||
m = re.search(r'(\d+\.\d+\.\d+[\.\-]\d+)', version)
|
m = re.search(r'(\d+\.\d+\.\d+[\.\-]\d+)', version)
|
||||||
result["version"] = m.group(1) if m else version[:50]
|
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
|
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,
|
def deploy_agent(hostname, ssh_user, ssh_key_path, ssh_port, os_family,
|
||||||
package_path, activation_id, customer_id, server_uri,
|
package_path, activation_id, customer_id, server_uri,
|
||||||
on_line=None):
|
on_line=None, on_stage=None, force_downgrade=False):
|
||||||
"""Deploie l'agent Qualys sur un serveur"""
|
"""Deploie l'agent Qualys sur un serveur (install, upgrade ou downgrade)"""
|
||||||
|
|
||||||
def emit(msg):
|
def emit(msg):
|
||||||
if on_line:
|
if on_line:
|
||||||
on_line(msg)
|
on_line(msg)
|
||||||
log.info(f"[{hostname}] {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}...")
|
emit(f"Connexion SSH {ssh_user}@{hostname}:{ssh_port}...")
|
||||||
client, error = _get_ssh_client(hostname, ssh_user, ssh_key_path, ssh_port)
|
client, error = _get_ssh_client(hostname, ssh_user, ssh_key_path, ssh_port)
|
||||||
if not client:
|
if not client:
|
||||||
emit(f"ERREUR connexion: {error}")
|
emit(f"ERREUR connexion: {error}")
|
||||||
|
stage("failed", error)
|
||||||
return {"hostname": hostname, "status": "FAILED", "detail": error}
|
return {"hostname": hostname, "status": "FAILED", "detail": error}
|
||||||
|
|
||||||
result = {"hostname": hostname, "status": "PENDING"}
|
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:
|
try:
|
||||||
# 1. Detect OS if not provided
|
# 1. Detect OS if not provided
|
||||||
if not os_family:
|
if not os_family:
|
||||||
code, out, _ = _run_cmd(client, "cat /etc/os-release 2>/dev/null | head -3")
|
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():
|
if "debian" in out.lower() or "ubuntu" in out.lower():
|
||||||
os_family = "debian"
|
os_family = "debian"
|
||||||
elif "red hat" in out.lower() or "centos" in out.lower() or "rocky" in out.lower():
|
elif "red hat" in out.lower() or "centos" in out.lower() or "rocky" in out.lower():
|
||||||
os_family = "rhel"
|
os_family = "rhel"
|
||||||
emit(f"OS detecte: {os_family}")
|
emit(f"OS detecte: {os_family}")
|
||||||
|
|
||||||
# 2. Check if already installed
|
# 2. Check installed version
|
||||||
code, out, _ = _run_cmd(client, "systemctl is-active qualys-cloud-agent 2>/dev/null")
|
stage("checking", "Verification agent existant")
|
||||||
if out.strip() == "active":
|
installed_version = None
|
||||||
emit("Agent deja installe et actif - skip")
|
code, out, _ = _run_cmd(client, "rpm -q qualys-cloud-agent 2>/dev/null")
|
||||||
result["status"] = "ALREADY_INSTALLED"
|
if code == 0 and "not installed" not in out.lower() and "n'est pas install" not in out.lower():
|
||||||
result["detail"] = "Agent deja actif"
|
m = re.search(r'(\d+\.\d+\.\d+[\.\-]\d+)', out)
|
||||||
client.close()
|
installed_version = m.group(1) if m else None
|
||||||
return result
|
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
|
# 3. Copy package
|
||||||
pkg_name = os.path.basename(package_path)
|
pkg_size = os.path.getsize(package_path) // 1024 // 1024
|
||||||
remote_path = f"/tmp/{pkg_name}"
|
stage("copying", f"Copie {pkg_name} ({pkg_size} Mo)")
|
||||||
emit(f"Copie {pkg_name} ({os.path.getsize(package_path)//1024//1024} Mo)...")
|
emit(f"Copie {pkg_name} ({pkg_size} Mo)...")
|
||||||
|
|
||||||
sftp = client.open_sftp()
|
sftp = client.open_sftp()
|
||||||
sftp.put(package_path, remote_path)
|
sftp.put(package_path, remote_path=f"/tmp/{pkg_name}")
|
||||||
sftp.close()
|
sftp.close()
|
||||||
emit("Copie terminee")
|
emit("Copie terminee")
|
||||||
|
|
||||||
# 4. Install
|
remote_path = f"/tmp/{pkg_name}"
|
||||||
is_deb = pkg_name.endswith(".deb")
|
|
||||||
|
# 4. Install / Upgrade / Downgrade
|
||||||
if is_deb:
|
if is_deb:
|
||||||
|
stage("installing", "Installation (dpkg)")
|
||||||
emit("Installation (dpkg)...")
|
emit("Installation (dpkg)...")
|
||||||
code, out, err = _run_cmd(client, f"dpkg --install {remote_path}", sudo=True, timeout=120)
|
code, out, err = _run_cmd(client, f"dpkg --install {remote_path}", sudo=True, timeout=120)
|
||||||
else:
|
else:
|
||||||
emit("Installation (rpm)...")
|
if force_downgrade and installed_version:
|
||||||
code, out, err = _run_cmd(client, f"rpm -ivh --nosignature {remote_path}", sudo=True, timeout=120)
|
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():
|
if code != 0 and "already installed" not in (out + err).lower():
|
||||||
emit(f"ERREUR installation (code {code}): {err[:200]}")
|
emit(f"ERREUR installation (code {code}): {err[:200]}")
|
||||||
|
stage("failed", f"Installation echouee: {err[:100]}")
|
||||||
result["status"] = "INSTALL_FAILED"
|
result["status"] = "INSTALL_FAILED"
|
||||||
result["detail"] = err[:200]
|
result["detail"] = err[:200]
|
||||||
client.close()
|
client.close()
|
||||||
@ -184,6 +257,7 @@ def deploy_agent(hostname, ssh_user, ssh_key_path, ssh_port, os_family,
|
|||||||
emit("Installation OK")
|
emit("Installation OK")
|
||||||
|
|
||||||
# 5. Activate
|
# 5. Activate
|
||||||
|
stage("activating", "Activation de l'agent")
|
||||||
emit("Activation de l'agent...")
|
emit("Activation de l'agent...")
|
||||||
activate_cmd = (
|
activate_cmd = (
|
||||||
f"/usr/local/qualys/cloud-agent/bin/qualys-cloud-agent.sh "
|
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)
|
code, out, err = _run_cmd(client, activate_cmd, sudo=True, timeout=60)
|
||||||
if code != 0:
|
if code != 0:
|
||||||
emit(f"ERREUR activation (code {code}): {err[:200]}")
|
emit(f"ERREUR activation (code {code}): {err[:200]}")
|
||||||
|
stage("failed", f"Activation echouee: {err[:100]}")
|
||||||
result["status"] = "ACTIVATE_FAILED"
|
result["status"] = "ACTIVATE_FAILED"
|
||||||
result["detail"] = err[:200]
|
result["detail"] = err[:200]
|
||||||
client.close()
|
client.close()
|
||||||
@ -202,17 +277,22 @@ def deploy_agent(hostname, ssh_user, ssh_key_path, ssh_port, os_family,
|
|||||||
emit("Activation OK")
|
emit("Activation OK")
|
||||||
|
|
||||||
# 6. Restart service
|
# 6. Restart service
|
||||||
|
stage("restarting", "Redemarrage du service")
|
||||||
emit("Redemarrage du service...")
|
emit("Redemarrage du service...")
|
||||||
_run_cmd(client, "systemctl restart qualys-cloud-agent", sudo=True)
|
_run_cmd(client, "systemctl restart qualys-cloud-agent", sudo=True)
|
||||||
|
|
||||||
# 7. Verify
|
# 7. Verify
|
||||||
|
stage("verifying", "Verification du service")
|
||||||
code, out, _ = _run_cmd(client, "systemctl is-active qualys-cloud-agent")
|
code, out, _ = _run_cmd(client, "systemctl is-active qualys-cloud-agent")
|
||||||
|
action = "Upgrade" if installed_version else "Install"
|
||||||
if out.strip() == "active":
|
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["status"] = "SUCCESS"
|
||||||
result["detail"] = "Agent deploye avec succes"
|
result["detail"] = f"{action} v{pkg_version} OK"
|
||||||
else:
|
else:
|
||||||
emit(f"Agent installe mais statut: {out.strip()}")
|
emit(f"Agent installe mais statut: {out.strip()}")
|
||||||
|
stage("partial", f"Service: {out.strip()}")
|
||||||
result["status"] = "PARTIAL"
|
result["status"] = "PARTIAL"
|
||||||
result["detail"] = f"Installe, service: {out.strip()}"
|
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:
|
except Exception as e:
|
||||||
emit(f"ERREUR: {e}")
|
emit(f"ERREUR: {e}")
|
||||||
|
stage("failed", str(e)[:100])
|
||||||
result["status"] = "FAILED"
|
result["status"] = "FAILED"
|
||||||
result["detail"] = str(e)[:200]
|
result["detail"] = str(e)[:200]
|
||||||
|
|
||||||
client.close()
|
client.close()
|
||||||
return result
|
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}
|
||||||
|
|||||||
@ -7,26 +7,71 @@
|
|||||||
<p class="text-xs text-gray-500 mt-1">Activation keys et versions des agents déployés</p>
|
<p class="text-xs text-gray-500 mt-1">Activation keys et versions des agents déployés</p>
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex;gap:8px">
|
<div style="display:flex;gap:8px">
|
||||||
<form method="POST" action="/qualys/agents/refresh" style="display:inline">
|
<button id="btn-refresh" class="btn-primary px-4 py-2 text-sm" onclick="refreshAgents()">
|
||||||
<button type="submit" class="btn-primary px-4 py-2 text-sm"
|
Rafraîchir depuis Qualys
|
||||||
onclick="this.disabled=true;this.textContent='Rafraîchissement...'">
|
</button>
|
||||||
Rafraîchir depuis Qualys
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
<a href="/qualys/deploy" class="btn-sm bg-cyber-border text-gray-300 px-4 py-2">Déployer</a>
|
<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>
|
<a href="/qualys/search" class="btn-sm bg-cyber-border text-gray-300 px-4 py-2">Recherche</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if 'refresh_ok' in msg %}
|
<!-- Overlay chargement -->
|
||||||
<div style="background:#1a5a2e;color:#8f8;padding:8px 16px;border-radius:6px;margin-bottom:12px;font-size:0.85rem">
|
<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">
|
||||||
Données rafraîchies depuis Qualys.
|
<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>
|
</div>
|
||||||
{% elif msg == 'refresh_error' %}
|
<style>@keyframes spin{to{transform:rotate(360deg)}}</style>
|
||||||
<div style="background:#5a1a1a;color:#ff3366;padding:8px 16px;border-radius:6px;margin-bottom:12px;font-size:0.85rem">
|
|
||||||
Erreur lors du rafraîchissement.
|
<!-- Message résultat -->
|
||||||
</div>
|
<div id="refresh-msg" style="display:none;padding:8px 16px;border-radius:6px;margin-bottom:12px;font-size:0.85rem"></div>
|
||||||
{% endif %}
|
|
||||||
|
<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 -->
|
<!-- KPIs agents -->
|
||||||
<div style="display:flex;flex-wrap:nowrap;gap:8px;margin-bottom:16px;">
|
<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 text-gray-400">{{ s.domain or '-' }}</td>
|
||||||
<td class="p-2 text-center">{{ s.env 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">{% 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>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@ -78,28 +78,20 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Actions -->
|
<!-- Actions -->
|
||||||
<div style="display:flex;gap:8px">
|
<div style="display:flex;gap:8px;align-items:center">
|
||||||
<form method="POST" action="/qualys/deploy/run" id="deployForm">
|
<button id="btn-deploy" class="btn-primary px-4 py-2 text-sm"
|
||||||
<input type="hidden" name="server_ids" :value="selectedIds.join(',')">
|
:disabled="selectedIds.length === 0"
|
||||||
<input type="hidden" name="activation_id" :value="document.getElementById('activation_id').value">
|
@click="deployAgent(selectedIds)">
|
||||||
<input type="hidden" name="customer_id" :value="document.getElementById('customer_id').value">
|
Déployer l'agent
|
||||||
<input type="hidden" name="server_uri" :value="document.getElementById('server_uri').value">
|
</button>
|
||||||
<input type="hidden" name="package_deb" :value="document.getElementById('package_deb').value">
|
<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"
|
||||||
<input type="hidden" name="package_rpm" :value="document.getElementById('package_rpm').value">
|
:disabled="selectedIds.length === 0"
|
||||||
<button type="submit" class="btn-primary px-4 py-2 text-sm"
|
@click="checkAgent(selectedIds)">
|
||||||
:disabled="selectedIds.length === 0"
|
Vérifier l'agent
|
||||||
onclick="if(!confirm('Déployer l\'agent sur ' + selectedIds.length + ' serveur(s) ?')) return false; this.textContent='Déploiement en cours...'">
|
</button>
|
||||||
Déployer l'agent
|
<label class="text-xs text-gray-400 ml-4" style="display:flex;align-items:center;gap:4px">
|
||||||
</button>
|
<input type="checkbox" id="force_downgrade"> Forcer le downgrade
|
||||||
</form>
|
</label>
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
<!-- Serveurs -->
|
<!-- 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) : []"
|
<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>
|
x-init="servers = {{ servers | tojson }}"></th>
|
||||||
<th class="p-2 text-left">Hostname</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">Domaine</th>
|
||||||
<th class="p-2">Env</th>
|
<th class="p-2">Env</th>
|
||||||
<th class="p-2">État</th>
|
<th class="p-2">État</th>
|
||||||
|
<th class="p-2">Agent installé</th>
|
||||||
<th class="p-2">SSH</th>
|
<th class="p-2">SSH</th>
|
||||||
</tr></thead>
|
</tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@ -129,13 +122,20 @@
|
|||||||
<td class="p-2 text-center">
|
<td class="p-2 text-center">
|
||||||
{% if s.os_family == 'linux' %}<span class="badge badge-green">Linux</span>
|
{% if s.os_family == 'linux' %}<span class="badge badge-green">Linux</span>
|
||||||
{% else %}<span class="badge badge-blue">{{ s.os_family or '?' }}</span>{% endif %}
|
{% 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>
|
||||||
<td class="p-2 text-center text-gray-400">{{ s.domain or '-' }}</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">{{ s.env or '-' }}</td>
|
||||||
<td class="p-2 text-center">
|
<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 %}
|
{% else %}{{ s.etat or '-' }}{% endif %}
|
||||||
</td>
|
</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>
|
<td class="p-2 text-center text-gray-500">{{ s.ssh_user or 'root' }}:{{ s.ssh_port or 22 }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@ -143,4 +143,248 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</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: '●'},
|
||||||
|
'connecting': {label: 'Connexion SSH', cls: 'badge-yellow', icon: '↻'},
|
||||||
|
'checking': {label: 'Vérification', cls: 'badge-yellow', icon: '↻'},
|
||||||
|
'copying': {label: 'Copie package', cls: 'badge-yellow', icon: '↻'},
|
||||||
|
'installing': {label: 'Installation', cls: 'badge-yellow', icon: '↻'},
|
||||||
|
'activating': {label: 'Activation', cls: 'badge-yellow', icon: '↻'},
|
||||||
|
'restarting': {label: 'Redémarrage', cls: 'badge-yellow', icon: '↻'},
|
||||||
|
'verifying': {label: 'Vérification', cls: 'badge-yellow', icon: '↻'},
|
||||||
|
'success': {label: 'Succès', cls: 'badge-green', icon: '✓'},
|
||||||
|
'already_installed': {label: 'Déjà installé', cls: 'badge-blue', icon: '✓'},
|
||||||
|
'downgrade_refused': {label: 'Downgrade refusé', cls: 'badge-yellow', icon: '⚠'},
|
||||||
|
'partial': {label: 'Partiel', cls: 'badge-yellow', icon: '⚠'},
|
||||||
|
'failed': {label: 'Échec', cls: 'badge-red', icon: '✗'},
|
||||||
|
};
|
||||||
|
|
||||||
|
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 %}
|
{% endblock %}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user