Compare commits
10 Commits
e551ddf575
...
e96d79aae3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e96d79aae3 | ||
|
|
13290c1ebb | ||
|
|
5cc10c5b6c | ||
|
|
c550597a86 | ||
|
|
769e199735 | ||
|
|
7f5e5c83eb | ||
|
|
5db47c497f | ||
|
|
6a5bdefde5 | ||
|
|
b159960522 | ||
|
|
c22ad75ee8 |
@ -6,7 +6,7 @@ from starlette.middleware.base import BaseHTTPMiddleware
|
|||||||
from .config import APP_NAME, APP_VERSION
|
from .config import APP_NAME, APP_VERSION
|
||||||
from .dependencies import get_current_user, get_user_perms
|
from .dependencies import get_current_user, get_user_perms
|
||||||
from .database import SessionLocal
|
from .database import SessionLocal
|
||||||
from .routers import auth, dashboard, servers, settings, users, campaigns, planning, specifics, audit, contacts, qualys, safe_patching, audit_full
|
from .routers import auth, dashboard, servers, settings, users, campaigns, planning, specifics, audit, contacts, qualys, safe_patching, audit_full, quickwin, referentiel
|
||||||
|
|
||||||
|
|
||||||
class PermissionsMiddleware(BaseHTTPMiddleware):
|
class PermissionsMiddleware(BaseHTTPMiddleware):
|
||||||
@ -43,6 +43,8 @@ app.include_router(contacts.router)
|
|||||||
app.include_router(qualys.router)
|
app.include_router(qualys.router)
|
||||||
app.include_router(safe_patching.router)
|
app.include_router(safe_patching.router)
|
||||||
app.include_router(audit_full.router)
|
app.include_router(audit_full.router)
|
||||||
|
app.include_router(quickwin.router)
|
||||||
|
app.include_router(referentiel.router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
|
|||||||
@ -18,6 +18,9 @@ async def audit_page(request: Request, db=Depends(get_db),
|
|||||||
user = get_current_user(request)
|
user = get_current_user(request)
|
||||||
if not user:
|
if not user:
|
||||||
return RedirectResponse(url="/login")
|
return RedirectResponse(url="/login")
|
||||||
|
perms = get_user_perms(db, user)
|
||||||
|
if not can_view(perms, "audit"):
|
||||||
|
return RedirectResponse(url="/dashboard")
|
||||||
|
|
||||||
where = ["1=1"]
|
where = ["1=1"]
|
||||||
params = {}
|
params = {}
|
||||||
@ -223,6 +226,9 @@ async def audit_realtime_save(request: Request, db=Depends(get_db)):
|
|||||||
user = get_current_user(request)
|
user = get_current_user(request)
|
||||||
if not user:
|
if not user:
|
||||||
return RedirectResponse(url="/login")
|
return RedirectResponse(url="/login")
|
||||||
|
perms = get_user_perms(db, user)
|
||||||
|
if not can_edit(perms, "audit"):
|
||||||
|
return RedirectResponse(url="/audit")
|
||||||
|
|
||||||
results = getattr(request.app.state, "last_audit_results", None)
|
results = getattr(request.app.state, "last_audit_results", None)
|
||||||
if not results:
|
if not results:
|
||||||
@ -238,6 +244,9 @@ async def audit_export_csv(request: Request, db=Depends(get_db),
|
|||||||
user = get_current_user(request)
|
user = get_current_user(request)
|
||||||
if not user:
|
if not user:
|
||||||
return RedirectResponse(url="/login")
|
return RedirectResponse(url="/login")
|
||||||
|
perms = get_user_perms(db, user)
|
||||||
|
if not can_view(perms, "audit"):
|
||||||
|
return RedirectResponse(url="/audit")
|
||||||
|
|
||||||
where = ["1=1"]
|
where = ["1=1"]
|
||||||
params = {}
|
params = {}
|
||||||
|
|||||||
@ -4,7 +4,7 @@ from fastapi import APIRouter, Request, Depends, UploadFile, File
|
|||||||
from fastapi.responses import HTMLResponse, RedirectResponse, StreamingResponse
|
from fastapi.responses import HTMLResponse, RedirectResponse, StreamingResponse
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from ..dependencies import get_db, get_current_user, get_user_perms, can_view, base_context
|
from ..dependencies import get_db, get_current_user, get_user_perms, can_view, can_edit, base_context
|
||||||
from ..services.server_audit_full_service import (
|
from ..services.server_audit_full_service import (
|
||||||
import_json_report, get_latest_audits, get_audit_detail,
|
import_json_report, get_latest_audits, get_audit_detail,
|
||||||
get_flow_map, get_flow_map_for_server, get_app_map,
|
get_flow_map, get_flow_map_for_server, get_app_map,
|
||||||
@ -208,6 +208,9 @@ async def audit_full_import(request: Request, db=Depends(get_db),
|
|||||||
user = get_current_user(request)
|
user = get_current_user(request)
|
||||||
if not user:
|
if not user:
|
||||||
return RedirectResponse(url="/login")
|
return RedirectResponse(url="/login")
|
||||||
|
perms = get_user_perms(db, user)
|
||||||
|
if not can_edit(perms, "audit"):
|
||||||
|
return RedirectResponse(url="/audit-full")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
content = await file.read()
|
content = await file.read()
|
||||||
@ -229,6 +232,9 @@ async def audit_full_patching(request: Request, db=Depends(get_db)):
|
|||||||
user = get_current_user(request)
|
user = get_current_user(request)
|
||||||
if not user:
|
if not user:
|
||||||
return RedirectResponse(url="/login")
|
return RedirectResponse(url="/login")
|
||||||
|
perms = get_user_perms(db, user)
|
||||||
|
if not can_view(perms, "audit"):
|
||||||
|
return RedirectResponse(url="/dashboard")
|
||||||
|
|
||||||
year = int(request.query_params.get("year", "2026"))
|
year = int(request.query_params.get("year", "2026"))
|
||||||
search = request.query_params.get("q", "").strip()
|
search = request.query_params.get("q", "").strip()
|
||||||
@ -408,11 +414,88 @@ async def audit_full_patching(request: Request, db=Depends(get_db)):
|
|||||||
return templates.TemplateResponse("audit_full_patching.html", ctx)
|
return templates.TemplateResponse("audit_full_patching.html", ctx)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/audit-full/patching/export-csv")
|
||||||
|
async def patching_export_csv(request: Request, db=Depends(get_db)):
|
||||||
|
user = get_current_user(request)
|
||||||
|
if not user:
|
||||||
|
return RedirectResponse(url="/login")
|
||||||
|
perms = get_user_perms(db, user)
|
||||||
|
if not can_view(perms, "audit"):
|
||||||
|
return RedirectResponse(url="/audit-full")
|
||||||
|
|
||||||
|
import io, csv
|
||||||
|
year = int(request.query_params.get("year", "2026"))
|
||||||
|
search = request.query_params.get("q", "").strip()
|
||||||
|
domain = request.query_params.get("domain", "")
|
||||||
|
scope = request.query_params.get("scope", "")
|
||||||
|
|
||||||
|
yr_count = "patch_count_2026" if year == 2026 else "patch_count_2025"
|
||||||
|
yr_weeks = "patch_weeks_2026" if year == 2026 else "patch_weeks_2025"
|
||||||
|
|
||||||
|
servers = db.execute(text(
|
||||||
|
f"SELECT DISTINCT ON (saf.hostname) saf.hostname,"
|
||||||
|
f" saf.{yr_count} as patch_count, saf.{yr_weeks} as patch_weeks,"
|
||||||
|
f" saf.last_patch_date, saf.last_patch_week, saf.last_patch_year,"
|
||||||
|
f" saf.patch_status_2026,"
|
||||||
|
f" d.name as domain, e.name as env, z.name as zone, s.os_family, s.etat"
|
||||||
|
f" FROM server_audit_full saf"
|
||||||
|
f" LEFT JOIN servers s ON saf.server_id = s.id"
|
||||||
|
f" LEFT JOIN domain_environments de ON s.domain_env_id = de.id"
|
||||||
|
f" LEFT JOIN domains d ON de.domain_id = d.id"
|
||||||
|
f" LEFT JOIN environments e ON de.environment_id = e.id"
|
||||||
|
f" LEFT JOIN zones z ON s.zone_id = z.id"
|
||||||
|
f" WHERE saf.status IN ('ok','partial')"
|
||||||
|
f" ORDER BY saf.hostname, saf.audit_date DESC"
|
||||||
|
)).fetchall()
|
||||||
|
|
||||||
|
if scope == "secops":
|
||||||
|
secops = {r.hostname for r in db.execute(text("SELECT hostname FROM servers WHERE patch_os_owner = 'secops'")).fetchall()}
|
||||||
|
servers = [s for s in servers if s.hostname in secops]
|
||||||
|
elif scope == "other":
|
||||||
|
secops = {r.hostname for r in db.execute(text("SELECT hostname FROM servers WHERE patch_os_owner = 'secops'")).fetchall()}
|
||||||
|
servers = [s for s in servers if s.hostname not in secops]
|
||||||
|
if domain:
|
||||||
|
zone_hosts = {r.hostname for r in db.execute(text(
|
||||||
|
"SELECT s.hostname FROM servers s JOIN zones z ON s.zone_id = z.id WHERE z.name = :n"
|
||||||
|
), {"n": domain}).fetchall()}
|
||||||
|
if zone_hosts:
|
||||||
|
servers = [s for s in servers if s.hostname in zone_hosts]
|
||||||
|
else:
|
||||||
|
dom_hosts = {r.hostname for r in db.execute(text(
|
||||||
|
"SELECT s.hostname FROM servers s JOIN domain_environments de ON s.domain_env_id = de.id"
|
||||||
|
" JOIN domains d ON de.domain_id = d.id WHERE d.code = :dc"
|
||||||
|
), {"dc": domain}).fetchall()}
|
||||||
|
servers = [s for s in servers if s.hostname in dom_hosts]
|
||||||
|
if search:
|
||||||
|
servers = [s for s in servers if search.lower() in s.hostname.lower()]
|
||||||
|
|
||||||
|
output = io.StringIO()
|
||||||
|
w = csv.writer(output, delimiter=";")
|
||||||
|
w.writerow(["Hostname", "OS", "Domaine", "Environnement", "Zone", "Etat",
|
||||||
|
"Nb patches", "Semaines", "Dernier patch", "Statut"])
|
||||||
|
for s in servers:
|
||||||
|
w.writerow([
|
||||||
|
s.hostname, s.os_family or "", s.domain or "", s.env or "",
|
||||||
|
s.zone or "", s.etat or "", s.patch_count or 0,
|
||||||
|
s.patch_weeks or "", s.last_patch_date or s.last_patch_week or "",
|
||||||
|
s.patch_status_2026 or "",
|
||||||
|
])
|
||||||
|
|
||||||
|
output.seek(0)
|
||||||
|
return StreamingResponse(
|
||||||
|
iter(["\ufeff" + output.getvalue()]),
|
||||||
|
media_type="text/csv",
|
||||||
|
headers={"Content-Disposition": f"attachment; filename=patching_{year}.csv"})
|
||||||
|
|
||||||
|
|
||||||
@router.get("/audit-full/export-csv")
|
@router.get("/audit-full/export-csv")
|
||||||
async def audit_full_export_csv(request: Request, db=Depends(get_db)):
|
async def audit_full_export_csv(request: Request, db=Depends(get_db)):
|
||||||
user = get_current_user(request)
|
user = get_current_user(request)
|
||||||
if not user:
|
if not user:
|
||||||
return RedirectResponse(url="/login")
|
return RedirectResponse(url="/login")
|
||||||
|
perms = get_user_perms(db, user)
|
||||||
|
if not can_view(perms, "audit"):
|
||||||
|
return RedirectResponse(url="/audit-full")
|
||||||
|
|
||||||
import io, csv
|
import io, csv
|
||||||
filtre = request.query_params.get("filter", "")
|
filtre = request.query_params.get("filter", "")
|
||||||
@ -487,6 +570,9 @@ async def audit_full_flow_map(request: Request, db=Depends(get_db)):
|
|||||||
user = get_current_user(request)
|
user = get_current_user(request)
|
||||||
if not user:
|
if not user:
|
||||||
return RedirectResponse(url="/login")
|
return RedirectResponse(url="/login")
|
||||||
|
perms = get_user_perms(db, user)
|
||||||
|
if not can_view(perms, "audit"):
|
||||||
|
return RedirectResponse(url="/audit-full")
|
||||||
|
|
||||||
domain_filter = request.query_params.get("domain", "")
|
domain_filter = request.query_params.get("domain", "")
|
||||||
server_filter = request.query_params.get("server", "").strip()
|
server_filter = request.query_params.get("server", "").strip()
|
||||||
@ -577,6 +663,9 @@ async def audit_full_detail(request: Request, audit_id: int, db=Depends(get_db))
|
|||||||
user = get_current_user(request)
|
user = get_current_user(request)
|
||||||
if not user:
|
if not user:
|
||||||
return RedirectResponse(url="/login")
|
return RedirectResponse(url="/login")
|
||||||
|
perms = get_user_perms(db, user)
|
||||||
|
if not can_view(perms, "audit"):
|
||||||
|
return RedirectResponse(url="/audit-full")
|
||||||
|
|
||||||
audit = get_audit_detail(db, audit_id)
|
audit = get_audit_detail(db, audit_id)
|
||||||
if not audit:
|
if not audit:
|
||||||
|
|||||||
@ -18,7 +18,7 @@ async def login_page(request: Request):
|
|||||||
|
|
||||||
@router.post("/login")
|
@router.post("/login")
|
||||||
async def login(request: Request, username: str = Form(...), password: str = Form(...), db=Depends(get_db)):
|
async def login(request: Request, username: str = Form(...), password: str = Form(...), db=Depends(get_db)):
|
||||||
row = db.execute(text("SELECT id, username, password_hash, role FROM users WHERE LOWER(username) = LOWER(:u)"),
|
row = db.execute(text("SELECT id, username, password_hash, role, is_active FROM users WHERE LOWER(username) = LOWER(:u)"),
|
||||||
{"u": username}).fetchone()
|
{"u": username}).fetchone()
|
||||||
if not row:
|
if not row:
|
||||||
log_login_failed(db, request, username)
|
log_login_failed(db, request, username)
|
||||||
@ -26,6 +26,12 @@ async def login(request: Request, username: str = Form(...), password: str = For
|
|||||||
return templates.TemplateResponse("login.html", {
|
return templates.TemplateResponse("login.html", {
|
||||||
"request": request, "app_name": APP_NAME, "version": APP_VERSION, "error": "Utilisateur inconnu"
|
"request": request, "app_name": APP_NAME, "version": APP_VERSION, "error": "Utilisateur inconnu"
|
||||||
})
|
})
|
||||||
|
if not row.is_active:
|
||||||
|
log_login_failed(db, request, username)
|
||||||
|
db.commit()
|
||||||
|
return templates.TemplateResponse("login.html", {
|
||||||
|
"request": request, "app_name": APP_NAME, "version": APP_VERSION, "error": "Compte desactive"
|
||||||
|
})
|
||||||
try:
|
try:
|
||||||
ok = verify_password(password, row.password_hash)
|
ok = verify_password(password, row.password_hash)
|
||||||
except Exception:
|
except Exception:
|
||||||
@ -40,7 +46,14 @@ async def login(request: Request, username: str = Form(...), password: str = For
|
|||||||
user = {"sub": row.username, "role": row.role, "uid": row.id}
|
user = {"sub": row.username, "role": row.role, "uid": row.id}
|
||||||
log_login(db, request, user)
|
log_login(db, request, user)
|
||||||
db.commit()
|
db.commit()
|
||||||
response = RedirectResponse(url="/dashboard", status_code=303)
|
# Redirect qw_only users to quickwin
|
||||||
|
perms = db.execute(text("SELECT module FROM user_permissions WHERE user_id = :uid"), {"uid": row.id}).fetchall()
|
||||||
|
modules = {r.module for r in perms}
|
||||||
|
if modules == {"quickwin"}:
|
||||||
|
redirect_url = "/quickwin"
|
||||||
|
else:
|
||||||
|
redirect_url = "/dashboard"
|
||||||
|
response = RedirectResponse(url=redirect_url, status_code=303)
|
||||||
response.set_cookie(key="access_token", value=token, httponly=True, samesite="lax", max_age=3600)
|
response.set_cookie(key="access_token", value=token, httponly=True, samesite="lax", max_age=3600)
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|||||||
@ -85,7 +85,10 @@ async def campaign_preview(request: Request, db=Depends(get_db),
|
|||||||
year: int = Query(...), week: int = Query(...)):
|
year: int = Query(...), week: int = Query(...)):
|
||||||
user = get_current_user(request)
|
user = get_current_user(request)
|
||||||
if not user:
|
if not user:
|
||||||
return HTMLResponse("<p>Non autorise</p>")
|
return HTMLResponse("<p>Non autorise</p>", status_code=401)
|
||||||
|
perms = get_user_perms(db, user)
|
||||||
|
if not can_view(perms, "campaigns"):
|
||||||
|
return HTMLResponse("<p>Acces interdit</p>", status_code=403)
|
||||||
servers, planning = get_servers_for_planning(db, year, week)
|
servers, planning = get_servers_for_planning(db, year, week)
|
||||||
scope = ", ".join(set(f"{p.domain_name} ({p.env_scope})" for p in planning if p.domain_code))
|
scope = ", ".join(set(f"{p.domain_name} ({p.env_scope})" for p in planning if p.domain_code))
|
||||||
return templates.TemplateResponse("partials/campaign_preview.html", {
|
return templates.TemplateResponse("partials/campaign_preview.html", {
|
||||||
@ -99,6 +102,9 @@ async def campaign_create(request: Request, db=Depends(get_db)):
|
|||||||
user = get_current_user(request)
|
user = get_current_user(request)
|
||||||
if not user:
|
if not user:
|
||||||
return RedirectResponse(url="/login")
|
return RedirectResponse(url="/login")
|
||||||
|
perms = get_user_perms(db, user)
|
||||||
|
if not can_edit(perms, "campaigns"):
|
||||||
|
return RedirectResponse(url="/campaigns")
|
||||||
form = await request.form()
|
form = await request.form()
|
||||||
year = int(form.get("year", datetime.now().year))
|
year = int(form.get("year", datetime.now().year))
|
||||||
week = int(form.get("week_number", 0))
|
week = int(form.get("week_number", 0))
|
||||||
@ -128,6 +134,9 @@ async def campaign_detail(request: Request, campaign_id: int, db=Depends(get_db)
|
|||||||
user = get_current_user(request)
|
user = get_current_user(request)
|
||||||
if not user:
|
if not user:
|
||||||
return RedirectResponse(url="/login")
|
return RedirectResponse(url="/login")
|
||||||
|
perms = get_user_perms(db, user)
|
||||||
|
if not can_view(perms, "campaigns"):
|
||||||
|
return RedirectResponse(url="/dashboard")
|
||||||
campaign = get_campaign(db, campaign_id)
|
campaign = get_campaign(db, campaign_id)
|
||||||
if not campaign:
|
if not campaign:
|
||||||
return RedirectResponse(url="/campaigns")
|
return RedirectResponse(url="/campaigns")
|
||||||
@ -212,6 +221,9 @@ async def session_prereq(request: Request, session_id: int, db=Depends(get_db),
|
|||||||
user = get_current_user(request)
|
user = get_current_user(request)
|
||||||
if not user:
|
if not user:
|
||||||
return RedirectResponse(url="/login")
|
return RedirectResponse(url="/login")
|
||||||
|
perms = get_user_perms(db, user)
|
||||||
|
if not can_view(perms, "campaigns"):
|
||||||
|
return RedirectResponse(url="/campaigns")
|
||||||
validate_prereq(db, session_id, prereq_ssh, prereq_satellite,
|
validate_prereq(db, session_id, prereq_ssh, prereq_satellite,
|
||||||
rollback_method or None, rollback_justif, user.get("sub"))
|
rollback_method or None, rollback_justif, user.get("sub"))
|
||||||
row = db.execute(text("SELECT campaign_id FROM patch_sessions WHERE id = :id"),
|
row = db.execute(text("SELECT campaign_id FROM patch_sessions WHERE id = :id"),
|
||||||
@ -224,6 +236,9 @@ async def campaign_check_prereqs(request: Request, campaign_id: int, db=Depends(
|
|||||||
user = get_current_user(request)
|
user = get_current_user(request)
|
||||||
if not user:
|
if not user:
|
||||||
return RedirectResponse(url="/login")
|
return RedirectResponse(url="/login")
|
||||||
|
perms = get_user_perms(db, user)
|
||||||
|
if not can_edit(perms, "campaigns"):
|
||||||
|
return RedirectResponse(url=f"/campaigns/{campaign_id}")
|
||||||
checked, auto_excluded = check_prereqs_campaign(db, campaign_id)
|
checked, auto_excluded = check_prereqs_campaign(db, campaign_id)
|
||||||
log_prereq_check(db, request, user, campaign_id, checked, auto_excluded)
|
log_prereq_check(db, request, user, campaign_id, checked, auto_excluded)
|
||||||
db.commit()
|
db.commit()
|
||||||
@ -235,6 +250,9 @@ async def session_check_prereq(request: Request, session_id: int, db=Depends(get
|
|||||||
user = get_current_user(request)
|
user = get_current_user(request)
|
||||||
if not user:
|
if not user:
|
||||||
return RedirectResponse(url="/login")
|
return RedirectResponse(url="/login")
|
||||||
|
perms = get_user_perms(db, user)
|
||||||
|
if not can_view(perms, "campaigns"):
|
||||||
|
return RedirectResponse(url="/campaigns")
|
||||||
check_single_prereq(db, session_id)
|
check_single_prereq(db, session_id)
|
||||||
row = db.execute(text("SELECT campaign_id FROM patch_sessions WHERE id = :id"),
|
row = db.execute(text("SELECT campaign_id FROM patch_sessions WHERE id = :id"),
|
||||||
{"id": session_id}).fetchone()
|
{"id": session_id}).fetchone()
|
||||||
@ -247,6 +265,9 @@ async def session_exclude(request: Request, session_id: int, db=Depends(get_db),
|
|||||||
user = get_current_user(request)
|
user = get_current_user(request)
|
||||||
if not user:
|
if not user:
|
||||||
return RedirectResponse(url="/login")
|
return RedirectResponse(url="/login")
|
||||||
|
perms = get_user_perms(db, user)
|
||||||
|
if not can_edit(perms, "campaigns"):
|
||||||
|
return RedirectResponse(url="/campaigns")
|
||||||
exclude_session(db, session_id, reason, detail, user.get("sub"))
|
exclude_session(db, session_id, reason, detail, user.get("sub"))
|
||||||
row = db.execute(text("SELECT campaign_id FROM patch_sessions WHERE id = :id"),
|
row = db.execute(text("SELECT campaign_id FROM patch_sessions WHERE id = :id"),
|
||||||
{"id": session_id}).fetchone()
|
{"id": session_id}).fetchone()
|
||||||
@ -258,6 +279,9 @@ async def session_restore(request: Request, session_id: int, db=Depends(get_db))
|
|||||||
user = get_current_user(request)
|
user = get_current_user(request)
|
||||||
if not user:
|
if not user:
|
||||||
return RedirectResponse(url="/login")
|
return RedirectResponse(url="/login")
|
||||||
|
perms = get_user_perms(db, user)
|
||||||
|
if not can_edit(perms, "campaigns"):
|
||||||
|
return RedirectResponse(url="/campaigns")
|
||||||
restore_session(db, session_id)
|
restore_session(db, session_id)
|
||||||
row = db.execute(text("SELECT campaign_id FROM patch_sessions WHERE id = :id"),
|
row = db.execute(text("SELECT campaign_id FROM patch_sessions WHERE id = :id"),
|
||||||
{"id": session_id}).fetchone()
|
{"id": session_id}).fetchone()
|
||||||
@ -272,6 +296,9 @@ async def session_take(request: Request, session_id: int, db=Depends(get_db)):
|
|||||||
user = get_current_user(request)
|
user = get_current_user(request)
|
||||||
if not user:
|
if not user:
|
||||||
return RedirectResponse(url="/login")
|
return RedirectResponse(url="/login")
|
||||||
|
perms = get_user_perms(db, user)
|
||||||
|
if not can_view(perms, "campaigns"):
|
||||||
|
return RedirectResponse(url="/campaigns")
|
||||||
row = db.execute(text("SELECT campaign_id, intervenant_id, forced_assignment FROM patch_sessions WHERE id = :id"),
|
row = db.execute(text("SELECT campaign_id, intervenant_id, forced_assignment FROM patch_sessions WHERE id = :id"),
|
||||||
{"id": session_id}).fetchone()
|
{"id": session_id}).fetchone()
|
||||||
if row.intervenant_id:
|
if row.intervenant_id:
|
||||||
@ -292,6 +319,9 @@ async def session_release(request: Request, session_id: int, db=Depends(get_db))
|
|||||||
user = get_current_user(request)
|
user = get_current_user(request)
|
||||||
if not user:
|
if not user:
|
||||||
return RedirectResponse(url="/login")
|
return RedirectResponse(url="/login")
|
||||||
|
perms = get_user_perms(db, user)
|
||||||
|
if not can_view(perms, "campaigns"):
|
||||||
|
return RedirectResponse(url="/campaigns")
|
||||||
if is_forced(db, session_id):
|
if is_forced(db, session_id):
|
||||||
row = db.execute(text("SELECT campaign_id FROM patch_sessions WHERE id = :id"),
|
row = db.execute(text("SELECT campaign_id FROM patch_sessions WHERE id = :id"),
|
||||||
{"id": session_id}).fetchone()
|
{"id": session_id}).fetchone()
|
||||||
@ -309,6 +339,9 @@ async def session_assign(request: Request, session_id: int, db=Depends(get_db),
|
|||||||
user = get_current_user(request)
|
user = get_current_user(request)
|
||||||
if not user:
|
if not user:
|
||||||
return RedirectResponse(url="/login")
|
return RedirectResponse(url="/login")
|
||||||
|
perms = get_user_perms(db, user)
|
||||||
|
if not can_edit(perms, "campaigns"):
|
||||||
|
return RedirectResponse(url="/campaigns")
|
||||||
oid = int(operator_id) if operator_id else None
|
oid = int(operator_id) if operator_id else None
|
||||||
is_forced_flag = forced == "on"
|
is_forced_flag = forced == "on"
|
||||||
if oid:
|
if oid:
|
||||||
@ -329,6 +362,9 @@ async def set_op_limit(request: Request, campaign_id: int, db=Depends(get_db),
|
|||||||
user = get_current_user(request)
|
user = get_current_user(request)
|
||||||
if not user:
|
if not user:
|
||||||
return RedirectResponse(url="/login")
|
return RedirectResponse(url="/login")
|
||||||
|
perms = get_user_perms(db, user)
|
||||||
|
if not can_edit(perms, "campaigns"):
|
||||||
|
return RedirectResponse(url=f"/campaigns/{campaign_id}")
|
||||||
set_operator_limit(db, campaign_id, operator_id, max_servers, note or None)
|
set_operator_limit(db, campaign_id, operator_id, max_servers, note or None)
|
||||||
return RedirectResponse(url=f"/campaigns/{campaign_id}?msg=limit_set", status_code=303)
|
return RedirectResponse(url=f"/campaigns/{campaign_id}?msg=limit_set", status_code=303)
|
||||||
|
|
||||||
@ -375,6 +411,9 @@ async def assignment_add(request: Request, db=Depends(get_db),
|
|||||||
user = get_current_user(request)
|
user = get_current_user(request)
|
||||||
if not user:
|
if not user:
|
||||||
return RedirectResponse(url="/login")
|
return RedirectResponse(url="/login")
|
||||||
|
perms = get_user_perms(db, user)
|
||||||
|
if not can_edit(perms, "campaigns"):
|
||||||
|
return RedirectResponse(url="/assignments")
|
||||||
try:
|
try:
|
||||||
db.execute(text("""
|
db.execute(text("""
|
||||||
INSERT INTO default_assignments (rule_type, rule_value, user_id, priority, note)
|
INSERT INTO default_assignments (rule_type, rule_value, user_id, priority, note)
|
||||||
@ -393,6 +432,9 @@ async def assignment_delete(request: Request, rule_id: int, db=Depends(get_db)):
|
|||||||
user = get_current_user(request)
|
user = get_current_user(request)
|
||||||
if not user:
|
if not user:
|
||||||
return RedirectResponse(url="/login")
|
return RedirectResponse(url="/login")
|
||||||
|
perms = get_user_perms(db, user)
|
||||||
|
if not can_edit(perms, "campaigns"):
|
||||||
|
return RedirectResponse(url="/assignments")
|
||||||
db.execute(text("DELETE FROM default_assignments WHERE id = :id"), {"id": rule_id})
|
db.execute(text("DELETE FROM default_assignments WHERE id = :id"), {"id": rule_id})
|
||||||
db.commit()
|
db.commit()
|
||||||
return RedirectResponse(url="/assignments?msg=deleted", status_code=303)
|
return RedirectResponse(url="/assignments?msg=deleted", status_code=303)
|
||||||
@ -406,6 +448,9 @@ async def bulk_take(request: Request, campaign_id: int, db=Depends(get_db),
|
|||||||
user = get_current_user(request)
|
user = get_current_user(request)
|
||||||
if not user:
|
if not user:
|
||||||
return RedirectResponse(url="/login")
|
return RedirectResponse(url="/login")
|
||||||
|
perms = get_user_perms(db, user)
|
||||||
|
if not can_view(perms, "campaigns"):
|
||||||
|
return RedirectResponse(url="/campaigns")
|
||||||
ids = [int(x) for x in session_ids.split(",") if x.strip().isdigit()]
|
ids = [int(x) for x in session_ids.split(",") if x.strip().isdigit()]
|
||||||
limit = get_operator_limit(db, campaign_id, user.get("uid"))
|
limit = get_operator_limit(db, campaign_id, user.get("uid"))
|
||||||
current = get_operator_count(db, campaign_id, user.get("uid"))
|
current = get_operator_count(db, campaign_id, user.get("uid"))
|
||||||
@ -461,6 +506,9 @@ async def session_schedule(request: Request, session_id: int, db=Depends(get_db)
|
|||||||
user = get_current_user(request)
|
user = get_current_user(request)
|
||||||
if not user:
|
if not user:
|
||||||
return RedirectResponse(url="/login")
|
return RedirectResponse(url="/login")
|
||||||
|
perms = get_user_perms(db, user)
|
||||||
|
if not can_edit(perms, "campaigns"):
|
||||||
|
return RedirectResponse(url="/campaigns")
|
||||||
update_session_schedule(db, session_id, date_prevue or None, heure_prevue or None)
|
update_session_schedule(db, session_id, date_prevue or None, heure_prevue or None)
|
||||||
row = db.execute(text("SELECT campaign_id FROM patch_sessions WHERE id = :id"),
|
row = db.execute(text("SELECT campaign_id FROM patch_sessions WHERE id = :id"),
|
||||||
{"id": session_id}).fetchone()
|
{"id": session_id}).fetchone()
|
||||||
|
|||||||
@ -43,6 +43,9 @@ async def contacts_page(request: Request, db=Depends(get_db),
|
|||||||
user = get_current_user(request)
|
user = get_current_user(request)
|
||||||
if not user:
|
if not user:
|
||||||
return RedirectResponse(url="/login")
|
return RedirectResponse(url="/login")
|
||||||
|
perms = get_user_perms(db, user)
|
||||||
|
if not can_view(perms, "servers"):
|
||||||
|
return RedirectResponse(url="/dashboard")
|
||||||
|
|
||||||
where = ["1=1"]
|
where = ["1=1"]
|
||||||
params = {}
|
params = {}
|
||||||
@ -170,6 +173,9 @@ async def contact_add(request: Request, db=Depends(get_db),
|
|||||||
user = get_current_user(request)
|
user = get_current_user(request)
|
||||||
if not user:
|
if not user:
|
||||||
return RedirectResponse(url="/login")
|
return RedirectResponse(url="/login")
|
||||||
|
perms = get_user_perms(db, user)
|
||||||
|
if not can_edit(perms, "servers"):
|
||||||
|
return RedirectResponse(url="/contacts")
|
||||||
try:
|
try:
|
||||||
db.execute(text("""
|
db.execute(text("""
|
||||||
INSERT INTO contacts (name, email, role, is_active)
|
INSERT INTO contacts (name, email, role, is_active)
|
||||||
@ -188,6 +194,9 @@ async def contact_edit(request: Request, contact_id: int, db=Depends(get_db),
|
|||||||
user = get_current_user(request)
|
user = get_current_user(request)
|
||||||
if not user:
|
if not user:
|
||||||
return RedirectResponse(url="/login")
|
return RedirectResponse(url="/login")
|
||||||
|
perms = get_user_perms(db, user)
|
||||||
|
if not can_edit(perms, "servers"):
|
||||||
|
return RedirectResponse(url="/contacts")
|
||||||
updates = []; params = {"id": contact_id}
|
updates = []; params = {"id": contact_id}
|
||||||
if name: updates.append("name = :n"); params["n"] = name
|
if name: updates.append("name = :n"); params["n"] = name
|
||||||
if email: updates.append("email = :e"); params["e"] = email.lower()
|
if email: updates.append("email = :e"); params["e"] = email.lower()
|
||||||
@ -203,6 +212,9 @@ async def contact_toggle(request: Request, contact_id: int, db=Depends(get_db)):
|
|||||||
user = get_current_user(request)
|
user = get_current_user(request)
|
||||||
if not user:
|
if not user:
|
||||||
return RedirectResponse(url="/login")
|
return RedirectResponse(url="/login")
|
||||||
|
perms = get_user_perms(db, user)
|
||||||
|
if not can_edit(perms, "servers"):
|
||||||
|
return RedirectResponse(url="/contacts")
|
||||||
db.execute(text("UPDATE contacts SET is_active = NOT is_active WHERE id = :id"), {"id": contact_id})
|
db.execute(text("UPDATE contacts SET is_active = NOT is_active WHERE id = :id"), {"id": contact_id})
|
||||||
db.commit()
|
db.commit()
|
||||||
return RedirectResponse(url="/contacts?msg=toggled", status_code=303)
|
return RedirectResponse(url="/contacts?msg=toggled", status_code=303)
|
||||||
@ -215,6 +227,9 @@ async def scope_add(request: Request, contact_id: int, db=Depends(get_db),
|
|||||||
user = get_current_user(request)
|
user = get_current_user(request)
|
||||||
if not user:
|
if not user:
|
||||||
return RedirectResponse(url="/login")
|
return RedirectResponse(url="/login")
|
||||||
|
perms = get_user_perms(db, user)
|
||||||
|
if not can_edit(perms, "servers"):
|
||||||
|
return RedirectResponse(url="/contacts")
|
||||||
try:
|
try:
|
||||||
db.execute(text("""
|
db.execute(text("""
|
||||||
INSERT INTO contact_scopes (contact_id, scope_type, scope_value, env_scope)
|
INSERT INTO contact_scopes (contact_id, scope_type, scope_value, env_scope)
|
||||||
@ -231,6 +246,9 @@ async def scope_delete(request: Request, scope_id: int, db=Depends(get_db)):
|
|||||||
user = get_current_user(request)
|
user = get_current_user(request)
|
||||||
if not user:
|
if not user:
|
||||||
return RedirectResponse(url="/login")
|
return RedirectResponse(url="/login")
|
||||||
|
perms = get_user_perms(db, user)
|
||||||
|
if not can_edit(perms, "servers"):
|
||||||
|
return RedirectResponse(url="/contacts")
|
||||||
db.execute(text("DELETE FROM contact_scopes WHERE id = :id"), {"id": scope_id})
|
db.execute(text("DELETE FROM contact_scopes WHERE id = :id"), {"id": scope_id})
|
||||||
db.commit()
|
db.commit()
|
||||||
return RedirectResponse(url="/contacts?msg=scope_deleted", status_code=303)
|
return RedirectResponse(url="/contacts?msg=scope_deleted", status_code=303)
|
||||||
@ -241,6 +259,9 @@ async def contact_delete(request: Request, contact_id: int, db=Depends(get_db)):
|
|||||||
user = get_current_user(request)
|
user = get_current_user(request)
|
||||||
if not user:
|
if not user:
|
||||||
return RedirectResponse(url="/login")
|
return RedirectResponse(url="/login")
|
||||||
|
perms = get_user_perms(db, user)
|
||||||
|
if not can_edit(perms, "servers"):
|
||||||
|
return RedirectResponse(url="/contacts")
|
||||||
db.execute(text("DELETE FROM contact_scopes WHERE contact_id = :cid"), {"cid": contact_id})
|
db.execute(text("DELETE FROM contact_scopes WHERE contact_id = :cid"), {"cid": contact_id})
|
||||||
db.execute(text("DELETE FROM contacts WHERE id = :cid"), {"cid": contact_id})
|
db.execute(text("DELETE FROM contacts WHERE id = :cid"), {"cid": contact_id})
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|||||||
@ -84,6 +84,8 @@ async def planning_page(request: Request, db=Depends(get_db),
|
|||||||
next_week = 1
|
next_week = 1
|
||||||
|
|
||||||
perms = get_user_perms(db, user)
|
perms = get_user_perms(db, user)
|
||||||
|
if not can_view(perms, "planning"):
|
||||||
|
return RedirectResponse(url="/dashboard")
|
||||||
return templates.TemplateResponse("planning.html", {
|
return templates.TemplateResponse("planning.html", {
|
||||||
"request": request, "user": user, "perms": perms, "app_name": APP_NAME,
|
"request": request, "user": user, "perms": perms, "app_name": APP_NAME,
|
||||||
"year": year, "domains": domains, "grid": grid,
|
"year": year, "domains": domains, "grid": grid,
|
||||||
@ -104,6 +106,9 @@ async def planning_add(request: Request, db=Depends(get_db),
|
|||||||
user = get_current_user(request)
|
user = get_current_user(request)
|
||||||
if not user:
|
if not user:
|
||||||
return RedirectResponse(url="/login")
|
return RedirectResponse(url="/login")
|
||||||
|
perms = get_user_perms(db, user)
|
||||||
|
if not can_edit(perms, "planning"):
|
||||||
|
return RedirectResponse(url="/planning")
|
||||||
|
|
||||||
y = int(year)
|
y = int(year)
|
||||||
wn = int(week_number) if week_number else 0
|
wn = int(week_number) if week_number else 0
|
||||||
@ -146,6 +151,9 @@ async def planning_edit(request: Request, entry_id: int, db=Depends(get_db),
|
|||||||
user = get_current_user(request)
|
user = get_current_user(request)
|
||||||
if not user:
|
if not user:
|
||||||
return RedirectResponse(url="/login")
|
return RedirectResponse(url="/login")
|
||||||
|
perms = get_user_perms(db, user)
|
||||||
|
if not can_edit(perms, "planning"):
|
||||||
|
return RedirectResponse(url="/planning")
|
||||||
row = db.execute(text("SELECT year FROM patch_planning WHERE id = :id"), {"id": entry_id}).fetchone()
|
row = db.execute(text("SELECT year FROM patch_planning WHERE id = :id"), {"id": entry_id}).fetchone()
|
||||||
cyc = int(cycle) if cycle.strip() else None
|
cyc = int(cycle) if cycle.strip() else None
|
||||||
db.execute(text("""
|
db.execute(text("""
|
||||||
@ -163,6 +171,9 @@ async def planning_delete(request: Request, entry_id: int, db=Depends(get_db)):
|
|||||||
user = get_current_user(request)
|
user = get_current_user(request)
|
||||||
if not user:
|
if not user:
|
||||||
return RedirectResponse(url="/login")
|
return RedirectResponse(url="/login")
|
||||||
|
perms = get_user_perms(db, user)
|
||||||
|
if not can_edit(perms, "planning"):
|
||||||
|
return RedirectResponse(url="/planning")
|
||||||
row = db.execute(text("SELECT year FROM patch_planning WHERE id = :id"), {"id": entry_id}).fetchone()
|
row = db.execute(text("SELECT year FROM patch_planning WHERE id = :id"), {"id": entry_id}).fetchone()
|
||||||
db.execute(text("DELETE FROM patch_planning WHERE id = :id"), {"id": entry_id})
|
db.execute(text("DELETE FROM patch_planning WHERE id = :id"), {"id": entry_id})
|
||||||
db.commit()
|
db.commit()
|
||||||
@ -177,6 +188,9 @@ async def planning_duplicate(request: Request, db=Depends(get_db),
|
|||||||
user = get_current_user(request)
|
user = get_current_user(request)
|
||||||
if not user:
|
if not user:
|
||||||
return RedirectResponse(url="/login")
|
return RedirectResponse(url="/login")
|
||||||
|
perms = get_user_perms(db, user)
|
||||||
|
if not can_edit(perms, "planning"):
|
||||||
|
return RedirectResponse(url="/planning")
|
||||||
|
|
||||||
# Verifier que l'annee cible est vide
|
# Verifier que l'annee cible est vide
|
||||||
existing = db.execute(text("SELECT COUNT(*) FROM patch_planning WHERE year = :y"),
|
existing = db.execute(text("SELECT COUNT(*) FROM patch_planning WHERE year = :y"),
|
||||||
|
|||||||
@ -168,6 +168,9 @@ async def qualys_tags_resync(request: Request, db=Depends(get_db)):
|
|||||||
user = get_current_user(request)
|
user = get_current_user(request)
|
||||||
if not user:
|
if not user:
|
||||||
return RedirectResponse(url="/login")
|
return RedirectResponse(url="/login")
|
||||||
|
perms = get_user_perms(db, user)
|
||||||
|
if not can_edit(perms, "qualys"):
|
||||||
|
return RedirectResponse(url="/qualys/tags")
|
||||||
result = resync_all_tags(db)
|
result = resync_all_tags(db)
|
||||||
msg = "resync_ok" if result["ok"] else "resync_ko"
|
msg = "resync_ok" if result["ok"] else "resync_ko"
|
||||||
return RedirectResponse(url=f"/qualys/tags?msg={msg}", status_code=303)
|
return RedirectResponse(url=f"/qualys/tags?msg={msg}", status_code=303)
|
||||||
@ -179,6 +182,9 @@ async def qualys_tag_create(request: Request, db=Depends(get_db),
|
|||||||
user = get_current_user(request)
|
user = get_current_user(request)
|
||||||
if not user:
|
if not user:
|
||||||
return RedirectResponse(url="/login")
|
return RedirectResponse(url="/login")
|
||||||
|
perms = get_user_perms(db, user)
|
||||||
|
if not can_edit(perms, "qualys"):
|
||||||
|
return RedirectResponse(url="/qualys/tags")
|
||||||
result = create_tag_api(db, tag_name.strip())
|
result = create_tag_api(db, tag_name.strip())
|
||||||
msg = "created" if result["ok"] else "create_error"
|
msg = "created" if result["ok"] else "create_error"
|
||||||
return RedirectResponse(url=f"/qualys/tags?msg={msg}", status_code=303)
|
return RedirectResponse(url=f"/qualys/tags?msg={msg}", status_code=303)
|
||||||
@ -189,6 +195,9 @@ async def qualys_tag_delete(request: Request, tag_id: int, db=Depends(get_db)):
|
|||||||
user = get_current_user(request)
|
user = get_current_user(request)
|
||||||
if not user:
|
if not user:
|
||||||
return RedirectResponse(url="/login")
|
return RedirectResponse(url="/login")
|
||||||
|
perms = get_user_perms(db, user)
|
||||||
|
if not can_edit(perms, "qualys"):
|
||||||
|
return RedirectResponse(url="/qualys/tags")
|
||||||
result = delete_tag_api(db, tag_id)
|
result = delete_tag_api(db, tag_id)
|
||||||
msg = "deleted" if result["ok"] else "delete_error"
|
msg = "deleted" if result["ok"] else "delete_error"
|
||||||
return RedirectResponse(url=f"/qualys/tags?msg={msg}", status_code=303)
|
return RedirectResponse(url=f"/qualys/tags?msg={msg}", status_code=303)
|
||||||
@ -200,6 +209,9 @@ async def qualys_asset_tag_add(request: Request, asset_id: int, db=Depends(get_d
|
|||||||
user = get_current_user(request)
|
user = get_current_user(request)
|
||||||
if not user:
|
if not user:
|
||||||
return RedirectResponse(url="/login")
|
return RedirectResponse(url="/login")
|
||||||
|
perms = get_user_perms(db, user)
|
||||||
|
if not can_edit(perms, "qualys"):
|
||||||
|
return RedirectResponse(url="/qualys/tags")
|
||||||
result = add_tag_to_asset_api(db, asset_id, int(tag_id))
|
result = add_tag_to_asset_api(db, asset_id, int(tag_id))
|
||||||
color = "text-cyber-green" if result["ok"] else "text-cyber-red"
|
color = "text-cyber-green" if result["ok"] else "text-cyber-red"
|
||||||
return HTMLResponse(f'<span class="text-xs {color}">{result["msg"]}</span>')
|
return HTMLResponse(f'<span class="text-xs {color}">{result["msg"]}</span>')
|
||||||
@ -211,6 +223,9 @@ async def qualys_asset_tag_remove(request: Request, asset_id: int, db=Depends(ge
|
|||||||
user = get_current_user(request)
|
user = get_current_user(request)
|
||||||
if not user:
|
if not user:
|
||||||
return RedirectResponse(url="/login")
|
return RedirectResponse(url="/login")
|
||||||
|
perms = get_user_perms(db, user)
|
||||||
|
if not can_edit(perms, "qualys"):
|
||||||
|
return RedirectResponse(url="/qualys/tags")
|
||||||
result = remove_tag_from_asset_api(db, asset_id, int(tag_id))
|
result = remove_tag_from_asset_api(db, asset_id, int(tag_id))
|
||||||
color = "text-cyber-green" if result["ok"] else "text-cyber-red"
|
color = "text-cyber-green" if result["ok"] else "text-cyber-red"
|
||||||
return HTMLResponse(f'<span class="text-xs {color}">{result["msg"]}</span>')
|
return HTMLResponse(f'<span class="text-xs {color}">{result["msg"]}</span>')
|
||||||
@ -228,6 +243,9 @@ async def qualys_bulk_add_tag(request: Request, db=Depends(get_db)):
|
|||||||
user = get_current_user(request)
|
user = get_current_user(request)
|
||||||
if not user:
|
if not user:
|
||||||
return RedirectResponse(url="/login")
|
return RedirectResponse(url="/login")
|
||||||
|
perms = get_user_perms(db, user)
|
||||||
|
if not can_edit(perms, "qualys"):
|
||||||
|
return RedirectResponse(url="/qualys/tags")
|
||||||
form = await request.form()
|
form = await request.form()
|
||||||
ids = [int(x) for x in form.get("asset_ids", "").split(",") if x.strip().isdigit()]
|
ids = [int(x) for x in form.get("asset_ids", "").split(",") if x.strip().isdigit()]
|
||||||
tid = int(form.get("tag_id", "0") or "0")
|
tid = int(form.get("tag_id", "0") or "0")
|
||||||
@ -244,6 +262,9 @@ async def qualys_bulk_remove_tag(request: Request, db=Depends(get_db)):
|
|||||||
user = get_current_user(request)
|
user = get_current_user(request)
|
||||||
if not user:
|
if not user:
|
||||||
return RedirectResponse(url="/login")
|
return RedirectResponse(url="/login")
|
||||||
|
perms = get_user_perms(db, user)
|
||||||
|
if not can_edit(perms, "qualys"):
|
||||||
|
return RedirectResponse(url="/qualys/tags")
|
||||||
form = await request.form()
|
form = await request.form()
|
||||||
ids = [int(x) for x in form.get("asset_ids", "").split(",") if x.strip().isdigit()]
|
ids = [int(x) for x in form.get("asset_ids", "").split(",") if x.strip().isdigit()]
|
||||||
tid = int(form.get("tag_id", "0") or "0")
|
tid = int(form.get("tag_id", "0") or "0")
|
||||||
@ -260,6 +281,9 @@ async def qualys_resync_assets(request: Request, db=Depends(get_db)):
|
|||||||
user = get_current_user(request)
|
user = get_current_user(request)
|
||||||
if not user:
|
if not user:
|
||||||
return RedirectResponse(url="/login")
|
return RedirectResponse(url="/login")
|
||||||
|
perms = get_user_perms(db, user)
|
||||||
|
if not can_edit(perms, "qualys"):
|
||||||
|
return RedirectResponse(url="/qualys/search")
|
||||||
form = await request.form()
|
form = await request.form()
|
||||||
ids = [int(x) for x in form.get("asset_ids", "").split(",") if x.strip().isdigit()]
|
ids = [int(x) for x in form.get("asset_ids", "").split(",") if x.strip().isdigit()]
|
||||||
ok = 0
|
ok = 0
|
||||||
@ -303,6 +327,9 @@ async def qualys_tags_export(request: Request, db=Depends(get_db)):
|
|||||||
user = get_current_user(request)
|
user = get_current_user(request)
|
||||||
if not user:
|
if not user:
|
||||||
return RedirectResponse(url="/login")
|
return RedirectResponse(url="/login")
|
||||||
|
perms = get_user_perms(db, user)
|
||||||
|
if not can_view(perms, "qualys"):
|
||||||
|
return RedirectResponse(url="/qualys/tags")
|
||||||
tags = db.execute(text("SELECT * FROM qualys_tags ORDER BY name")).fetchall()
|
tags = db.execute(text("SELECT * FROM qualys_tags ORDER BY name")).fetchall()
|
||||||
output = io.StringIO()
|
output = io.StringIO()
|
||||||
writer = csv.writer(output, delimiter=";")
|
writer = csv.writer(output, delimiter=";")
|
||||||
@ -452,14 +479,13 @@ async def qualys_agents_page(request: Request, db=Depends(get_db)):
|
|||||||
|
|
||||||
# Serveurs en prod sans agent Qualys
|
# Serveurs en prod sans agent Qualys
|
||||||
no_agent = db.execute(text("""
|
no_agent = db.execute(text("""
|
||||||
SELECT s.hostname, s.os_family, d.name as domain, e.name as env, z.name as zone
|
SELECT s.hostname, s.os_family, s.etat, d.name as domain, e.name as env, z.name as zone
|
||||||
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 zones z ON s.zone_id = z.id
|
LEFT JOIN zones z ON s.zone_id = z.id
|
||||||
WHERE s.etat = 'en_production'
|
WHERE NOT EXISTS (SELECT 1 FROM qualys_assets qa WHERE LOWER(qa.hostname) = LOWER(s.hostname))
|
||||||
AND NOT EXISTS (SELECT 1 FROM qualys_assets qa WHERE LOWER(qa.hostname) = LOWER(s.hostname))
|
|
||||||
ORDER BY s.hostname
|
ORDER BY s.hostname
|
||||||
""")).fetchall()
|
""")).fetchall()
|
||||||
|
|
||||||
@ -480,12 +506,73 @@ async def qualys_agents_page(request: Request, db=Depends(get_db)):
|
|||||||
return templates.TemplateResponse("qualys_agents.html", ctx)
|
return templates.TemplateResponse("qualys_agents.html", ctx)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/qualys/agents/export-no-agent")
|
||||||
|
async def export_no_agent_csv(request: Request, db=Depends(get_db)):
|
||||||
|
user = get_current_user(request)
|
||||||
|
if not user:
|
||||||
|
return RedirectResponse(url="/login")
|
||||||
|
perms = get_user_perms(db, user)
|
||||||
|
if not can_view(perms, "qualys"):
|
||||||
|
return RedirectResponse(url="/qualys/agents")
|
||||||
|
import io, csv as _csv
|
||||||
|
rows = db.execute(text("""
|
||||||
|
SELECT s.hostname, s.os_family, s.etat, d.name as domain, e.name as env, z.name as zone
|
||||||
|
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 zones z ON s.zone_id = z.id
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM qualys_assets qa WHERE LOWER(qa.hostname) = LOWER(s.hostname))
|
||||||
|
ORDER BY s.hostname
|
||||||
|
""")).fetchall()
|
||||||
|
output = io.StringIO()
|
||||||
|
w = _csv.writer(output, delimiter=";")
|
||||||
|
w.writerow(["Hostname", "OS", "Domaine", "Environnement", "Zone", "Etat"])
|
||||||
|
for r in rows:
|
||||||
|
w.writerow([r.hostname, r.os_family or "", r.domain or "", r.env or "", r.zone or "", r.etat or ""])
|
||||||
|
output.seek(0)
|
||||||
|
return StreamingResponse(
|
||||||
|
iter(["\ufeff" + output.getvalue()]),
|
||||||
|
media_type="text/csv",
|
||||||
|
headers={"Content-Disposition": "attachment; filename=serveurs_sans_agent.csv"})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/qualys/agents/export-inactive")
|
||||||
|
async def export_inactive_csv(request: Request, db=Depends(get_db)):
|
||||||
|
user = get_current_user(request)
|
||||||
|
if not user:
|
||||||
|
return RedirectResponse(url="/login")
|
||||||
|
perms = get_user_perms(db, user)
|
||||||
|
if not can_view(perms, "qualys"):
|
||||||
|
return RedirectResponse(url="/qualys/agents")
|
||||||
|
import io, csv as _csv
|
||||||
|
rows = db.execute(text("""
|
||||||
|
SELECT qa.hostname, qa.os, qa.agent_version, qa.last_checkin, s.etat
|
||||||
|
FROM qualys_assets qa LEFT JOIN servers s ON qa.server_id = s.id
|
||||||
|
WHERE qa.agent_status ILIKE '%inactive%' ORDER BY qa.hostname
|
||||||
|
""")).fetchall()
|
||||||
|
output = io.StringIO()
|
||||||
|
w = _csv.writer(output, delimiter=";")
|
||||||
|
w.writerow(["Hostname", "OS", "Version agent", "Dernier check-in", "Etat"])
|
||||||
|
for r in rows:
|
||||||
|
lc = str(r.last_checkin)[:10] if r.last_checkin else ""
|
||||||
|
w.writerow([r.hostname, r.os or "", r.agent_version or "", lc, r.etat or ""])
|
||||||
|
output.seek(0)
|
||||||
|
return StreamingResponse(
|
||||||
|
iter(["\ufeff" + output.getvalue()]),
|
||||||
|
media_type="text/csv",
|
||||||
|
headers={"Content-Disposition": "attachment; filename=agents_inactifs.csv"})
|
||||||
|
|
||||||
|
|
||||||
@router.get("/qualys/vulns/{ip}", response_class=HTMLResponse)
|
@router.get("/qualys/vulns/{ip}", response_class=HTMLResponse)
|
||||||
async def qualys_vulns_detail(request: Request, ip: str, db=Depends(get_db)):
|
async def qualys_vulns_detail(request: Request, ip: str, db=Depends(get_db)):
|
||||||
"""Retourne le detail des vulns severity 3,4,5 pour une IP (fragment HTMX)"""
|
"""Retourne le detail des vulns severity 3,4,5 pour une IP (fragment HTMX)"""
|
||||||
user = get_current_user(request)
|
user = get_current_user(request)
|
||||||
if not user:
|
if not user:
|
||||||
return HTMLResponse("<p>Non autorise</p>")
|
return HTMLResponse("<p>Non autorise</p>", status_code=401)
|
||||||
|
perms = get_user_perms(db, user)
|
||||||
|
if not can_view(perms, "qualys"):
|
||||||
|
return HTMLResponse("<p>Acces interdit</p>", status_code=403)
|
||||||
|
|
||||||
# Cache 10 min
|
# Cache 10 min
|
||||||
from ..services import cache as _cache
|
from ..services import cache as _cache
|
||||||
@ -642,7 +729,10 @@ async def qualys_vulns_detail(request: Request, ip: str, db=Depends(get_db)):
|
|||||||
async def qualys_asset_detail(request: Request, asset_id: int, db=Depends(get_db)):
|
async def qualys_asset_detail(request: Request, asset_id: int, db=Depends(get_db)):
|
||||||
user = get_current_user(request)
|
user = get_current_user(request)
|
||||||
if not user:
|
if not user:
|
||||||
return HTMLResponse("<p>Non autorisé</p>")
|
return HTMLResponse("<p>Non autorisé</p>", status_code=401)
|
||||||
|
perms = get_user_perms(db, user)
|
||||||
|
if not can_view(perms, "qualys"):
|
||||||
|
return HTMLResponse("<p>Acces interdit</p>", status_code=403)
|
||||||
|
|
||||||
asset = db.execute(text("SELECT * FROM qualys_assets WHERE qualys_asset_id = :aid"),
|
asset = db.execute(text("SELECT * FROM qualys_assets WHERE qualys_asset_id = :aid"),
|
||||||
{"aid": asset_id}).fetchone()
|
{"aid": asset_id}).fetchone()
|
||||||
|
|||||||
1012
app/routers/quickwin.py
Normal file
1012
app/routers/quickwin.py
Normal file
File diff suppressed because it is too large
Load Diff
478
app/routers/referentiel.py
Normal file
478
app/routers/referentiel.py
Normal file
@ -0,0 +1,478 @@
|
|||||||
|
"""Router Referentiel — CRUD domaines, environnements, associations, zones"""
|
||||||
|
from fastapi import APIRouter, Request, Depends, Form, Query
|
||||||
|
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
from sqlalchemy import text
|
||||||
|
from ..dependencies import get_db, get_current_user, get_user_perms, can_view, can_edit
|
||||||
|
|
||||||
|
templates = Jinja2Templates(directory="app/templates")
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
# =========================================================
|
||||||
|
# PAGE PRINCIPALE (onglets)
|
||||||
|
# =========================================================
|
||||||
|
|
||||||
|
@router.get("/referentiel", response_class=HTMLResponse)
|
||||||
|
def referentiel_page(request: Request, db=Depends(get_db),
|
||||||
|
tab: str = Query("domains")):
|
||||||
|
user = get_current_user(request)
|
||||||
|
if not user:
|
||||||
|
return RedirectResponse(url="/login")
|
||||||
|
perms = get_user_perms(db, user)
|
||||||
|
if not can_view(perms, "settings"):
|
||||||
|
return RedirectResponse(url="/dashboard")
|
||||||
|
|
||||||
|
can_modify = can_edit(perms, "settings")
|
||||||
|
|
||||||
|
# Domaines
|
||||||
|
domains = db.execute(text(
|
||||||
|
"SELECT id, name, code, description, default_excludes, default_patch_window, "
|
||||||
|
"default_patch_frequency, is_active, display_order FROM domains ORDER BY display_order, name"
|
||||||
|
)).fetchall()
|
||||||
|
|
||||||
|
# Environnements
|
||||||
|
envs = db.execute(text(
|
||||||
|
"SELECT id, name, code FROM environments ORDER BY id"
|
||||||
|
)).fetchall()
|
||||||
|
|
||||||
|
# Zones
|
||||||
|
zones = db.execute(text(
|
||||||
|
"SELECT id, name, description, is_dmz FROM zones ORDER BY id"
|
||||||
|
)).fetchall()
|
||||||
|
|
||||||
|
# Associations domain_environments
|
||||||
|
assocs = db.execute(text("""
|
||||||
|
SELECT de.id, d.name as domain_name, d.id as domain_id,
|
||||||
|
e.name as env_name, e.id as env_id,
|
||||||
|
de.responsable_nom, de.responsable_email,
|
||||||
|
de.referent_nom, de.referent_email,
|
||||||
|
de.patch_window, de.patch_excludes,
|
||||||
|
de.nb_servers, de.is_active
|
||||||
|
FROM domain_environments de
|
||||||
|
JOIN domains d ON de.domain_id = d.id
|
||||||
|
JOIN environments e ON de.environment_id = e.id
|
||||||
|
ORDER BY d.display_order, d.name, e.id
|
||||||
|
""")).fetchall()
|
||||||
|
|
||||||
|
# Domaines DNS (domain_ltd)
|
||||||
|
dns_domains = db.execute(text(
|
||||||
|
"SELECT id, name, description, is_active FROM domain_ltd_list ORDER BY name"
|
||||||
|
)).fetchall()
|
||||||
|
|
||||||
|
# Compteur serveurs par domain_ltd
|
||||||
|
dns_srv_counts = {}
|
||||||
|
rows = db.execute(text("""
|
||||||
|
SELECT dl.id, COUNT(s.id) as cnt FROM domain_ltd_list dl
|
||||||
|
LEFT JOIN servers s ON s.domain_ltd = dl.name
|
||||||
|
GROUP BY dl.id
|
||||||
|
""")).fetchall()
|
||||||
|
for r in rows:
|
||||||
|
dns_srv_counts[r.id] = r.cnt
|
||||||
|
|
||||||
|
# Compteur serveurs par domaine
|
||||||
|
dom_srv_counts = {}
|
||||||
|
rows = db.execute(text("""
|
||||||
|
SELECT d.id, COUNT(s.id) as cnt FROM domains d
|
||||||
|
LEFT JOIN domain_environments de ON de.domain_id = d.id
|
||||||
|
LEFT JOIN servers s ON s.domain_env_id = de.id
|
||||||
|
GROUP BY d.id
|
||||||
|
""")).fetchall()
|
||||||
|
for r in rows:
|
||||||
|
dom_srv_counts[r.id] = r.cnt
|
||||||
|
|
||||||
|
# Compteur serveurs par env
|
||||||
|
env_srv_counts = {}
|
||||||
|
rows = db.execute(text("""
|
||||||
|
SELECT e.id, COUNT(s.id) as cnt FROM environments e
|
||||||
|
LEFT JOIN domain_environments de ON de.environment_id = e.id
|
||||||
|
LEFT JOIN servers s ON s.domain_env_id = de.id
|
||||||
|
GROUP BY e.id
|
||||||
|
""")).fetchall()
|
||||||
|
for r in rows:
|
||||||
|
env_srv_counts[r.id] = r.cnt
|
||||||
|
|
||||||
|
# Compteur serveurs par zone
|
||||||
|
zone_srv_counts = {}
|
||||||
|
rows = db.execute(text("""
|
||||||
|
SELECT z.id, COUNT(s.id) as cnt FROM zones z
|
||||||
|
LEFT JOIN servers s ON s.zone_id = z.id
|
||||||
|
GROUP BY z.id
|
||||||
|
""")).fetchall()
|
||||||
|
for r in rows:
|
||||||
|
zone_srv_counts[r.id] = r.cnt
|
||||||
|
|
||||||
|
return templates.TemplateResponse("referentiel.html", {
|
||||||
|
"request": request, "user": user, "perms": perms,
|
||||||
|
"can_modify": can_modify, "tab": tab,
|
||||||
|
"domains": domains, "envs": envs, "zones": zones, "assocs": assocs,
|
||||||
|
"dns_domains": dns_domains, "dns_srv_counts": dns_srv_counts,
|
||||||
|
"dom_srv_counts": dom_srv_counts,
|
||||||
|
"env_srv_counts": env_srv_counts,
|
||||||
|
"zone_srv_counts": zone_srv_counts,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
# =========================================================
|
||||||
|
# DOMAINES CRUD
|
||||||
|
# =========================================================
|
||||||
|
|
||||||
|
@router.post("/referentiel/domains/add")
|
||||||
|
def domain_add(request: Request, db=Depends(get_db),
|
||||||
|
name: str = Form(...), code: str = Form(...),
|
||||||
|
description: str = Form(""),
|
||||||
|
default_excludes: str = Form(""),
|
||||||
|
default_patch_window: str = Form(""),
|
||||||
|
display_order: int = Form(0)):
|
||||||
|
user = get_current_user(request)
|
||||||
|
if not user:
|
||||||
|
return RedirectResponse(url="/login")
|
||||||
|
perms = get_user_perms(db, user)
|
||||||
|
if not can_edit(perms, "settings"):
|
||||||
|
return RedirectResponse(url="/referentiel?tab=domains")
|
||||||
|
|
||||||
|
db.execute(text("""
|
||||||
|
INSERT INTO domains (name, code, description, default_excludes,
|
||||||
|
default_patch_window, display_order)
|
||||||
|
VALUES (:name, :code, :desc, :excl, :pw, :ord)
|
||||||
|
"""), {"name": name.strip(), "code": code.strip().upper(),
|
||||||
|
"desc": description.strip(), "excl": default_excludes.strip(),
|
||||||
|
"pw": default_patch_window.strip(), "ord": display_order})
|
||||||
|
db.commit()
|
||||||
|
return RedirectResponse(url="/referentiel?tab=domains&msg=added", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/referentiel/domains/{domain_id}/edit")
|
||||||
|
def domain_edit(request: Request, domain_id: int, db=Depends(get_db),
|
||||||
|
name: str = Form(...), code: str = Form(...),
|
||||||
|
description: str = Form(""),
|
||||||
|
default_excludes: str = Form(""),
|
||||||
|
default_patch_window: str = Form(""),
|
||||||
|
display_order: int = Form(0),
|
||||||
|
is_active: str = Form("off")):
|
||||||
|
user = get_current_user(request)
|
||||||
|
if not user:
|
||||||
|
return RedirectResponse(url="/login")
|
||||||
|
perms = get_user_perms(db, user)
|
||||||
|
if not can_edit(perms, "settings"):
|
||||||
|
return RedirectResponse(url="/referentiel?tab=domains")
|
||||||
|
|
||||||
|
active = is_active == "on"
|
||||||
|
db.execute(text("""
|
||||||
|
UPDATE domains SET name=:name, code=:code, description=:desc,
|
||||||
|
default_excludes=:excl, default_patch_window=:pw,
|
||||||
|
display_order=:ord, is_active=:act, updated_at=now()
|
||||||
|
WHERE id=:id
|
||||||
|
"""), {"id": domain_id, "name": name.strip(), "code": code.strip().upper(),
|
||||||
|
"desc": description.strip(), "excl": default_excludes.strip(),
|
||||||
|
"pw": default_patch_window.strip(), "ord": display_order, "act": active})
|
||||||
|
db.commit()
|
||||||
|
return RedirectResponse(url="/referentiel?tab=domains&msg=updated", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/referentiel/domains/{domain_id}/delete")
|
||||||
|
def domain_delete(request: Request, domain_id: int, db=Depends(get_db)):
|
||||||
|
user = get_current_user(request)
|
||||||
|
if not user:
|
||||||
|
return RedirectResponse(url="/login")
|
||||||
|
perms = get_user_perms(db, user)
|
||||||
|
if not can_edit(perms, "settings"):
|
||||||
|
return RedirectResponse(url="/referentiel?tab=domains")
|
||||||
|
|
||||||
|
# Verifier s'il y a des serveurs lies
|
||||||
|
cnt = db.execute(text("""
|
||||||
|
SELECT COUNT(*) as c FROM servers s
|
||||||
|
JOIN domain_environments de ON s.domain_env_id = de.id
|
||||||
|
WHERE de.domain_id = :id
|
||||||
|
"""), {"id": domain_id}).fetchone().c
|
||||||
|
if cnt > 0:
|
||||||
|
return RedirectResponse(
|
||||||
|
url=f"/referentiel?tab=domains&msg=nodelete&detail={cnt}",
|
||||||
|
status_code=303)
|
||||||
|
|
||||||
|
db.execute(text("DELETE FROM domain_environments WHERE domain_id = :id"), {"id": domain_id})
|
||||||
|
db.execute(text("DELETE FROM domains WHERE id = :id"), {"id": domain_id})
|
||||||
|
db.commit()
|
||||||
|
return RedirectResponse(url="/referentiel?tab=domains&msg=deleted", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
# =========================================================
|
||||||
|
# ENVIRONNEMENTS CRUD
|
||||||
|
# =========================================================
|
||||||
|
|
||||||
|
@router.post("/referentiel/envs/add")
|
||||||
|
def env_add(request: Request, db=Depends(get_db),
|
||||||
|
name: str = Form(...), code: str = Form(...)):
|
||||||
|
user = get_current_user(request)
|
||||||
|
if not user:
|
||||||
|
return RedirectResponse(url="/login")
|
||||||
|
perms = get_user_perms(db, user)
|
||||||
|
if not can_edit(perms, "settings"):
|
||||||
|
return RedirectResponse(url="/referentiel?tab=envs")
|
||||||
|
|
||||||
|
db.execute(text("""
|
||||||
|
INSERT INTO environments (name, code) VALUES (:name, :code)
|
||||||
|
"""), {"name": name.strip(), "code": code.strip().upper()})
|
||||||
|
db.commit()
|
||||||
|
return RedirectResponse(url="/referentiel?tab=envs&msg=added", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/referentiel/envs/{env_id}/edit")
|
||||||
|
def env_edit(request: Request, env_id: int, db=Depends(get_db),
|
||||||
|
name: str = Form(...), code: str = Form(...)):
|
||||||
|
user = get_current_user(request)
|
||||||
|
if not user:
|
||||||
|
return RedirectResponse(url="/login")
|
||||||
|
perms = get_user_perms(db, user)
|
||||||
|
if not can_edit(perms, "settings"):
|
||||||
|
return RedirectResponse(url="/referentiel?tab=envs")
|
||||||
|
|
||||||
|
db.execute(text("""
|
||||||
|
UPDATE environments SET name=:name, code=:code WHERE id=:id
|
||||||
|
"""), {"id": env_id, "name": name.strip(), "code": code.strip().upper()})
|
||||||
|
db.commit()
|
||||||
|
return RedirectResponse(url="/referentiel?tab=envs&msg=updated", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/referentiel/envs/{env_id}/delete")
|
||||||
|
def env_delete(request: Request, env_id: int, db=Depends(get_db)):
|
||||||
|
user = get_current_user(request)
|
||||||
|
if not user:
|
||||||
|
return RedirectResponse(url="/login")
|
||||||
|
perms = get_user_perms(db, user)
|
||||||
|
if not can_edit(perms, "settings"):
|
||||||
|
return RedirectResponse(url="/referentiel?tab=envs")
|
||||||
|
|
||||||
|
cnt = db.execute(text("""
|
||||||
|
SELECT COUNT(*) as c FROM servers s
|
||||||
|
JOIN domain_environments de ON s.domain_env_id = de.id
|
||||||
|
WHERE de.environment_id = :id
|
||||||
|
"""), {"id": env_id}).fetchone().c
|
||||||
|
if cnt > 0:
|
||||||
|
return RedirectResponse(
|
||||||
|
url=f"/referentiel?tab=envs&msg=nodelete&detail={cnt}",
|
||||||
|
status_code=303)
|
||||||
|
|
||||||
|
db.execute(text("DELETE FROM domain_environments WHERE environment_id = :id"), {"id": env_id})
|
||||||
|
db.execute(text("DELETE FROM environments WHERE id = :id"), {"id": env_id})
|
||||||
|
db.commit()
|
||||||
|
return RedirectResponse(url="/referentiel?tab=envs&msg=deleted", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
# =========================================================
|
||||||
|
# ZONES CRUD
|
||||||
|
# =========================================================
|
||||||
|
|
||||||
|
@router.post("/referentiel/zones/add")
|
||||||
|
def zone_add(request: Request, db=Depends(get_db),
|
||||||
|
name: str = Form(...), description: str = Form(""),
|
||||||
|
is_dmz: str = Form("off")):
|
||||||
|
user = get_current_user(request)
|
||||||
|
if not user:
|
||||||
|
return RedirectResponse(url="/login")
|
||||||
|
perms = get_user_perms(db, user)
|
||||||
|
if not can_edit(perms, "settings"):
|
||||||
|
return RedirectResponse(url="/referentiel?tab=zones")
|
||||||
|
|
||||||
|
db.execute(text("""
|
||||||
|
INSERT INTO zones (name, description, is_dmz) VALUES (:name, :desc, :dmz)
|
||||||
|
"""), {"name": name.strip(), "desc": description.strip(), "dmz": is_dmz == "on"})
|
||||||
|
db.commit()
|
||||||
|
return RedirectResponse(url="/referentiel?tab=zones&msg=added", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/referentiel/zones/{zone_id}/edit")
|
||||||
|
def zone_edit(request: Request, zone_id: int, db=Depends(get_db),
|
||||||
|
name: str = Form(...), description: str = Form(""),
|
||||||
|
is_dmz: str = Form("off")):
|
||||||
|
user = get_current_user(request)
|
||||||
|
if not user:
|
||||||
|
return RedirectResponse(url="/login")
|
||||||
|
perms = get_user_perms(db, user)
|
||||||
|
if not can_edit(perms, "settings"):
|
||||||
|
return RedirectResponse(url="/referentiel?tab=zones")
|
||||||
|
|
||||||
|
db.execute(text("""
|
||||||
|
UPDATE zones SET name=:name, description=:desc, is_dmz=:dmz WHERE id=:id
|
||||||
|
"""), {"id": zone_id, "name": name.strip(), "desc": description.strip(),
|
||||||
|
"dmz": is_dmz == "on"})
|
||||||
|
db.commit()
|
||||||
|
return RedirectResponse(url="/referentiel?tab=zones&msg=updated", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/referentiel/zones/{zone_id}/delete")
|
||||||
|
def zone_delete(request: Request, zone_id: int, db=Depends(get_db)):
|
||||||
|
user = get_current_user(request)
|
||||||
|
if not user:
|
||||||
|
return RedirectResponse(url="/login")
|
||||||
|
perms = get_user_perms(db, user)
|
||||||
|
if not can_edit(perms, "settings"):
|
||||||
|
return RedirectResponse(url="/referentiel?tab=zones")
|
||||||
|
|
||||||
|
cnt = db.execute(text(
|
||||||
|
"SELECT COUNT(*) as c FROM servers WHERE zone_id = :id"
|
||||||
|
), {"id": zone_id}).fetchone().c
|
||||||
|
if cnt > 0:
|
||||||
|
return RedirectResponse(
|
||||||
|
url=f"/referentiel?tab=zones&msg=nodelete&detail={cnt}",
|
||||||
|
status_code=303)
|
||||||
|
|
||||||
|
db.execute(text("DELETE FROM zones WHERE id = :id"), {"id": zone_id})
|
||||||
|
db.commit()
|
||||||
|
return RedirectResponse(url="/referentiel?tab=zones&msg=deleted", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
# =========================================================
|
||||||
|
# ASSOCIATIONS DOMAIN x ENV
|
||||||
|
# =========================================================
|
||||||
|
|
||||||
|
@router.post("/referentiel/assocs/add")
|
||||||
|
def assoc_add(request: Request, db=Depends(get_db),
|
||||||
|
domain_id: int = Form(...), environment_id: int = Form(...),
|
||||||
|
responsable_nom: str = Form(""), responsable_email: str = Form(""),
|
||||||
|
referent_nom: str = Form(""), referent_email: str = Form(""),
|
||||||
|
patch_window: str = Form(""), patch_excludes: str = Form("")):
|
||||||
|
user = get_current_user(request)
|
||||||
|
if not user:
|
||||||
|
return RedirectResponse(url="/login")
|
||||||
|
perms = get_user_perms(db, user)
|
||||||
|
if not can_edit(perms, "settings"):
|
||||||
|
return RedirectResponse(url="/referentiel?tab=assocs")
|
||||||
|
|
||||||
|
existing = db.execute(text(
|
||||||
|
"SELECT id FROM domain_environments WHERE domain_id=:d AND environment_id=:e"
|
||||||
|
), {"d": domain_id, "e": environment_id}).fetchone()
|
||||||
|
if existing:
|
||||||
|
return RedirectResponse(url="/referentiel?tab=assocs&msg=exists", status_code=303)
|
||||||
|
|
||||||
|
db.execute(text("""
|
||||||
|
INSERT INTO domain_environments (domain_id, environment_id, responsable_nom,
|
||||||
|
responsable_email, referent_nom, referent_email, patch_window, patch_excludes)
|
||||||
|
VALUES (:d, :e, :rn, :re, :fn, :fe, :pw, :pe)
|
||||||
|
"""), {"d": domain_id, "e": environment_id,
|
||||||
|
"rn": responsable_nom.strip(), "re": responsable_email.strip(),
|
||||||
|
"fn": referent_nom.strip(), "fe": referent_email.strip(),
|
||||||
|
"pw": patch_window.strip(), "pe": patch_excludes.strip()})
|
||||||
|
db.commit()
|
||||||
|
return RedirectResponse(url="/referentiel?tab=assocs&msg=added", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/referentiel/assocs/{assoc_id}/edit")
|
||||||
|
def assoc_edit(request: Request, assoc_id: int, db=Depends(get_db),
|
||||||
|
responsable_nom: str = Form(""), responsable_email: str = Form(""),
|
||||||
|
referent_nom: str = Form(""), referent_email: str = Form(""),
|
||||||
|
patch_window: str = Form(""), patch_excludes: str = Form(""),
|
||||||
|
is_active: str = Form("off")):
|
||||||
|
user = get_current_user(request)
|
||||||
|
if not user:
|
||||||
|
return RedirectResponse(url="/login")
|
||||||
|
perms = get_user_perms(db, user)
|
||||||
|
if not can_edit(perms, "settings"):
|
||||||
|
return RedirectResponse(url="/referentiel?tab=assocs")
|
||||||
|
|
||||||
|
db.execute(text("""
|
||||||
|
UPDATE domain_environments SET responsable_nom=:rn, responsable_email=:re,
|
||||||
|
referent_nom=:fn, referent_email=:fe, patch_window=:pw,
|
||||||
|
patch_excludes=:pe, is_active=:act
|
||||||
|
WHERE id=:id
|
||||||
|
"""), {"id": assoc_id,
|
||||||
|
"rn": responsable_nom.strip(), "re": responsable_email.strip(),
|
||||||
|
"fn": referent_nom.strip(), "fe": referent_email.strip(),
|
||||||
|
"pw": patch_window.strip(), "pe": patch_excludes.strip(),
|
||||||
|
"act": is_active == "on"})
|
||||||
|
db.commit()
|
||||||
|
return RedirectResponse(url="/referentiel?tab=assocs&msg=updated", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/referentiel/assocs/{assoc_id}/delete")
|
||||||
|
def assoc_delete(request: Request, assoc_id: int, db=Depends(get_db)):
|
||||||
|
user = get_current_user(request)
|
||||||
|
if not user:
|
||||||
|
return RedirectResponse(url="/login")
|
||||||
|
perms = get_user_perms(db, user)
|
||||||
|
if not can_edit(perms, "settings"):
|
||||||
|
return RedirectResponse(url="/referentiel?tab=assocs")
|
||||||
|
|
||||||
|
cnt = db.execute(text(
|
||||||
|
"SELECT COUNT(*) as c FROM servers WHERE domain_env_id = :id"
|
||||||
|
), {"id": assoc_id}).fetchone().c
|
||||||
|
if cnt > 0:
|
||||||
|
return RedirectResponse(
|
||||||
|
url=f"/referentiel?tab=assocs&msg=nodelete&detail={cnt}",
|
||||||
|
status_code=303)
|
||||||
|
|
||||||
|
db.execute(text("DELETE FROM domain_environments WHERE id = :id"), {"id": assoc_id})
|
||||||
|
db.commit()
|
||||||
|
return RedirectResponse(url="/referentiel?tab=assocs&msg=deleted", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
# =========================================================
|
||||||
|
# DOMAINES DNS (domain_ltd)
|
||||||
|
# =========================================================
|
||||||
|
|
||||||
|
@router.post("/referentiel/dns/add")
|
||||||
|
def dns_add(request: Request, db=Depends(get_db),
|
||||||
|
name: str = Form(...), description: str = Form("")):
|
||||||
|
user = get_current_user(request)
|
||||||
|
if not user:
|
||||||
|
return RedirectResponse(url="/login")
|
||||||
|
perms = get_user_perms(db, user)
|
||||||
|
if not can_edit(perms, "settings"):
|
||||||
|
return RedirectResponse(url="/referentiel?tab=dns")
|
||||||
|
|
||||||
|
db.execute(text("""
|
||||||
|
INSERT INTO domain_ltd_list (name, description)
|
||||||
|
VALUES (:name, :desc)
|
||||||
|
"""), {"name": name.strip().lower(), "desc": description.strip()})
|
||||||
|
db.commit()
|
||||||
|
return RedirectResponse(url="/referentiel?tab=dns&msg=added", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/referentiel/dns/{dns_id}/edit")
|
||||||
|
def dns_edit(request: Request, dns_id: int, db=Depends(get_db),
|
||||||
|
name: str = Form(...), description: str = Form(""),
|
||||||
|
is_active: str = Form("off")):
|
||||||
|
user = get_current_user(request)
|
||||||
|
if not user:
|
||||||
|
return RedirectResponse(url="/login")
|
||||||
|
perms = get_user_perms(db, user)
|
||||||
|
if not can_edit(perms, "settings"):
|
||||||
|
return RedirectResponse(url="/referentiel?tab=dns")
|
||||||
|
|
||||||
|
old = db.execute(text("SELECT name FROM domain_ltd_list WHERE id=:id"), {"id": dns_id}).fetchone()
|
||||||
|
new_name = name.strip().lower()
|
||||||
|
db.execute(text("""
|
||||||
|
UPDATE domain_ltd_list SET name=:name, description=:desc, is_active=:act WHERE id=:id
|
||||||
|
"""), {"id": dns_id, "name": new_name, "desc": description.strip(),
|
||||||
|
"act": is_active == "on"})
|
||||||
|
# Propager le renommage sur les serveurs
|
||||||
|
if old and old.name != new_name:
|
||||||
|
db.execute(text("UPDATE servers SET domain_ltd=:new WHERE domain_ltd=:old"),
|
||||||
|
{"old": old.name, "new": new_name})
|
||||||
|
db.commit()
|
||||||
|
return RedirectResponse(url="/referentiel?tab=dns&msg=updated", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/referentiel/dns/{dns_id}/delete")
|
||||||
|
def dns_delete(request: Request, dns_id: int, db=Depends(get_db)):
|
||||||
|
user = get_current_user(request)
|
||||||
|
if not user:
|
||||||
|
return RedirectResponse(url="/login")
|
||||||
|
perms = get_user_perms(db, user)
|
||||||
|
if not can_edit(perms, "settings"):
|
||||||
|
return RedirectResponse(url="/referentiel?tab=dns")
|
||||||
|
|
||||||
|
row = db.execute(text("SELECT name FROM domain_ltd_list WHERE id=:id"), {"id": dns_id}).fetchone()
|
||||||
|
if row:
|
||||||
|
cnt = db.execute(text(
|
||||||
|
"SELECT COUNT(*) as c FROM servers WHERE domain_ltd = :n"
|
||||||
|
), {"n": row.name}).fetchone().c
|
||||||
|
if cnt > 0:
|
||||||
|
return RedirectResponse(
|
||||||
|
url=f"/referentiel?tab=dns&msg=nodelete&detail={cnt}",
|
||||||
|
status_code=303)
|
||||||
|
|
||||||
|
db.execute(text("DELETE FROM domain_ltd_list WHERE id = :id"), {"id": dns_id})
|
||||||
|
db.commit()
|
||||||
|
return RedirectResponse(url="/referentiel?tab=dns&msg=deleted", status_code=303)
|
||||||
@ -89,6 +89,9 @@ async def safe_patching_detail(request: Request, campaign_id: int, db=Depends(ge
|
|||||||
user = get_current_user(request)
|
user = get_current_user(request)
|
||||||
if not user:
|
if not user:
|
||||||
return RedirectResponse(url="/login")
|
return RedirectResponse(url="/login")
|
||||||
|
perms = get_user_perms(db, user)
|
||||||
|
if not can_view(perms, "campaigns"):
|
||||||
|
return RedirectResponse(url="/dashboard")
|
||||||
|
|
||||||
campaign = get_campaign(db, campaign_id)
|
campaign = get_campaign(db, campaign_id)
|
||||||
if not campaign:
|
if not campaign:
|
||||||
@ -148,6 +151,9 @@ async def safe_patching_check_prereqs(request: Request, campaign_id: int, db=Dep
|
|||||||
user = get_current_user(request)
|
user = get_current_user(request)
|
||||||
if not user:
|
if not user:
|
||||||
return RedirectResponse(url="/login")
|
return RedirectResponse(url="/login")
|
||||||
|
perms = get_user_perms(db, user)
|
||||||
|
if not can_edit(perms, "campaigns"):
|
||||||
|
return RedirectResponse(url=f"/safe-patching/{campaign_id}")
|
||||||
from ..services.prereq_service import check_prereqs_campaign
|
from ..services.prereq_service import check_prereqs_campaign
|
||||||
checked, auto_excluded = check_prereqs_campaign(db, campaign_id)
|
checked, auto_excluded = check_prereqs_campaign(db, campaign_id)
|
||||||
return RedirectResponse(url=f"/safe-patching/{campaign_id}?step=prereqs&msg=prereqs_done", status_code=303)
|
return RedirectResponse(url=f"/safe-patching/{campaign_id}?step=prereqs&msg=prereqs_done", status_code=303)
|
||||||
@ -159,6 +165,9 @@ async def safe_patching_bulk_exclude(request: Request, campaign_id: int, db=Depe
|
|||||||
user = get_current_user(request)
|
user = get_current_user(request)
|
||||||
if not user:
|
if not user:
|
||||||
return RedirectResponse(url="/login")
|
return RedirectResponse(url="/login")
|
||||||
|
perms = get_user_perms(db, user)
|
||||||
|
if not can_edit(perms, "campaigns"):
|
||||||
|
return RedirectResponse(url=f"/safe-patching/{campaign_id}")
|
||||||
from ..services.campaign_service import exclude_session
|
from ..services.campaign_service import exclude_session
|
||||||
ids = [int(x) for x in session_ids.split(",") if x.strip().isdigit()]
|
ids = [int(x) for x in session_ids.split(",") if x.strip().isdigit()]
|
||||||
for sid in ids:
|
for sid in ids:
|
||||||
@ -173,6 +182,9 @@ async def safe_patching_execute(request: Request, campaign_id: int, db=Depends(g
|
|||||||
user = get_current_user(request)
|
user = get_current_user(request)
|
||||||
if not user:
|
if not user:
|
||||||
return RedirectResponse(url="/login")
|
return RedirectResponse(url="/login")
|
||||||
|
perms = get_user_perms(db, user)
|
||||||
|
if not can_edit(perms, "campaigns"):
|
||||||
|
return RedirectResponse(url=f"/safe-patching/{campaign_id}")
|
||||||
|
|
||||||
# Récupérer les sessions pending de la branche
|
# Récupérer les sessions pending de la branche
|
||||||
if branch == "hprod":
|
if branch == "hprod":
|
||||||
@ -215,6 +227,9 @@ async def safe_patching_terminal(request: Request, campaign_id: int, db=Depends(
|
|||||||
user = get_current_user(request)
|
user = get_current_user(request)
|
||||||
if not user:
|
if not user:
|
||||||
return RedirectResponse(url="/login")
|
return RedirectResponse(url="/login")
|
||||||
|
perms = get_user_perms(db, user)
|
||||||
|
if not can_view(perms, "campaigns"):
|
||||||
|
return RedirectResponse(url="/safe-patching")
|
||||||
campaign = get_campaign(db, campaign_id)
|
campaign = get_campaign(db, campaign_id)
|
||||||
ctx = base_context(request, db, user)
|
ctx = base_context(request, db, user)
|
||||||
ctx.update({"app_name": APP_NAME, "c": campaign, "branch": branch})
|
ctx.update({"app_name": APP_NAME, "c": campaign, "branch": branch})
|
||||||
@ -222,8 +237,11 @@ async def safe_patching_terminal(request: Request, campaign_id: int, db=Depends(
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/safe-patching/{campaign_id}/stream")
|
@router.get("/safe-patching/{campaign_id}/stream")
|
||||||
async def safe_patching_stream(request: Request, campaign_id: int):
|
async def safe_patching_stream(request: Request, campaign_id: int, db=Depends(get_db)):
|
||||||
"""SSE endpoint — stream les logs en temps réel"""
|
"""SSE endpoint — stream les logs en temps réel"""
|
||||||
|
user = get_current_user(request)
|
||||||
|
if not user:
|
||||||
|
return StreamingResponse(iter([]), media_type="text/event-stream")
|
||||||
async def event_generator():
|
async def event_generator():
|
||||||
q = get_stream(campaign_id)
|
q = get_stream(campaign_id)
|
||||||
while True:
|
while True:
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
"""Router serveurs — CRUD + detail + edit via HTMX"""
|
"""Router serveurs — CRUD + detail + edit via HTMX"""
|
||||||
from fastapi import APIRouter, Request, Depends, Query, Form
|
from fastapi import APIRouter, Request, Depends, Query, Form
|
||||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
from fastapi.responses import HTMLResponse, RedirectResponse, StreamingResponse
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
from ..dependencies import get_db, get_current_user
|
from ..dependencies import get_db, get_current_user
|
||||||
from ..services.server_service import (
|
from ..services.server_service import (
|
||||||
@ -18,13 +18,14 @@ templates = Jinja2Templates(directory="app/templates")
|
|||||||
async def servers_list(request: Request, db=Depends(get_db),
|
async def servers_list(request: Request, db=Depends(get_db),
|
||||||
domain: str = Query(None), env: str = Query(None),
|
domain: str = Query(None), env: str = Query(None),
|
||||||
tier: str = Query(None), etat: str = Query(None),
|
tier: str = Query(None), etat: str = Query(None),
|
||||||
|
os: str = Query(None), owner: str = Query(None),
|
||||||
search: str = Query(None), page: int = Query(1),
|
search: str = Query(None), page: int = Query(1),
|
||||||
sort: str = Query("hostname"), sort_dir: str = Query("asc")):
|
sort: str = Query("hostname"), sort_dir: str = Query("asc")):
|
||||||
user = get_current_user(request)
|
user = get_current_user(request)
|
||||||
if not user:
|
if not user:
|
||||||
return RedirectResponse(url="/login")
|
return RedirectResponse(url="/login")
|
||||||
|
|
||||||
filters = {"domain": domain, "env": env, "tier": tier, "etat": etat, "search": search}
|
filters = {"domain": domain, "env": env, "tier": tier, "etat": etat, "os": os, "owner": owner, "search": search}
|
||||||
servers, total = list_servers(db, filters, page, sort=sort, sort_dir=sort_dir)
|
servers, total = list_servers(db, filters, page, sort=sort, sort_dir=sort_dir)
|
||||||
domains_list, envs_list = get_reference_data(db)
|
domains_list, envs_list = get_reference_data(db)
|
||||||
|
|
||||||
@ -36,6 +37,37 @@ async def servers_list(request: Request, db=Depends(get_db),
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/servers/export-csv")
|
||||||
|
async def servers_export_csv(request: Request, db=Depends(get_db),
|
||||||
|
domain: str = Query(None), env: str = Query(None),
|
||||||
|
tier: str = Query(None), etat: str = Query(None),
|
||||||
|
os: str = Query(None), owner: str = Query(None),
|
||||||
|
search: str = Query(None)):
|
||||||
|
user = get_current_user(request)
|
||||||
|
if not user:
|
||||||
|
return RedirectResponse(url="/login")
|
||||||
|
import io, csv
|
||||||
|
filters = {"domain": domain, "env": env, "tier": tier, "etat": etat, "os": os, "owner": owner, "search": search}
|
||||||
|
servers, total = list_servers(db, filters, page=1, per_page=99999, sort="hostname", sort_dir="asc")
|
||||||
|
output = io.StringIO()
|
||||||
|
w = csv.writer(output, delimiter=";")
|
||||||
|
w.writerow(["Hostname", "FQDN", "OS", "Version OS", "Domaine", "Environnement",
|
||||||
|
"Zone", "Tier", "Etat", "Owner patching", "Application"])
|
||||||
|
for s in servers:
|
||||||
|
w.writerow([
|
||||||
|
s.hostname, getattr(s, "fqdn", "") or "", s.os_family or "",
|
||||||
|
getattr(s, "os_version", "") or "",
|
||||||
|
getattr(s, "domaine", "") or "", getattr(s, "environnement", "") or "",
|
||||||
|
getattr(s, "zone_name", "") or "", s.tier or "", s.etat or "",
|
||||||
|
s.patch_os_owner or "", getattr(s, "application_name", "") or "",
|
||||||
|
])
|
||||||
|
output.seek(0)
|
||||||
|
return StreamingResponse(
|
||||||
|
iter(["\ufeff" + output.getvalue()]),
|
||||||
|
media_type="text/csv",
|
||||||
|
headers={"Content-Disposition": "attachment; filename=serveurs.csv"})
|
||||||
|
|
||||||
@router.get("/servers/{server_id}/detail", response_class=HTMLResponse)
|
@router.get("/servers/{server_id}/detail", response_class=HTMLResponse)
|
||||||
async def server_detail(request: Request, server_id: int, db=Depends(get_db)):
|
async def server_detail(request: Request, server_id: int, db=Depends(get_db)):
|
||||||
user = get_current_user(request)
|
user = get_current_user(request)
|
||||||
@ -61,8 +93,17 @@ async def server_edit(request: Request, server_id: int, db=Depends(get_db)):
|
|||||||
return HTMLResponse("<p>Serveur non trouve</p>")
|
return HTMLResponse("<p>Serveur non trouve</p>")
|
||||||
domains, envs = get_reference_data(db)
|
domains, envs = get_reference_data(db)
|
||||||
ips = get_server_ips(db, server_id)
|
ips = get_server_ips(db, server_id)
|
||||||
|
from sqlalchemy import text as sqlt
|
||||||
|
dns_list = db.execute(sqlt(
|
||||||
|
"SELECT name FROM domain_ltd_list WHERE is_active = true ORDER BY name"
|
||||||
|
)).fetchall()
|
||||||
|
zones_list = db.execute(sqlt(
|
||||||
|
"SELECT name FROM zones ORDER BY name"
|
||||||
|
)).fetchall()
|
||||||
return templates.TemplateResponse("partials/server_edit.html", {
|
return templates.TemplateResponse("partials/server_edit.html", {
|
||||||
"request": request, "s": s, "domains": domains, "envs": envs, "ips": ips
|
"request": request, "s": s, "domains": domains, "envs": envs, "ips": ips,
|
||||||
|
"dns_list": [r.name for r in dns_list],
|
||||||
|
"zones_list": [r.name for r in zones_list],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@ -74,7 +115,7 @@ async def server_update(request: Request, server_id: int, db=Depends(get_db),
|
|||||||
referent_nom: str = Form(None), mode_operatoire: str = Form(None),
|
referent_nom: str = Form(None), mode_operatoire: str = Form(None),
|
||||||
commentaire: str = Form(None),
|
commentaire: str = Form(None),
|
||||||
ip_reelle: str = Form(None), ip_connexion: str = Form(None),
|
ip_reelle: str = Form(None), ip_connexion: str = Form(None),
|
||||||
ssh_method: str = Form(None),
|
ssh_method: str = Form(None), domain_ltd: str = Form(None),
|
||||||
pref_patch_jour: str = Form(None), pref_patch_heure: str = Form(None)):
|
pref_patch_jour: str = Form(None), pref_patch_heure: str = Form(None)):
|
||||||
|
|
||||||
user = get_current_user(request)
|
user = get_current_user(request)
|
||||||
@ -87,7 +128,7 @@ async def server_update(request: Request, server_id: int, db=Depends(get_db),
|
|||||||
"responsable_nom": responsable_nom, "referent_nom": referent_nom,
|
"responsable_nom": responsable_nom, "referent_nom": referent_nom,
|
||||||
"mode_operatoire": mode_operatoire, "commentaire": commentaire,
|
"mode_operatoire": mode_operatoire, "commentaire": commentaire,
|
||||||
"ip_reelle": ip_reelle, "ip_connexion": ip_connexion,
|
"ip_reelle": ip_reelle, "ip_connexion": ip_connexion,
|
||||||
"ssh_method": ssh_method,
|
"ssh_method": ssh_method, "domain_ltd": domain_ltd,
|
||||||
"pref_patch_jour": pref_patch_jour, "pref_patch_heure": pref_patch_heure,
|
"pref_patch_jour": pref_patch_jour, "pref_patch_heure": pref_patch_heure,
|
||||||
}
|
}
|
||||||
update_server(db, server_id, data, user.get("sub"))
|
update_server(db, server_id, data, user.get("sub"))
|
||||||
|
|||||||
@ -3,7 +3,7 @@ from fastapi import APIRouter, Request, Depends, Form
|
|||||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from ..dependencies import get_db, get_current_user
|
from ..dependencies import get_db, get_current_user, get_user_perms, can_view, can_edit
|
||||||
from ..services.secrets_service import get_secret, set_secret, list_secrets, init_secrets_from_config
|
from ..services.secrets_service import get_secret, set_secret, list_secrets, init_secrets_from_config
|
||||||
from ..config import APP_NAME
|
from ..config import APP_NAME
|
||||||
|
|
||||||
@ -134,6 +134,9 @@ async def settings_page(request: Request, db=Depends(get_db)):
|
|||||||
user = get_current_user(request)
|
user = get_current_user(request)
|
||||||
if not user:
|
if not user:
|
||||||
return RedirectResponse(url="/login")
|
return RedirectResponse(url="/login")
|
||||||
|
perms = get_user_perms(db, user)
|
||||||
|
if not can_view(perms, "settings"):
|
||||||
|
return RedirectResponse(url="/dashboard")
|
||||||
ctx = _build_context(db, user)
|
ctx = _build_context(db, user)
|
||||||
ctx["request"] = request
|
ctx["request"] = request
|
||||||
return templates.TemplateResponse("settings.html", ctx)
|
return templates.TemplateResponse("settings.html", ctx)
|
||||||
@ -146,6 +149,12 @@ async def settings_save(request: Request, section: str, db=Depends(get_db)):
|
|||||||
return RedirectResponse(url="/login")
|
return RedirectResponse(url="/login")
|
||||||
if section not in SECTIONS:
|
if section not in SECTIONS:
|
||||||
return HTMLResponse("<p>Section inconnue</p>", status_code=400)
|
return HTMLResponse("<p>Section inconnue</p>", status_code=400)
|
||||||
|
perms = get_user_perms(db, user)
|
||||||
|
if not can_edit(perms, "settings"):
|
||||||
|
return RedirectResponse(url="/settings")
|
||||||
|
role = user.get("role", "viewer")
|
||||||
|
if section in SECTION_ACCESS and role not in SECTION_ACCESS[section]["editable"]:
|
||||||
|
return RedirectResponse(url="/settings")
|
||||||
|
|
||||||
form = await request.form()
|
form = await request.form()
|
||||||
for key, label, is_secret in SECTIONS[section]:
|
for key, label, is_secret in SECTIONS[section]:
|
||||||
@ -174,6 +183,9 @@ async def vcenter_add(request: Request, db=Depends(get_db),
|
|||||||
user = get_current_user(request)
|
user = get_current_user(request)
|
||||||
if not user:
|
if not user:
|
||||||
return RedirectResponse(url="/login")
|
return RedirectResponse(url="/login")
|
||||||
|
perms = get_user_perms(db, user)
|
||||||
|
if not can_edit(perms, "settings"):
|
||||||
|
return RedirectResponse(url="/settings")
|
||||||
db.execute(text(
|
db.execute(text(
|
||||||
"INSERT INTO vcenters (name, endpoint, datacenter, description, responsable) VALUES (:n, :e, :dc, :desc, :resp)"
|
"INSERT INTO vcenters (name, endpoint, datacenter, description, responsable) VALUES (:n, :e, :dc, :desc, :resp)"
|
||||||
), {"n": vc_name, "e": vc_endpoint, "dc": vc_datacenter or None, "desc": vc_description or None, "resp": vc_responsable or None})
|
), {"n": vc_name, "e": vc_endpoint, "dc": vc_datacenter or None, "desc": vc_description or None, "resp": vc_responsable or None})
|
||||||
@ -188,6 +200,9 @@ async def vcenter_delete(request: Request, vc_id: int, db=Depends(get_db)):
|
|||||||
user = get_current_user(request)
|
user = get_current_user(request)
|
||||||
if not user:
|
if not user:
|
||||||
return RedirectResponse(url="/login")
|
return RedirectResponse(url="/login")
|
||||||
|
perms = get_user_perms(db, user)
|
||||||
|
if not can_edit(perms, "settings"):
|
||||||
|
return RedirectResponse(url="/settings")
|
||||||
db.execute(text("UPDATE vcenters SET is_active = false WHERE id = :id"), {"id": vc_id})
|
db.execute(text("UPDATE vcenters SET is_active = false WHERE id = :id"), {"id": vc_id})
|
||||||
db.commit()
|
db.commit()
|
||||||
ctx = _build_context(db, user, saved="vsphere")
|
ctx = _build_context(db, user, saved="vsphere")
|
||||||
@ -203,6 +218,9 @@ async def secret_update(request: Request, db=Depends(get_db),
|
|||||||
user = get_current_user(request)
|
user = get_current_user(request)
|
||||||
if not user:
|
if not user:
|
||||||
return RedirectResponse(url="/login")
|
return RedirectResponse(url="/login")
|
||||||
|
perms = get_user_perms(db, user)
|
||||||
|
if not can_edit(perms, "settings"):
|
||||||
|
return RedirectResponse(url="/settings")
|
||||||
if secret_value and secret_value != "********":
|
if secret_value and secret_value != "********":
|
||||||
# Recuperer la description existante
|
# Recuperer la description existante
|
||||||
existing = db.execute(text("SELECT description FROM app_secrets WHERE key = :k"),
|
existing = db.execute(text("SELECT description FROM app_secrets WHERE key = :k"),
|
||||||
@ -240,6 +258,9 @@ async def network_add(request: Request, db=Depends(get_db),
|
|||||||
user = get_current_user(request)
|
user = get_current_user(request)
|
||||||
if not user:
|
if not user:
|
||||||
return RedirectResponse(url="/login")
|
return RedirectResponse(url="/login")
|
||||||
|
perms = get_user_perms(db, user)
|
||||||
|
if not can_edit(perms, "settings"):
|
||||||
|
return RedirectResponse(url="/settings")
|
||||||
db.execute(text("INSERT INTO allowed_networks (cidr, description) VALUES (:c, :d)"),
|
db.execute(text("INSERT INTO allowed_networks (cidr, description) VALUES (:c, :d)"),
|
||||||
{"c": cidr.strip(), "d": description or None})
|
{"c": cidr.strip(), "d": description or None})
|
||||||
db.commit()
|
db.commit()
|
||||||
@ -254,6 +275,9 @@ async def network_delete(request: Request, net_id: int, db=Depends(get_db)):
|
|||||||
user = get_current_user(request)
|
user = get_current_user(request)
|
||||||
if not user:
|
if not user:
|
||||||
return RedirectResponse(url="/login")
|
return RedirectResponse(url="/login")
|
||||||
|
perms = get_user_perms(db, user)
|
||||||
|
if not can_edit(perms, "settings"):
|
||||||
|
return RedirectResponse(url="/settings")
|
||||||
db.execute(text("DELETE FROM allowed_networks WHERE id = :id"), {"id": net_id})
|
db.execute(text("DELETE FROM allowed_networks WHERE id = :id"), {"id": net_id})
|
||||||
db.commit()
|
db.commit()
|
||||||
_regen_nginx_acl(db)
|
_regen_nginx_acl(db)
|
||||||
@ -267,6 +291,9 @@ async def network_toggle(request: Request, net_id: int, db=Depends(get_db)):
|
|||||||
user = get_current_user(request)
|
user = get_current_user(request)
|
||||||
if not user:
|
if not user:
|
||||||
return RedirectResponse(url="/login")
|
return RedirectResponse(url="/login")
|
||||||
|
perms = get_user_perms(db, user)
|
||||||
|
if not can_edit(perms, "settings"):
|
||||||
|
return RedirectResponse(url="/settings")
|
||||||
db.execute(text("UPDATE allowed_networks SET is_active = NOT is_active WHERE id = :id"), {"id": net_id})
|
db.execute(text("UPDATE allowed_networks SET is_active = NOT is_active WHERE id = :id"), {"id": net_id})
|
||||||
db.commit()
|
db.commit()
|
||||||
_regen_nginx_acl(db)
|
_regen_nginx_acl(db)
|
||||||
|
|||||||
@ -47,6 +47,9 @@ async def specifics_list(request: Request, db=Depends(get_db),
|
|||||||
user = get_current_user(request)
|
user = get_current_user(request)
|
||||||
if not user:
|
if not user:
|
||||||
return RedirectResponse(url="/login")
|
return RedirectResponse(url="/login")
|
||||||
|
perms = get_user_perms(db, user)
|
||||||
|
if not can_view(perms, "specifics"):
|
||||||
|
return RedirectResponse(url="/dashboard")
|
||||||
entries = _list_specifics(db, app_type, search)
|
entries = _list_specifics(db, app_type, search)
|
||||||
# Types en base
|
# Types en base
|
||||||
types_in_db = db.execute(text(
|
types_in_db = db.execute(text(
|
||||||
@ -65,6 +68,9 @@ async def specific_edit(request: Request, spec_id: int, db=Depends(get_db)):
|
|||||||
user = get_current_user(request)
|
user = get_current_user(request)
|
||||||
if not user:
|
if not user:
|
||||||
return HTMLResponse("<p>Non autorise</p>")
|
return HTMLResponse("<p>Non autorise</p>")
|
||||||
|
perms = get_user_perms(db, user)
|
||||||
|
if not can_edit(perms, "specifics"):
|
||||||
|
return HTMLResponse("<p>Acces interdit</p>", status_code=403)
|
||||||
row = db.execute(text("""
|
row = db.execute(text("""
|
||||||
SELECT ss.*, s.hostname FROM server_specifics ss
|
SELECT ss.*, s.hostname FROM server_specifics ss
|
||||||
JOIN servers s ON ss.server_id = s.id WHERE ss.id = :id
|
JOIN servers s ON ss.server_id = s.id WHERE ss.id = :id
|
||||||
@ -81,6 +87,9 @@ async def specific_save(request: Request, spec_id: int, db=Depends(get_db)):
|
|||||||
user = get_current_user(request)
|
user = get_current_user(request)
|
||||||
if not user:
|
if not user:
|
||||||
return RedirectResponse(url="/login")
|
return RedirectResponse(url="/login")
|
||||||
|
perms = get_user_perms(db, user)
|
||||||
|
if not can_edit(perms, "specifics"):
|
||||||
|
return RedirectResponse(url="/specifics")
|
||||||
form = await request.form()
|
form = await request.form()
|
||||||
|
|
||||||
def val(k): v = form.get(k, ""); return v.strip() if v else None
|
def val(k): v = form.get(k, ""); return v.strip() if v else None
|
||||||
@ -147,6 +156,9 @@ async def specific_add(request: Request, db=Depends(get_db),
|
|||||||
user = get_current_user(request)
|
user = get_current_user(request)
|
||||||
if not user:
|
if not user:
|
||||||
return RedirectResponse(url="/login")
|
return RedirectResponse(url="/login")
|
||||||
|
perms = get_user_perms(db, user)
|
||||||
|
if not can_edit(perms, "specifics"):
|
||||||
|
return RedirectResponse(url="/specifics")
|
||||||
row = db.execute(text("SELECT id FROM servers WHERE LOWER(hostname) = LOWER(:h)"),
|
row = db.execute(text("SELECT id FROM servers WHERE LOWER(hostname) = LOWER(:h)"),
|
||||||
{"h": hostname.strip()}).fetchone()
|
{"h": hostname.strip()}).fetchone()
|
||||||
if not row:
|
if not row:
|
||||||
|
|||||||
73
app/services/quickwin_log_service.py
Normal file
73
app/services/quickwin_log_service.py
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
"""Service logs QuickWin — journalisation des actions et erreurs par campagne"""
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
|
||||||
|
def log_entry(db, run_id, step, level, message, detail=None,
|
||||||
|
entry_id=None, hostname=None, created_by=None):
|
||||||
|
"""Ajoute une ligne de log pour un run QuickWin.
|
||||||
|
level: info, warn, error, success"""
|
||||||
|
db.execute(text("""
|
||||||
|
INSERT INTO quickwin_logs (run_id, entry_id, hostname, step, level, message, detail, created_by)
|
||||||
|
VALUES (:rid, :eid, :host, :step, :lvl, :msg, :det, :by)
|
||||||
|
"""), {
|
||||||
|
"rid": run_id, "eid": entry_id, "host": hostname,
|
||||||
|
"step": step, "lvl": level, "msg": message,
|
||||||
|
"det": detail, "by": created_by,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def log_info(db, run_id, step, message, **kwargs):
|
||||||
|
log_entry(db, run_id, step, "info", message, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def log_warn(db, run_id, step, message, **kwargs):
|
||||||
|
log_entry(db, run_id, step, "warn", message, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def log_error(db, run_id, step, message, **kwargs):
|
||||||
|
log_entry(db, run_id, step, "error", message, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def log_success(db, run_id, step, message, **kwargs):
|
||||||
|
log_entry(db, run_id, step, "success", message, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def get_logs(db, run_id, level=None, step=None, hostname=None, limit=500):
|
||||||
|
"""Recupere les logs d'un run avec filtres optionnels."""
|
||||||
|
where = ["run_id = :rid"]
|
||||||
|
params = {"rid": run_id}
|
||||||
|
if level:
|
||||||
|
where.append("level = :lvl")
|
||||||
|
params["lvl"] = level
|
||||||
|
if step:
|
||||||
|
where.append("step = :step")
|
||||||
|
params["step"] = step
|
||||||
|
if hostname:
|
||||||
|
where.append("hostname ILIKE :host")
|
||||||
|
params["host"] = f"%{hostname}%"
|
||||||
|
params["lim"] = limit
|
||||||
|
return db.execute(text(f"""
|
||||||
|
SELECT * FROM quickwin_logs
|
||||||
|
WHERE {' AND '.join(where)}
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT :lim
|
||||||
|
"""), params).fetchall()
|
||||||
|
|
||||||
|
|
||||||
|
def get_log_stats(db, run_id):
|
||||||
|
"""Compteurs par level pour un run."""
|
||||||
|
return db.execute(text("""
|
||||||
|
SELECT level, COUNT(*) as cnt
|
||||||
|
FROM quickwin_logs WHERE run_id = :rid
|
||||||
|
GROUP BY level ORDER BY level
|
||||||
|
"""), {"rid": run_id}).fetchall()
|
||||||
|
|
||||||
|
|
||||||
|
def clear_logs(db, run_id, step=None):
|
||||||
|
"""Supprime les logs d'un run (optionnel: seulement une etape)."""
|
||||||
|
if step:
|
||||||
|
db.execute(text("DELETE FROM quickwin_logs WHERE run_id = :rid AND step = :step"),
|
||||||
|
{"rid": run_id, "step": step})
|
||||||
|
else:
|
||||||
|
db.execute(text("DELETE FROM quickwin_logs WHERE run_id = :rid"), {"rid": run_id})
|
||||||
|
db.commit()
|
||||||
387
app/services/quickwin_prereq_service.py
Normal file
387
app/services/quickwin_prereq_service.py
Normal file
@ -0,0 +1,387 @@
|
|||||||
|
"""Service prereq QuickWin — resolution DNS, SSH, disque, satellite
|
||||||
|
Adapte au contexte SANEF : PSMP CyberArk + SSH key selon methode serveur"""
|
||||||
|
import socket
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
|
log = logging.getLogger("quickwin.prereq")
|
||||||
|
|
||||||
|
try:
|
||||||
|
import paramiko
|
||||||
|
PARAMIKO_OK = True
|
||||||
|
except ImportError:
|
||||||
|
PARAMIKO_OK = False
|
||||||
|
log.warning("paramiko non disponible — checks SSH impossibles")
|
||||||
|
|
||||||
|
# --- Constantes ---
|
||||||
|
DOMP = "sanef.groupe" # domaine prod/preprod/dev
|
||||||
|
DOMR = "sanef-rec.fr" # domaine recette/test
|
||||||
|
|
||||||
|
PSMP_HOST = "psmp.sanef.fr"
|
||||||
|
CYBR_USER = "CYBP01336"
|
||||||
|
TARGET_USER = "cybsecope"
|
||||||
|
|
||||||
|
SSH_KEY_FILE_DEFAULT = "/opt/patchcenter/keys/id_rsa_cybglobal.pem"
|
||||||
|
SSH_TIMEOUT = 15
|
||||||
|
|
||||||
|
# Seuils disque (% utilise)
|
||||||
|
DISK_MAX_PCT = 90 # >90% = KO
|
||||||
|
|
||||||
|
# Banniere CyberArk a filtrer
|
||||||
|
BANNER_FILTERS = [
|
||||||
|
"GROUPE SANEF", "propriete du Groupe", "accederait", "emprisonnement",
|
||||||
|
"Article 323", "code penal", "Authorized uses only", "CyberArk",
|
||||||
|
"This session", "session is being",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _get_settings(db):
|
||||||
|
"""Charge les settings utiles depuis la table settings"""
|
||||||
|
from sqlalchemy import text
|
||||||
|
rows = db.execute(text(
|
||||||
|
"SELECT key, value FROM settings WHERE key IN "
|
||||||
|
"('psmp_host','default_ssh_timeout','disk_min_root_mb')"
|
||||||
|
)).fetchall()
|
||||||
|
return {r.key: r.value for r in rows}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_secret(db, key):
|
||||||
|
"""Recupere un secret dechiffre depuis app_secrets"""
|
||||||
|
try:
|
||||||
|
from ..services.secrets_service import get_secret
|
||||||
|
return get_secret(db, key)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# =========================================================
|
||||||
|
# 1. RESOLUTION DNS
|
||||||
|
# =========================================================
|
||||||
|
|
||||||
|
def _detect_env(hostname):
|
||||||
|
"""Detecte l'environnement par la 2e lettre du hostname (convention SANEF)
|
||||||
|
p=prod, i=preprod, r=recette, v/t=test, d=dev"""
|
||||||
|
if len(hostname) < 2:
|
||||||
|
return "unknown"
|
||||||
|
c = hostname[1].lower()
|
||||||
|
if c == "p":
|
||||||
|
return "prod"
|
||||||
|
elif c == "i":
|
||||||
|
return "preprod"
|
||||||
|
elif c == "r":
|
||||||
|
return "recette"
|
||||||
|
elif c in ("v", "t"):
|
||||||
|
return "test"
|
||||||
|
elif c == "d":
|
||||||
|
return "dev"
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_fqdn(hostname, domain_ltd=None, env_code=None):
|
||||||
|
"""Resout le hostname en FQDN testable.
|
||||||
|
Retourne (fqdn, None) ou (None, error_msg).
|
||||||
|
|
||||||
|
Logique:
|
||||||
|
- Prod/Preprod/Dev: domp d'abord, puis domr
|
||||||
|
- Recette/Test: domr d'abord, puis domp
|
||||||
|
- Utilise domain_ltd si dispo, sinon detection par hostname
|
||||||
|
"""
|
||||||
|
if "." in hostname:
|
||||||
|
# Deja un FQDN
|
||||||
|
if _dns_resolves(hostname):
|
||||||
|
return hostname, None
|
||||||
|
return None, f"FQDN {hostname} non resolvable"
|
||||||
|
|
||||||
|
# Determiner l'ordre des domaines
|
||||||
|
env = _detect_env(hostname)
|
||||||
|
if env_code:
|
||||||
|
ec = env_code.upper()
|
||||||
|
if ec in ("PRD", "PPR", "DEV"):
|
||||||
|
env = "prod"
|
||||||
|
elif ec in ("REC",):
|
||||||
|
env = "recette"
|
||||||
|
elif ec in ("TES", "TS1", "TS2"):
|
||||||
|
env = "test"
|
||||||
|
|
||||||
|
if env in ("prod", "preprod", "dev"):
|
||||||
|
domains_order = [DOMP, DOMR]
|
||||||
|
elif env in ("recette", "test"):
|
||||||
|
domains_order = [DOMR, DOMP]
|
||||||
|
else:
|
||||||
|
# Fallback: utiliser domain_ltd si dispo
|
||||||
|
if domain_ltd and domain_ltd.strip():
|
||||||
|
alt = DOMR if domain_ltd.strip() == DOMP else DOMP
|
||||||
|
domains_order = [domain_ltd.strip(), alt]
|
||||||
|
else:
|
||||||
|
domains_order = [DOMP, DOMR]
|
||||||
|
|
||||||
|
# Tenter resolution dans l'ordre
|
||||||
|
for dom in domains_order:
|
||||||
|
fqdn = f"{hostname}.{dom}"
|
||||||
|
if _dns_resolves(fqdn):
|
||||||
|
return fqdn, None
|
||||||
|
|
||||||
|
return None, f"DNS KO: {hostname} non resolu ({'/'.join(domains_order)})"
|
||||||
|
|
||||||
|
|
||||||
|
def _dns_resolves(fqdn):
|
||||||
|
"""Verifie si un FQDN se resout en IP"""
|
||||||
|
try:
|
||||||
|
socket.getaddrinfo(fqdn, 22, socket.AF_INET, socket.SOCK_STREAM)
|
||||||
|
return True
|
||||||
|
except (socket.gaierror, socket.herror, OSError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# =========================================================
|
||||||
|
# 2. TEST SSH
|
||||||
|
# =========================================================
|
||||||
|
|
||||||
|
def _get_ssh_key_path(db=None):
|
||||||
|
"""Retourne le chemin de la cle SSH. Cherche d'abord dans app_secrets (ssh_key_file),
|
||||||
|
puis fallback sur le chemin par defaut."""
|
||||||
|
if db:
|
||||||
|
secret_path = _get_secret(db, "ssh_key_file")
|
||||||
|
if secret_path and secret_path.strip() and os.path.exists(secret_path.strip()):
|
||||||
|
return secret_path.strip()
|
||||||
|
if os.path.exists(SSH_KEY_FILE_DEFAULT):
|
||||||
|
return SSH_KEY_FILE_DEFAULT
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _load_ssh_key(db=None):
|
||||||
|
"""Charge la cle SSH privee depuis le chemin configure en base ou par defaut"""
|
||||||
|
key_path = _get_ssh_key_path(db)
|
||||||
|
if not key_path:
|
||||||
|
return None
|
||||||
|
for cls in [paramiko.RSAKey, paramiko.Ed25519Key, paramiko.ECDSAKey]:
|
||||||
|
try:
|
||||||
|
return cls.from_private_key_file(key_path)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _ssh_via_psmp(fqdn, password):
|
||||||
|
"""Connexion SSH via PSMP CyberArk (interactive auth).
|
||||||
|
Username format: CYBP01336@cybsecope@fqdn"""
|
||||||
|
if not password:
|
||||||
|
return None, "PSMP: pas de mot de passe configure"
|
||||||
|
try:
|
||||||
|
username = f"{CYBR_USER}@{TARGET_USER}@{fqdn}"
|
||||||
|
transport = paramiko.Transport((PSMP_HOST, 22))
|
||||||
|
transport.connect()
|
||||||
|
|
||||||
|
def handler(title, instructions, prompt_list):
|
||||||
|
return [password] * len(prompt_list)
|
||||||
|
|
||||||
|
transport.auth_interactive(username, handler)
|
||||||
|
client = paramiko.SSHClient()
|
||||||
|
client._transport = transport
|
||||||
|
return client, None
|
||||||
|
except Exception as e:
|
||||||
|
return None, f"PSMP: {str(e)[:120]}"
|
||||||
|
|
||||||
|
|
||||||
|
def _ssh_via_key(fqdn, ssh_user, key):
|
||||||
|
"""Connexion SSH directe par cle"""
|
||||||
|
if not key:
|
||||||
|
return None, "SSH key: cle non trouvee"
|
||||||
|
if not ssh_user:
|
||||||
|
return None, "SSH key: user non configure"
|
||||||
|
try:
|
||||||
|
client = paramiko.SSHClient()
|
||||||
|
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||||
|
client.connect(fqdn, port=22, username=ssh_user, pkey=key,
|
||||||
|
timeout=SSH_TIMEOUT, look_for_keys=False, allow_agent=False)
|
||||||
|
return client, None
|
||||||
|
except Exception as e:
|
||||||
|
return None, f"SSH key: {str(e)[:120]}"
|
||||||
|
|
||||||
|
|
||||||
|
def _ssh_connect(fqdn, ssh_method, db):
|
||||||
|
"""Connecte au serveur selon la methode (ssh_psmp ou ssh_key).
|
||||||
|
Retourne (client, error_msg)"""
|
||||||
|
if not PARAMIKO_OK:
|
||||||
|
return None, "paramiko non installe"
|
||||||
|
|
||||||
|
if ssh_method == "ssh_psmp":
|
||||||
|
# PSMP: password depuis app_secrets
|
||||||
|
password = _get_secret(db, "ssh_pwd_default_pass")
|
||||||
|
client, err = _ssh_via_psmp(fqdn, password)
|
||||||
|
if client:
|
||||||
|
return client, None
|
||||||
|
# Fallback: tenter par cle
|
||||||
|
key = _load_ssh_key(db)
|
||||||
|
ssh_user = _get_secret(db, "ssh_pwd_default_user") or TARGET_USER
|
||||||
|
client2, err2 = _ssh_via_key(fqdn, ssh_user, key)
|
||||||
|
if client2:
|
||||||
|
return client2, None
|
||||||
|
return None, err # retourner l'erreur PSMP originale
|
||||||
|
else:
|
||||||
|
# ssh_key: user depuis secrets, cle depuis fichier
|
||||||
|
ssh_user = _get_secret(db, "ssh_pwd_default_user") or TARGET_USER
|
||||||
|
key = _load_ssh_key(db)
|
||||||
|
client, err = _ssh_via_key(fqdn, ssh_user, key)
|
||||||
|
if client:
|
||||||
|
return client, None
|
||||||
|
# Fallback: tenter via PSMP
|
||||||
|
password = _get_secret(db, "ssh_pwd_default_pass")
|
||||||
|
if password:
|
||||||
|
client2, err2 = _ssh_via_psmp(fqdn, password)
|
||||||
|
if client2:
|
||||||
|
return client2, None
|
||||||
|
return None, err
|
||||||
|
|
||||||
|
|
||||||
|
def _ssh_exec(client, cmd, timeout=12):
|
||||||
|
"""Execute une commande via SSH et retourne (stdout, stderr, returncode).
|
||||||
|
Filtre les bannieres CyberArk."""
|
||||||
|
try:
|
||||||
|
chan = client._transport.open_session()
|
||||||
|
chan.settimeout(timeout)
|
||||||
|
chan.exec_command(cmd)
|
||||||
|
out = b""
|
||||||
|
err = b""
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
chunk = chan.recv(8192)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
out += chunk
|
||||||
|
except Exception:
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
err = chan.recv_stderr(8192)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
rc = chan.recv_exit_status()
|
||||||
|
chan.close()
|
||||||
|
# Filtrer bannieres
|
||||||
|
out_str = out.decode("utf-8", errors="replace")
|
||||||
|
lines = [l for l in out_str.splitlines() if not any(b in l for b in BANNER_FILTERS)]
|
||||||
|
return "\n".join(lines), err.decode("utf-8", errors="replace"), rc
|
||||||
|
except Exception as e:
|
||||||
|
return "", str(e), -1
|
||||||
|
|
||||||
|
|
||||||
|
# =========================================================
|
||||||
|
# 3. TEST ESPACE DISQUE
|
||||||
|
# =========================================================
|
||||||
|
|
||||||
|
def _check_disk(client):
|
||||||
|
"""Verifie l'espace disque / et /var via sudo df.
|
||||||
|
Retourne (ok, detail_msg)"""
|
||||||
|
out, err, rc = _ssh_exec(client, "sudo df / /var --output=target,pcent 2>/dev/null | tail -n +2")
|
||||||
|
if rc != 0 or not out.strip():
|
||||||
|
return True, "Disque: non verifie (df echoue)"
|
||||||
|
|
||||||
|
ok = True
|
||||||
|
parts = []
|
||||||
|
for line in out.strip().splitlines():
|
||||||
|
tokens = line.split()
|
||||||
|
if len(tokens) >= 2:
|
||||||
|
mount = tokens[0]
|
||||||
|
pct_str = tokens[-1].replace("%", "").strip()
|
||||||
|
if pct_str.isdigit():
|
||||||
|
pct = int(pct_str)
|
||||||
|
if pct >= DISK_MAX_PCT:
|
||||||
|
ok = False
|
||||||
|
parts.append(f"{mount}={pct}% KO")
|
||||||
|
else:
|
||||||
|
parts.append(f"{mount}={pct}%")
|
||||||
|
|
||||||
|
if not parts:
|
||||||
|
return True, "Disque: non verifie"
|
||||||
|
return ok, "Disque: " + ", ".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
# =========================================================
|
||||||
|
# 4. TEST SATELLITE / YUM REPOS
|
||||||
|
# =========================================================
|
||||||
|
|
||||||
|
def _check_satellite(client):
|
||||||
|
"""Verifie l'enregistrement Satellite et les repos YUM.
|
||||||
|
Retourne (ok, detail_msg)"""
|
||||||
|
# Tenter subscription-manager d'abord
|
||||||
|
out, err, rc = _ssh_exec(client, "sudo subscription-manager status 2>/dev/null | head -5")
|
||||||
|
if rc == 0 and "Current" in out:
|
||||||
|
return True, "Satellite: OK (subscription-manager)"
|
||||||
|
|
||||||
|
# Fallback: yum repolist
|
||||||
|
out2, err2, rc2 = _ssh_exec(client, "sudo yum repolist 2>/dev/null | tail -1")
|
||||||
|
if rc2 == 0 and out2.strip():
|
||||||
|
line = out2.strip()
|
||||||
|
# Si "repolist: 0" => pas de repos
|
||||||
|
if "repolist: 0" in line.lower():
|
||||||
|
return False, "Satellite: KO (0 repos)"
|
||||||
|
return True, f"Satellite: OK ({line[:60]})"
|
||||||
|
|
||||||
|
return False, "Satellite: KO (pas de repos ni subscription)"
|
||||||
|
|
||||||
|
|
||||||
|
# =========================================================
|
||||||
|
# ORCHESTRATEUR PRINCIPAL
|
||||||
|
# =========================================================
|
||||||
|
|
||||||
|
def check_server_prereqs(hostname, db, domain_ltd=None, env_code=None,
|
||||||
|
ssh_method="ssh_key"):
|
||||||
|
"""Verification complete des prerequis d'un serveur QuickWin.
|
||||||
|
|
||||||
|
Etapes:
|
||||||
|
1. Resolution DNS (domp/domr selon env)
|
||||||
|
2. Test SSH (PSMP ou key selon ssh_method)
|
||||||
|
3. Espace disque (sudo df)
|
||||||
|
4. Satellite/YUM
|
||||||
|
|
||||||
|
Retourne dict:
|
||||||
|
dns_ok, ssh_ok, disk_ok, satellite_ok,
|
||||||
|
fqdn, detail, skip (True si serveur a ignorer)
|
||||||
|
"""
|
||||||
|
result = {
|
||||||
|
"dns_ok": False, "ssh_ok": False, "disk_ok": False, "satellite_ok": False,
|
||||||
|
"fqdn": "", "detail": "", "skip": False,
|
||||||
|
}
|
||||||
|
detail_parts = []
|
||||||
|
|
||||||
|
# 1. Resolution DNS
|
||||||
|
fqdn, dns_err = _resolve_fqdn(hostname, domain_ltd, env_code)
|
||||||
|
if not fqdn:
|
||||||
|
result["detail"] = dns_err
|
||||||
|
result["skip"] = True
|
||||||
|
log.warning(f"[{hostname}] {dns_err}")
|
||||||
|
return result
|
||||||
|
result["dns_ok"] = True
|
||||||
|
result["fqdn"] = fqdn
|
||||||
|
detail_parts.append(f"DNS: OK ({fqdn})")
|
||||||
|
|
||||||
|
# 2. Test SSH
|
||||||
|
client, ssh_err = _ssh_connect(fqdn, ssh_method, db)
|
||||||
|
if not client:
|
||||||
|
detail_parts.append(f"SSH: KO ({ssh_err})")
|
||||||
|
result["detail"] = " | ".join(detail_parts)
|
||||||
|
result["skip"] = True
|
||||||
|
log.warning(f"[{hostname}] SSH KO: {ssh_err}")
|
||||||
|
return result
|
||||||
|
result["ssh_ok"] = True
|
||||||
|
method_label = "PSMP" if ssh_method == "ssh_psmp" else "key"
|
||||||
|
detail_parts.append(f"SSH: OK ({method_label})")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 3. Espace disque
|
||||||
|
disk_ok, disk_detail = _check_disk(client)
|
||||||
|
result["disk_ok"] = disk_ok
|
||||||
|
detail_parts.append(disk_detail)
|
||||||
|
|
||||||
|
# 4. Satellite
|
||||||
|
sat_ok, sat_detail = _check_satellite(client)
|
||||||
|
result["satellite_ok"] = sat_ok
|
||||||
|
detail_parts.append(sat_detail)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
client.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
result["detail"] = " | ".join(detail_parts)
|
||||||
|
return result
|
||||||
788
app/services/quickwin_service.py
Normal file
788
app/services/quickwin_service.py
Normal file
@ -0,0 +1,788 @@
|
|||||||
|
"""Service QuickWin — gestion des campagnes + exclusions par serveur"""
|
||||||
|
import json
|
||||||
|
from sqlalchemy import text
|
||||||
|
from .quickwin_log_service import log_info, log_warn, log_error, log_success
|
||||||
|
|
||||||
|
# Exclusions generales par defaut (reboot packages + middleware/apps)
|
||||||
|
DEFAULT_GENERAL_EXCLUDES = (
|
||||||
|
"dbus* dracut* glibc* grub2* kernel* kexec-tools* "
|
||||||
|
"libselinux* linux-firmware* microcode_ctl* mokutil* "
|
||||||
|
"net-snmp* NetworkManager* network-scripts* nss* openssl-libs* "
|
||||||
|
"polkit* selinux-policy* shim* systemd* tuned*"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_server_configs(db, server_ids=None):
|
||||||
|
"""Retourne les configs QuickWin pour les serveurs (ou tous)"""
|
||||||
|
if server_ids:
|
||||||
|
rows = db.execute(text("""
|
||||||
|
SELECT qc.*, s.hostname, s.os_family, s.tier,
|
||||||
|
d.name as domaine, e.name as environnement,
|
||||||
|
z.name as zone
|
||||||
|
FROM quickwin_server_config qc
|
||||||
|
JOIN servers s ON qc.server_id = s.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 environments e ON de.environment_id = e.id
|
||||||
|
LEFT JOIN zones z ON s.zone_id = z.id
|
||||||
|
WHERE qc.server_id = ANY(:ids)
|
||||||
|
ORDER BY s.hostname
|
||||||
|
"""), {"ids": server_ids}).fetchall()
|
||||||
|
else:
|
||||||
|
rows = db.execute(text("""
|
||||||
|
SELECT qc.*, s.hostname, s.os_family, s.tier,
|
||||||
|
d.name as domaine, e.name as environnement,
|
||||||
|
z.name as zone
|
||||||
|
FROM quickwin_server_config qc
|
||||||
|
JOIN servers s ON qc.server_id = s.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 environments e ON de.environment_id = e.id
|
||||||
|
LEFT JOIN zones z ON s.zone_id = z.id
|
||||||
|
ORDER BY s.hostname
|
||||||
|
""")).fetchall()
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def upsert_server_config(db, server_id, general_excludes=None, specific_excludes="", notes=""):
|
||||||
|
"""Cree ou met a jour la config QuickWin d'un serveur.
|
||||||
|
Si general_excludes est vide lors de la creation, applique DEFAULT_GENERAL_EXCLUDES."""
|
||||||
|
existing = db.execute(text(
|
||||||
|
"SELECT id FROM quickwin_server_config WHERE server_id = :sid"
|
||||||
|
), {"sid": server_id}).fetchone()
|
||||||
|
if existing:
|
||||||
|
ge = general_excludes if general_excludes is not None else DEFAULT_GENERAL_EXCLUDES
|
||||||
|
db.execute(text("""
|
||||||
|
UPDATE quickwin_server_config
|
||||||
|
SET general_excludes = :ge, specific_excludes = :se, notes = :n, updated_at = now()
|
||||||
|
WHERE server_id = :sid
|
||||||
|
"""), {"sid": server_id, "ge": ge, "se": specific_excludes, "n": notes})
|
||||||
|
else:
|
||||||
|
ge = general_excludes if general_excludes else DEFAULT_GENERAL_EXCLUDES
|
||||||
|
db.execute(text("""
|
||||||
|
INSERT INTO quickwin_server_config (server_id, general_excludes, specific_excludes, notes)
|
||||||
|
VALUES (:sid, :ge, :se, :n)
|
||||||
|
"""), {"sid": server_id, "ge": ge, "se": specific_excludes, "n": notes})
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def delete_server_config(db, config_id):
|
||||||
|
db.execute(text("DELETE FROM quickwin_server_config WHERE id = :id"), {"id": config_id})
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def get_eligible_servers(db):
|
||||||
|
"""Serveurs Linux en_production, patch_os_owner=secops"""
|
||||||
|
return db.execute(text("""
|
||||||
|
SELECT s.id, s.hostname, s.os_family, s.os_version, s.machine_type,
|
||||||
|
s.tier, s.etat, s.patch_excludes, s.is_flux_libre, s.is_podman,
|
||||||
|
d.name as domaine, d.code as domain_code,
|
||||||
|
e.name as environnement, e.code as env_code,
|
||||||
|
COALESCE(qc.general_excludes, '') as qw_general_excludes,
|
||||||
|
COALESCE(qc.specific_excludes, '') as qw_specific_excludes
|
||||||
|
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 quickwin_server_config qc ON qc.server_id = s.id
|
||||||
|
WHERE s.os_family = 'linux'
|
||||||
|
AND s.etat = 'en_production'
|
||||||
|
AND s.patch_os_owner = 'secops'
|
||||||
|
ORDER BY e.display_order, d.display_order, s.hostname
|
||||||
|
""")).fetchall()
|
||||||
|
|
||||||
|
|
||||||
|
# -- Runs --
|
||||||
|
|
||||||
|
def list_runs(db):
|
||||||
|
return db.execute(text("""
|
||||||
|
SELECT r.*,
|
||||||
|
u.display_name as created_by_name,
|
||||||
|
(SELECT COUNT(*) FROM quickwin_entries e WHERE e.run_id = r.id) as total_entries,
|
||||||
|
(SELECT COUNT(*) FROM quickwin_entries e WHERE e.run_id = r.id AND e.status = 'patched') as patched_count,
|
||||||
|
(SELECT COUNT(*) FROM quickwin_entries e WHERE e.run_id = r.id AND e.status = 'failed') as failed_count,
|
||||||
|
(SELECT COUNT(*) FROM quickwin_entries e WHERE e.run_id = r.id AND e.branch = 'hprod') as hprod_count,
|
||||||
|
(SELECT COUNT(*) FROM quickwin_entries e WHERE e.run_id = r.id AND e.branch = 'prod') as prod_count
|
||||||
|
FROM quickwin_runs r
|
||||||
|
LEFT JOIN users u ON r.created_by = u.id
|
||||||
|
ORDER BY r.year DESC, r.week_number DESC, r.id DESC
|
||||||
|
""")).fetchall()
|
||||||
|
|
||||||
|
|
||||||
|
def get_run(db, run_id):
|
||||||
|
return db.execute(text("""
|
||||||
|
SELECT r.*, u.display_name as created_by_name
|
||||||
|
FROM quickwin_runs r LEFT JOIN users u ON r.created_by = u.id
|
||||||
|
WHERE r.id = :id
|
||||||
|
"""), {"id": run_id}).fetchone()
|
||||||
|
|
||||||
|
|
||||||
|
def get_run_entries(db, run_id):
|
||||||
|
return db.execute(text("""
|
||||||
|
SELECT qe.*, s.hostname, s.fqdn, s.os_family, s.machine_type,
|
||||||
|
d.name as domaine, e.name as environnement
|
||||||
|
FROM quickwin_entries qe
|
||||||
|
JOIN servers s ON qe.server_id = s.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 environments e ON de.environment_id = e.id
|
||||||
|
WHERE qe.run_id = :rid
|
||||||
|
ORDER BY qe.branch, s.hostname
|
||||||
|
"""), {"rid": run_id}).fetchall()
|
||||||
|
|
||||||
|
|
||||||
|
def create_run(db, year, week_number, label, user_id, server_ids, notes=""):
|
||||||
|
"""Cree un run QuickWin avec les serveurs selectionnes.
|
||||||
|
Classe auto en hprod/prod selon l'environnement du serveur."""
|
||||||
|
row = db.execute(text("""
|
||||||
|
INSERT INTO quickwin_runs (year, week_number, label, created_by, notes)
|
||||||
|
VALUES (:y, :w, :l, :uid, :n) RETURNING id
|
||||||
|
"""), {"y": year, "w": week_number, "l": label, "uid": user_id, "n": notes}).fetchone()
|
||||||
|
run_id = row.id
|
||||||
|
|
||||||
|
for sid in server_ids:
|
||||||
|
srv = db.execute(text("""
|
||||||
|
SELECT s.id, e.name as env_name,
|
||||||
|
COALESCE(qc.general_excludes, '') as ge,
|
||||||
|
COALESCE(qc.specific_excludes, '') as se
|
||||||
|
FROM servers s
|
||||||
|
LEFT JOIN domain_environments de ON s.domain_env_id = de.id
|
||||||
|
LEFT JOIN environments e ON de.environment_id = e.id
|
||||||
|
LEFT JOIN quickwin_server_config qc ON qc.server_id = s.id
|
||||||
|
WHERE s.id = :sid
|
||||||
|
"""), {"sid": sid}).fetchone()
|
||||||
|
if not srv:
|
||||||
|
continue
|
||||||
|
branch = "prod" if srv.env_name and "production" in srv.env_name.lower() else "hprod"
|
||||||
|
ge = srv.ge if srv.ge else DEFAULT_GENERAL_EXCLUDES
|
||||||
|
db.execute(text("""
|
||||||
|
INSERT INTO quickwin_entries (run_id, server_id, branch, general_excludes, specific_excludes)
|
||||||
|
VALUES (:rid, :sid, :br, :ge, :se)
|
||||||
|
"""), {"rid": run_id, "sid": sid, "br": branch, "ge": ge, "se": srv.se})
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return run_id
|
||||||
|
|
||||||
|
|
||||||
|
def delete_run(db, run_id):
|
||||||
|
db.execute(text("DELETE FROM quickwin_entries WHERE run_id = :rid"), {"rid": run_id})
|
||||||
|
db.execute(text("DELETE FROM quickwin_runs WHERE id = :rid"), {"rid": run_id})
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def get_available_servers(db, run_id, search="", domains=None, envs=None, zones=None):
|
||||||
|
"""Serveurs eligibles PAS encore dans ce run, avec multi-filtres.
|
||||||
|
domains/envs/zones: listes de noms (multi-select)."""
|
||||||
|
rows = db.execute(text("""
|
||||||
|
SELECT s.id, s.hostname, s.os_family, s.machine_type, s.domain_ltd,
|
||||||
|
d.name as domaine, e.name as environnement, e.code as env_code,
|
||||||
|
z.name as zone
|
||||||
|
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 zones z ON s.zone_id = z.id
|
||||||
|
WHERE s.os_family = 'linux'
|
||||||
|
AND s.etat = 'en_production'
|
||||||
|
AND s.patch_os_owner = 'secops'
|
||||||
|
AND s.id NOT IN (SELECT server_id FROM quickwin_entries WHERE run_id = :rid)
|
||||||
|
ORDER BY d.name, e.name, s.hostname
|
||||||
|
"""), {"rid": run_id}).fetchall()
|
||||||
|
filtered = rows
|
||||||
|
if search:
|
||||||
|
filtered = [r for r in filtered if search.lower() in r.hostname.lower()]
|
||||||
|
if domains:
|
||||||
|
filtered = [r for r in filtered if (r.domaine or '') in domains]
|
||||||
|
if envs:
|
||||||
|
filtered = [r for r in filtered if (r.environnement or '') in envs]
|
||||||
|
if zones:
|
||||||
|
filtered = [r for r in filtered if (r.zone or '') in zones]
|
||||||
|
return filtered
|
||||||
|
|
||||||
|
|
||||||
|
def get_available_filters(db, run_id):
|
||||||
|
"""Retourne les domaines, envs et zones disponibles (avec compteurs)."""
|
||||||
|
rows = db.execute(text("""
|
||||||
|
SELECT d.name as domaine, e.name as environnement, z.name as zone
|
||||||
|
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 zones z ON s.zone_id = z.id
|
||||||
|
WHERE s.os_family = 'linux'
|
||||||
|
AND s.etat = 'en_production'
|
||||||
|
AND s.patch_os_owner = 'secops'
|
||||||
|
AND s.id NOT IN (SELECT server_id FROM quickwin_entries WHERE run_id = :rid)
|
||||||
|
"""), {"rid": run_id}).fetchall()
|
||||||
|
domains = sorted(set(r.domaine for r in rows if r.domaine))
|
||||||
|
envs = sorted(set(r.environnement for r in rows if r.environnement))
|
||||||
|
zones = sorted(set(r.zone for r in rows if r.zone))
|
||||||
|
# Compteurs
|
||||||
|
dom_counts = {}
|
||||||
|
for r in rows:
|
||||||
|
k = r.domaine or '?'
|
||||||
|
dom_counts[k] = dom_counts.get(k, 0) + 1
|
||||||
|
env_counts = {}
|
||||||
|
for r in rows:
|
||||||
|
k = r.environnement or '?'
|
||||||
|
env_counts[k] = env_counts.get(k, 0) + 1
|
||||||
|
zone_counts = {}
|
||||||
|
for r in rows:
|
||||||
|
k = r.zone or '?'
|
||||||
|
zone_counts[k] = zone_counts.get(k, 0) + 1
|
||||||
|
return domains, envs, zones, dom_counts, env_counts, zone_counts
|
||||||
|
|
||||||
|
|
||||||
|
def add_entries_to_run(db, run_id, server_ids, user=None):
|
||||||
|
"""Ajoute des serveurs a un run existant. Determine auto hprod/prod.
|
||||||
|
Retourne le nombre de serveurs ajoutes."""
|
||||||
|
existing = set(r.server_id for r in db.execute(text(
|
||||||
|
"SELECT server_id FROM quickwin_entries WHERE run_id = :rid"
|
||||||
|
), {"rid": run_id}).fetchall())
|
||||||
|
|
||||||
|
by = user.get("display_name", user.get("username", "")) if user else ""
|
||||||
|
added = 0
|
||||||
|
hostnames = []
|
||||||
|
for sid in server_ids:
|
||||||
|
if sid in existing:
|
||||||
|
continue
|
||||||
|
srv = db.execute(text("""
|
||||||
|
SELECT s.id, s.hostname, e.name as env_name,
|
||||||
|
COALESCE(qc.general_excludes, '') as ge,
|
||||||
|
COALESCE(qc.specific_excludes, '') as se
|
||||||
|
FROM servers s
|
||||||
|
LEFT JOIN domain_environments de ON s.domain_env_id = de.id
|
||||||
|
LEFT JOIN environments e ON de.environment_id = e.id
|
||||||
|
LEFT JOIN quickwin_server_config qc ON qc.server_id = s.id
|
||||||
|
WHERE s.id = :sid
|
||||||
|
"""), {"sid": sid}).fetchone()
|
||||||
|
if not srv:
|
||||||
|
continue
|
||||||
|
branch = "prod" if srv.env_name and "production" in srv.env_name.lower() else "hprod"
|
||||||
|
ge = srv.ge if srv.ge else DEFAULT_GENERAL_EXCLUDES
|
||||||
|
db.execute(text("""
|
||||||
|
INSERT INTO quickwin_entries (run_id, server_id, branch, general_excludes, specific_excludes)
|
||||||
|
VALUES (:rid, :sid, :br, :ge, :se)
|
||||||
|
"""), {"rid": run_id, "sid": sid, "br": branch, "ge": ge, "se": srv.se})
|
||||||
|
added += 1
|
||||||
|
hostnames.append(srv.hostname)
|
||||||
|
if added:
|
||||||
|
log_info(db, run_id, "servers", f"{added} serveur(s) ajoute(s)",
|
||||||
|
detail=", ".join(hostnames[:20]) + ("..." if len(hostnames) > 20 else ""),
|
||||||
|
created_by=by)
|
||||||
|
db.commit()
|
||||||
|
return added
|
||||||
|
|
||||||
|
|
||||||
|
def remove_entries_from_run(db, run_id, entry_ids, user=None):
|
||||||
|
"""Supprime des entries d'un run. Retourne le nombre de suppressions."""
|
||||||
|
if not entry_ids:
|
||||||
|
return 0
|
||||||
|
by = user.get("display_name", user.get("username", "")) if user else ""
|
||||||
|
# Log hostnames avant suppression
|
||||||
|
rows = db.execute(text("""
|
||||||
|
SELECT s.hostname FROM quickwin_entries qe
|
||||||
|
JOIN servers s ON qe.server_id = s.id
|
||||||
|
WHERE qe.run_id = :rid AND qe.id = ANY(:ids)
|
||||||
|
"""), {"rid": run_id, "ids": entry_ids}).fetchall()
|
||||||
|
hostnames = [r.hostname for r in rows]
|
||||||
|
result = db.execute(text(
|
||||||
|
"DELETE FROM quickwin_entries WHERE run_id = :rid AND id = ANY(:ids)"
|
||||||
|
), {"rid": run_id, "ids": entry_ids})
|
||||||
|
removed = result.rowcount
|
||||||
|
if removed:
|
||||||
|
log_warn(db, run_id, "servers", f"{removed} serveur(s) supprime(s)",
|
||||||
|
detail=", ".join(hostnames[:20]) + ("..." if len(hostnames) > 20 else ""),
|
||||||
|
created_by=by)
|
||||||
|
db.commit()
|
||||||
|
return removed
|
||||||
|
|
||||||
|
|
||||||
|
def get_campaign_scope(db, run_id):
|
||||||
|
"""Retourne domaines, envs et zones presents dans le run avec compteurs."""
|
||||||
|
rows = db.execute(text("""
|
||||||
|
SELECT d.name as domaine, e.name as environnement, z.name as zone, qe.status
|
||||||
|
FROM quickwin_entries qe
|
||||||
|
JOIN servers s ON qe.server_id = s.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 environments e ON de.environment_id = e.id
|
||||||
|
LEFT JOIN zones z ON s.zone_id = z.id
|
||||||
|
WHERE qe.run_id = :rid
|
||||||
|
"""), {"rid": run_id}).fetchall()
|
||||||
|
dom_counts = {}
|
||||||
|
env_counts = {}
|
||||||
|
zone_counts = {}
|
||||||
|
dom_active = {} # non-excluded count
|
||||||
|
zone_active = {} # non-excluded count
|
||||||
|
for r in rows:
|
||||||
|
d = r.domaine or '?'
|
||||||
|
e = r.environnement or '?'
|
||||||
|
z = r.zone or '?'
|
||||||
|
dom_counts[d] = dom_counts.get(d, 0) + 1
|
||||||
|
env_counts[e] = env_counts.get(e, 0) + 1
|
||||||
|
zone_counts[z] = zone_counts.get(z, 0) + 1
|
||||||
|
if r.status != 'excluded':
|
||||||
|
dom_active[d] = dom_active.get(d, 0) + 1
|
||||||
|
zone_active[z] = zone_active.get(z, 0) + 1
|
||||||
|
domains = sorted(k for k in dom_counts if k != '?')
|
||||||
|
envs = sorted(k for k in env_counts if k != '?')
|
||||||
|
zones = sorted(k for k in zone_counts if k != '?')
|
||||||
|
return {
|
||||||
|
"domains": domains, "envs": envs, "zones": zones,
|
||||||
|
"dom_counts": dom_counts, "env_counts": env_counts, "zone_counts": zone_counts,
|
||||||
|
"dom_active": dom_active, "zone_active": zone_active,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def apply_scope(db, run_id, keep_domains=None, keep_zones=None, user=None):
|
||||||
|
"""Applique le perimetre: serveurs hors domaines/zones selectionnes -> excluded.
|
||||||
|
Serveurs dans le perimetre -> pending (si etaient excluded)."""
|
||||||
|
by = user.get("display_name", user.get("username", "")) if user else ""
|
||||||
|
|
||||||
|
# Recuperer toutes les entries avec leur domaine/zone
|
||||||
|
entries = db.execute(text("""
|
||||||
|
SELECT qe.id, qe.status, s.hostname, d.name as domaine, z.name as zone
|
||||||
|
FROM quickwin_entries qe
|
||||||
|
JOIN servers s ON qe.server_id = s.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 zones z ON s.zone_id = z.id
|
||||||
|
WHERE qe.run_id = :rid
|
||||||
|
"""), {"rid": run_id}).fetchall()
|
||||||
|
|
||||||
|
included = 0
|
||||||
|
excluded = 0
|
||||||
|
for e in entries:
|
||||||
|
in_scope = True
|
||||||
|
if keep_domains and (e.domaine or '') not in keep_domains:
|
||||||
|
in_scope = False
|
||||||
|
if keep_zones and (e.zone or '') not in keep_zones:
|
||||||
|
in_scope = False
|
||||||
|
|
||||||
|
if in_scope and e.status == 'excluded':
|
||||||
|
db.execute(text("UPDATE quickwin_entries SET status='pending', updated_at=now() WHERE id=:id"),
|
||||||
|
{"id": e.id})
|
||||||
|
included += 1
|
||||||
|
elif not in_scope and e.status != 'excluded':
|
||||||
|
db.execute(text("UPDATE quickwin_entries SET status='excluded', updated_at=now() WHERE id=:id"),
|
||||||
|
{"id": e.id})
|
||||||
|
excluded += 1
|
||||||
|
|
||||||
|
scope_desc = []
|
||||||
|
if keep_domains:
|
||||||
|
scope_desc.append(f"domaines: {', '.join(keep_domains)}")
|
||||||
|
if keep_zones:
|
||||||
|
scope_desc.append(f"zones: {', '.join(keep_zones)}")
|
||||||
|
log_info(db, run_id, "scope",
|
||||||
|
f"Perimetre applique: {included} inclus, {excluded} exclus",
|
||||||
|
detail=" | ".join(scope_desc) if scope_desc else "Tous",
|
||||||
|
created_by=by)
|
||||||
|
db.commit()
|
||||||
|
return included, excluded
|
||||||
|
|
||||||
|
|
||||||
|
def update_entry_status(db, entry_id, status, patch_output="", packages_count=0,
|
||||||
|
packages="", reboot_required=False, notes=""):
|
||||||
|
db.execute(text("""
|
||||||
|
UPDATE quickwin_entries SET
|
||||||
|
status = :st, patch_output = :po, patch_packages_count = :pc,
|
||||||
|
patch_packages = :pp, reboot_required = :rb, notes = :n,
|
||||||
|
patch_date = CASE WHEN :st IN ('patched','failed') THEN now() ELSE patch_date END,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = :id
|
||||||
|
"""), {"id": entry_id, "st": status, "po": patch_output, "pc": packages_count,
|
||||||
|
"pp": packages, "rb": reboot_required, "n": notes})
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def update_entry_field(db, entry_id, field, value):
|
||||||
|
"""Mise a jour d'un champ unique (pour inline edit)"""
|
||||||
|
allowed = ("general_excludes", "specific_excludes", "notes", "status",
|
||||||
|
"snap_done", "prereq_ok", "prereq_detail", "dryrun_output")
|
||||||
|
if field not in allowed:
|
||||||
|
return False
|
||||||
|
db.execute(text(f"UPDATE quickwin_entries SET {field} = :val, updated_at = now() WHERE id = :id"),
|
||||||
|
{"val": value, "id": entry_id})
|
||||||
|
db.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def can_start_prod(db, run_id):
|
||||||
|
"""Verifie que tous les hprod sont termines avant d'autoriser le prod"""
|
||||||
|
pending = db.execute(text("""
|
||||||
|
SELECT COUNT(*) as cnt FROM quickwin_entries
|
||||||
|
WHERE run_id = :rid AND branch = 'hprod' AND status IN ('pending', 'in_progress')
|
||||||
|
"""), {"rid": run_id}).fetchone()
|
||||||
|
return pending.cnt == 0
|
||||||
|
|
||||||
|
|
||||||
|
def get_run_stats(db, run_id):
|
||||||
|
return db.execute(text("""
|
||||||
|
SELECT
|
||||||
|
COUNT(*) FILTER (WHERE status != 'excluded') as total,
|
||||||
|
COUNT(*) FILTER (WHERE branch = 'hprod' AND status != 'excluded') as hprod_total,
|
||||||
|
COUNT(*) FILTER (WHERE branch = 'prod' AND status != 'excluded') as prod_total,
|
||||||
|
COUNT(*) FILTER (WHERE status = 'patched') as patched,
|
||||||
|
COUNT(*) FILTER (WHERE status = 'failed') as failed,
|
||||||
|
COUNT(*) FILTER (WHERE status = 'pending') as pending,
|
||||||
|
COUNT(*) FILTER (WHERE status = 'pending' AND branch = 'hprod') as hprod_pending,
|
||||||
|
COUNT(*) FILTER (WHERE status = 'excluded') as excluded,
|
||||||
|
COUNT(*) FILTER (WHERE status = 'skipped') as skipped,
|
||||||
|
COUNT(*) FILTER (WHERE branch = 'hprod' AND status = 'patched') as hprod_patched,
|
||||||
|
COUNT(*) FILTER (WHERE branch = 'prod' AND status = 'patched') as prod_patched,
|
||||||
|
COUNT(*) FILTER (WHERE reboot_required AND status != 'excluded') as reboot_count
|
||||||
|
FROM quickwin_entries WHERE run_id = :rid
|
||||||
|
"""), {"rid": run_id}).fetchone()
|
||||||
|
|
||||||
|
|
||||||
|
def advance_run_status(db, run_id, target_status, user=None):
|
||||||
|
"""Avance le statut du run vers l'etape suivante"""
|
||||||
|
VALID_TRANSITIONS = {
|
||||||
|
"draft": "prereq",
|
||||||
|
"prereq": "snapshot",
|
||||||
|
"snapshot": "patching",
|
||||||
|
"patching": "result",
|
||||||
|
"result": "completed",
|
||||||
|
}
|
||||||
|
run = db.execute(text("SELECT status FROM quickwin_runs WHERE id = :id"),
|
||||||
|
{"id": run_id}).fetchone()
|
||||||
|
if not run:
|
||||||
|
return False
|
||||||
|
by = user.get("display_name", user.get("username", "")) if user else ""
|
||||||
|
if target_status == "draft":
|
||||||
|
db.execute(text("UPDATE quickwin_runs SET status = :st, updated_at = now() WHERE id = :id"),
|
||||||
|
{"st": target_status, "id": run_id})
|
||||||
|
log_info(db, run_id, "workflow", f"Retour a l'etape brouillon", created_by=by)
|
||||||
|
db.commit()
|
||||||
|
return True
|
||||||
|
expected = VALID_TRANSITIONS.get(run.status)
|
||||||
|
if expected != target_status:
|
||||||
|
log_warn(db, run_id, "workflow",
|
||||||
|
f"Transition refusee: {run.status} -> {target_status}", created_by=by)
|
||||||
|
db.commit()
|
||||||
|
return False
|
||||||
|
db.execute(text("UPDATE quickwin_runs SET status = :st, updated_at = now() WHERE id = :id"),
|
||||||
|
{"st": target_status, "id": run_id})
|
||||||
|
log_info(db, run_id, "workflow", f"Passage a l'etape: {target_status}", created_by=by)
|
||||||
|
db.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def get_step_stats(db, run_id, branch=None):
|
||||||
|
"""Stats par etape pour un run (optionnel: filtre par branch)"""
|
||||||
|
where = "run_id = :rid"
|
||||||
|
params = {"rid": run_id}
|
||||||
|
if branch:
|
||||||
|
where += " AND branch = :br"
|
||||||
|
params["br"] = branch
|
||||||
|
return db.execute(text(f"""
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as total,
|
||||||
|
COUNT(*) FILTER (WHERE status NOT IN ('excluded','skipped')) as active,
|
||||||
|
COUNT(*) FILTER (WHERE prereq_ok = true) as prereq_ok,
|
||||||
|
COUNT(*) FILTER (WHERE prereq_ok = false) as prereq_ko,
|
||||||
|
COUNT(*) FILTER (WHERE prereq_ok IS NULL AND status NOT IN ('excluded','skipped')) as prereq_pending,
|
||||||
|
COUNT(*) FILTER (WHERE snap_done = true) as snap_ok,
|
||||||
|
COUNT(*) FILTER (WHERE snap_done = false AND status NOT IN ('excluded','skipped')) as snap_pending,
|
||||||
|
COUNT(*) FILTER (WHERE status = 'patched') as patched,
|
||||||
|
COUNT(*) FILTER (WHERE status = 'failed') as failed,
|
||||||
|
COUNT(*) FILTER (WHERE status = 'pending') as pending,
|
||||||
|
COUNT(*) FILTER (WHERE reboot_required = true) as reboot_count
|
||||||
|
FROM quickwin_entries WHERE {where}
|
||||||
|
"""), params).fetchone()
|
||||||
|
|
||||||
|
|
||||||
|
def check_prereqs(db, run_id, branch, user=None):
|
||||||
|
"""Lance les verifications prerequis pour tous les serveurs d'une branche.
|
||||||
|
Etapes par serveur: DNS resolution, SSH, espace disque, satellite."""
|
||||||
|
from .quickwin_prereq_service import check_server_prereqs
|
||||||
|
|
||||||
|
entries = db.execute(text("""
|
||||||
|
SELECT qe.id, s.hostname, s.domain_ltd, s.ssh_method,
|
||||||
|
e.code as env_code
|
||||||
|
FROM quickwin_entries qe
|
||||||
|
JOIN servers s ON qe.server_id = s.id
|
||||||
|
LEFT JOIN domain_environments de ON s.domain_env_id = de.id
|
||||||
|
LEFT JOIN environments e ON de.environment_id = e.id
|
||||||
|
WHERE qe.run_id = :rid AND qe.branch = :br
|
||||||
|
AND qe.status NOT IN ('excluded','skipped')
|
||||||
|
ORDER BY s.hostname
|
||||||
|
"""), {"rid": run_id, "br": branch}).fetchall()
|
||||||
|
|
||||||
|
by = user.get("display_name", user.get("username", "")) if user else ""
|
||||||
|
log_info(db, run_id, "prereq",
|
||||||
|
f"Lancement check prerequis {branch} ({len(entries)} serveurs)", created_by=by)
|
||||||
|
|
||||||
|
results = []
|
||||||
|
ok_count = 0
|
||||||
|
for e in entries:
|
||||||
|
try:
|
||||||
|
r = check_server_prereqs(
|
||||||
|
hostname=e.hostname,
|
||||||
|
db=db,
|
||||||
|
domain_ltd=e.domain_ltd,
|
||||||
|
env_code=e.env_code,
|
||||||
|
ssh_method=e.ssh_method or "ssh_key",
|
||||||
|
)
|
||||||
|
except Exception as ex:
|
||||||
|
r = {"dns_ok": False, "ssh_ok": False, "satellite_ok": False,
|
||||||
|
"disk_ok": False, "detail": str(ex), "skip": True}
|
||||||
|
|
||||||
|
prereq_ok = (r.get("dns_ok", False) and r.get("ssh_ok", False)
|
||||||
|
and r.get("satellite_ok", False) and r.get("disk_ok", True))
|
||||||
|
db.execute(text("""
|
||||||
|
UPDATE quickwin_entries SET
|
||||||
|
prereq_ok = :ok, prereq_ssh = :ssh, prereq_satellite = :sat, prereq_disk = :disk,
|
||||||
|
prereq_detail = :detail, prereq_date = now(), updated_at = now()
|
||||||
|
WHERE id = :id
|
||||||
|
"""), {
|
||||||
|
"id": e.id, "ok": prereq_ok,
|
||||||
|
"ssh": r.get("ssh_ok", False), "sat": r.get("satellite_ok", False),
|
||||||
|
"disk": r.get("disk_ok", True), "detail": r.get("detail", ""),
|
||||||
|
})
|
||||||
|
# Log par serveur
|
||||||
|
detail_str = r.get("detail", "")
|
||||||
|
if prereq_ok:
|
||||||
|
ok_count += 1
|
||||||
|
log_success(db, run_id, "prereq", f"OK: {e.hostname}",
|
||||||
|
detail=detail_str, entry_id=e.id, hostname=e.hostname)
|
||||||
|
else:
|
||||||
|
log_error(db, run_id, "prereq", f"KO: {e.hostname}",
|
||||||
|
detail=detail_str, entry_id=e.id, hostname=e.hostname)
|
||||||
|
results.append({"hostname": e.hostname, "ok": prereq_ok, "detail": detail_str})
|
||||||
|
|
||||||
|
ko_count = len(results) - ok_count
|
||||||
|
log_info(db, run_id, "prereq",
|
||||||
|
f"Fin check {branch}: {ok_count} OK, {ko_count} KO sur {len(results)}", created_by=by)
|
||||||
|
db.commit()
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def mark_snapshot(db, entry_id, done=True):
|
||||||
|
"""Marque un serveur comme snapshot fait/pas fait"""
|
||||||
|
db.execute(text("""
|
||||||
|
UPDATE quickwin_entries SET snap_done = :done,
|
||||||
|
snap_date = CASE WHEN :done THEN now() ELSE NULL END,
|
||||||
|
updated_at = now() WHERE id = :id
|
||||||
|
"""), {"id": entry_id, "done": done})
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def mark_all_snapshots(db, run_id, branch, done=True):
|
||||||
|
"""Marque tous les serveurs d'une branche comme snapshot fait"""
|
||||||
|
db.execute(text("""
|
||||||
|
UPDATE quickwin_entries SET snap_done = :done,
|
||||||
|
snap_date = CASE WHEN :done THEN now() ELSE NULL END,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE run_id = :rid AND branch = :br AND status NOT IN ('excluded','skipped')
|
||||||
|
"""), {"rid": run_id, "br": branch, "done": done})
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def build_yum_commands(db, run_id, branch):
|
||||||
|
"""Construit les commandes yum pour chaque serveur d'une branche.
|
||||||
|
Inclut tous les serveurs actifs (non excluded/skipped) avec snap fait."""
|
||||||
|
entries = db.execute(text("""
|
||||||
|
SELECT qe.id, s.hostname, qe.general_excludes, qe.specific_excludes
|
||||||
|
FROM quickwin_entries qe
|
||||||
|
JOIN servers s ON qe.server_id = s.id
|
||||||
|
WHERE qe.run_id = :rid AND qe.branch = :br
|
||||||
|
AND qe.status NOT IN ('excluded','skipped')
|
||||||
|
AND qe.snap_done = true
|
||||||
|
ORDER BY s.hostname
|
||||||
|
"""), {"rid": run_id, "br": branch}).fetchall()
|
||||||
|
|
||||||
|
commands = []
|
||||||
|
for e in entries:
|
||||||
|
excludes = (e.general_excludes or "") + " " + (e.specific_excludes or "")
|
||||||
|
parts = [p.strip() for p in excludes.split() if p.strip()]
|
||||||
|
exclude_args = " ".join(f"--exclude={p}" for p in parts)
|
||||||
|
cmd = f"yum update -y {exclude_args}".strip()
|
||||||
|
db.execute(text("UPDATE quickwin_entries SET patch_command = :cmd WHERE id = :id"),
|
||||||
|
{"cmd": cmd, "id": e.id})
|
||||||
|
commands.append({"id": e.id, "hostname": e.hostname, "command": cmd})
|
||||||
|
db.commit()
|
||||||
|
return commands
|
||||||
|
|
||||||
|
|
||||||
|
def inject_yum_history(db, data):
|
||||||
|
"""Injecte l'historique yum dans quickwin_server_config.
|
||||||
|
data = [{"server": "hostname", "yum_commands": [...]}]"""
|
||||||
|
updated = 0
|
||||||
|
inserted = 0
|
||||||
|
for item in data:
|
||||||
|
hostname = item.get("server", item.get("server_name", "")).strip()
|
||||||
|
if not hostname:
|
||||||
|
continue
|
||||||
|
srv = db.execute(text("SELECT id FROM servers WHERE hostname = :h"), {"h": hostname}).fetchone()
|
||||||
|
if not srv:
|
||||||
|
continue
|
||||||
|
cmds = json.dumps(item.get("yum_commands", item.get("last_yum_commands", [])), ensure_ascii=False)
|
||||||
|
existing = db.execute(text(
|
||||||
|
"SELECT id FROM quickwin_server_config WHERE server_id = :sid"
|
||||||
|
), {"sid": srv.id}).fetchone()
|
||||||
|
if existing:
|
||||||
|
db.execute(text("""
|
||||||
|
UPDATE quickwin_server_config SET last_yum_commands = :cmds::jsonb, updated_at = now()
|
||||||
|
WHERE server_id = :sid
|
||||||
|
"""), {"sid": srv.id, "cmds": cmds})
|
||||||
|
updated += 1
|
||||||
|
else:
|
||||||
|
db.execute(text("""
|
||||||
|
INSERT INTO quickwin_server_config (server_id, last_yum_commands)
|
||||||
|
VALUES (:sid, :cmds::jsonb)
|
||||||
|
"""), {"sid": srv.id, "cmds": cmds})
|
||||||
|
inserted += 1
|
||||||
|
db.commit()
|
||||||
|
return updated, inserted
|
||||||
|
|
||||||
|
|
||||||
|
# ========== CORRESPONDANCE HPROD ↔ PROD ==========
|
||||||
|
|
||||||
|
def compute_correspondance(db, run_id, user=None):
|
||||||
|
"""Auto-apparie chaque serveur hprod avec son homologue prod (2e lettre → p).
|
||||||
|
Retourne (matched, unmatched, anomalies)."""
|
||||||
|
by = user.get("display_name", user.get("username", "")) if user else ""
|
||||||
|
|
||||||
|
hprod_rows = db.execute(text("""
|
||||||
|
SELECT qe.id, LOWER(s.hostname) as hostname
|
||||||
|
FROM quickwin_entries qe JOIN servers s ON qe.server_id = s.id
|
||||||
|
WHERE qe.run_id = :rid AND qe.branch = 'hprod' AND qe.status != 'excluded'
|
||||||
|
"""), {"rid": run_id}).fetchall()
|
||||||
|
|
||||||
|
prod_rows = db.execute(text("""
|
||||||
|
SELECT qe.id, LOWER(s.hostname) as hostname
|
||||||
|
FROM quickwin_entries qe JOIN servers s ON qe.server_id = s.id
|
||||||
|
WHERE qe.run_id = :rid AND qe.branch = 'prod' AND qe.status != 'excluded'
|
||||||
|
"""), {"rid": run_id}).fetchall()
|
||||||
|
|
||||||
|
prod_by_host = {r.hostname: r.id for r in prod_rows}
|
||||||
|
matched = 0
|
||||||
|
unmatched = 0
|
||||||
|
anomalies = 0
|
||||||
|
skipped = 0
|
||||||
|
|
||||||
|
# Existing pairs — ne pas toucher
|
||||||
|
existing = {r.id for r in db.execute(text("""
|
||||||
|
SELECT id FROM quickwin_entries
|
||||||
|
WHERE run_id = :rid AND branch = 'hprod' AND prod_pair_entry_id IS NOT NULL
|
||||||
|
"""), {"rid": run_id}).fetchall()}
|
||||||
|
|
||||||
|
for h in hprod_rows:
|
||||||
|
if h.id in existing:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
if len(h.hostname) < 2:
|
||||||
|
unmatched += 1
|
||||||
|
continue
|
||||||
|
candidate = h.hostname[0] + 'p' + h.hostname[2:]
|
||||||
|
if candidate == h.hostname:
|
||||||
|
anomalies += 1
|
||||||
|
if candidate in prod_by_host:
|
||||||
|
db.execute(text("""
|
||||||
|
UPDATE quickwin_entries SET prod_pair_entry_id = :pid WHERE id = :hid
|
||||||
|
"""), {"pid": prod_by_host[candidate], "hid": h.id})
|
||||||
|
matched += 1
|
||||||
|
else:
|
||||||
|
unmatched += 1
|
||||||
|
|
||||||
|
log_info(db, run_id, "correspondance",
|
||||||
|
f"Auto-appariement: {matched} nouveaux, {skipped} conservés, {unmatched} sans homologue, {anomalies} anomalies",
|
||||||
|
created_by=by)
|
||||||
|
db.commit()
|
||||||
|
return matched, unmatched, anomalies
|
||||||
|
|
||||||
|
|
||||||
|
def get_correspondance(db, run_id, search=None, pair_filter=None, env_filter=None, domain_filter=None):
|
||||||
|
"""Retourne la liste des hprod avec leur homologue prod (ou NULL)."""
|
||||||
|
rows = db.execute(text("""
|
||||||
|
SELECT hp.id as hprod_id, sh.hostname as hprod_hostname,
|
||||||
|
dh.name as hprod_domaine, eh.name as hprod_env,
|
||||||
|
SUBSTRING(LOWER(sh.hostname), 2, 1) as letter2,
|
||||||
|
hp.prod_pair_entry_id,
|
||||||
|
pp.id as prod_id, sp.hostname as prod_hostname,
|
||||||
|
dp.name as prod_domaine
|
||||||
|
FROM quickwin_entries hp
|
||||||
|
JOIN servers sh ON hp.server_id = sh.id
|
||||||
|
LEFT JOIN domain_environments deh ON sh.domain_env_id = deh.id
|
||||||
|
LEFT JOIN domains dh ON deh.domain_id = dh.id
|
||||||
|
LEFT JOIN environments eh ON deh.environment_id = eh.id
|
||||||
|
LEFT JOIN quickwin_entries pp ON hp.prod_pair_entry_id = pp.id
|
||||||
|
LEFT JOIN servers sp ON pp.server_id = sp.id
|
||||||
|
LEFT JOIN domain_environments dep ON sp.domain_env_id = dep.id
|
||||||
|
LEFT JOIN domains dp ON dep.domain_id = dp.id
|
||||||
|
WHERE hp.run_id = :rid AND hp.branch = 'hprod' AND hp.status != 'excluded'
|
||||||
|
ORDER BY sh.hostname
|
||||||
|
"""), {"rid": run_id}).fetchall()
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for r in rows:
|
||||||
|
candidate = ""
|
||||||
|
if len(r.hprod_hostname) >= 2:
|
||||||
|
candidate = r.hprod_hostname[0] + 'p' + r.hprod_hostname[2:]
|
||||||
|
is_anomaly = (r.letter2 == 'p')
|
||||||
|
is_matched = r.prod_pair_entry_id is not None
|
||||||
|
|
||||||
|
if pair_filter == "matched" and not is_matched:
|
||||||
|
continue
|
||||||
|
if pair_filter == "unmatched" and is_matched:
|
||||||
|
continue
|
||||||
|
if pair_filter == "anomaly" and not is_anomaly:
|
||||||
|
continue
|
||||||
|
if env_filter:
|
||||||
|
env_map = {"preprod": "i", "recette": "r", "dev": "d", "test": "vt"}
|
||||||
|
allowed_letters = env_map.get(env_filter, "")
|
||||||
|
if r.letter2 not in allowed_letters:
|
||||||
|
continue
|
||||||
|
if domain_filter and (r.hprod_domaine or '') != domain_filter:
|
||||||
|
continue
|
||||||
|
if search and search.lower() not in r.hprod_hostname.lower():
|
||||||
|
if not (r.prod_hostname and search.lower() in r.prod_hostname.lower()):
|
||||||
|
continue
|
||||||
|
|
||||||
|
result.append({
|
||||||
|
"hprod_id": r.hprod_id,
|
||||||
|
"hprod_hostname": r.hprod_hostname,
|
||||||
|
"hprod_domaine": r.hprod_domaine or "",
|
||||||
|
"hprod_env": r.hprod_env or "",
|
||||||
|
"letter2": r.letter2,
|
||||||
|
"candidate": candidate,
|
||||||
|
"is_anomaly": is_anomaly,
|
||||||
|
"prod_id": r.prod_id,
|
||||||
|
"prod_hostname": r.prod_hostname or "",
|
||||||
|
"prod_domaine": r.prod_domaine or "",
|
||||||
|
"is_matched": is_matched,
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def get_available_prod_entries(db, run_id):
|
||||||
|
"""Retourne toutes les entries prod (un prod peut etre apparie a plusieurs hprod)."""
|
||||||
|
return db.execute(text("""
|
||||||
|
SELECT qe.id, s.hostname, d.name as domaine
|
||||||
|
FROM quickwin_entries qe
|
||||||
|
JOIN servers s ON qe.server_id = s.id
|
||||||
|
LEFT JOIN domain_environments de ON s.domain_env_id = de.id
|
||||||
|
LEFT JOIN domains d ON de.domain_id = d.id
|
||||||
|
WHERE qe.run_id = :rid AND qe.branch = 'prod' AND qe.status != 'excluded'
|
||||||
|
ORDER BY s.hostname
|
||||||
|
"""), {"rid": run_id}).fetchall()
|
||||||
|
|
||||||
|
|
||||||
|
def set_prod_pair(db, hprod_entry_id, prod_entry_id):
|
||||||
|
"""Associe manuellement un hprod à un prod (ou NULL pour dissocier)."""
|
||||||
|
pid = prod_entry_id if prod_entry_id else None
|
||||||
|
db.execute(text("""
|
||||||
|
UPDATE quickwin_entries SET prod_pair_entry_id = :pid, updated_at = now() WHERE id = :hid
|
||||||
|
"""), {"pid": pid, "hid": hprod_entry_id})
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def clear_all_pairs(db, run_id):
|
||||||
|
"""Supprime tous les appariements d'un run."""
|
||||||
|
db.execute(text("""
|
||||||
|
UPDATE quickwin_entries SET prod_pair_entry_id = NULL, updated_at = now()
|
||||||
|
WHERE run_id = :rid AND branch = 'hprod'
|
||||||
|
"""), {"rid": run_id})
|
||||||
|
db.commit()
|
||||||
144
app/services/quickwin_snapshot_service.py
Normal file
144
app/services/quickwin_snapshot_service.py
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
"""Service snapshot QuickWin — prise de snapshots VM via vSphere/pyvmomi
|
||||||
|
Ordre de recherche des VM sur les vCenters:
|
||||||
|
- Hors-prod: Senlis (vpgesavcs1) → Nanterre (vpmetavcs1) → DR (vpsicavcs1)
|
||||||
|
- Prod: Nanterre (vpmetavcs1) → Senlis (vpgesavcs1) → DR (vpsicavcs1)
|
||||||
|
Physiques: pas de snapshot, alerte Commvault."""
|
||||||
|
import ssl
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
log = logging.getLogger("quickwin.snapshot")
|
||||||
|
|
||||||
|
try:
|
||||||
|
from pyVim.connect import SmartConnect, Disconnect
|
||||||
|
from pyVmomi import vim
|
||||||
|
PYVMOMI_OK = True
|
||||||
|
except ImportError:
|
||||||
|
PYVMOMI_OK = False
|
||||||
|
log.warning("pyvmomi non disponible — snapshots impossibles")
|
||||||
|
|
||||||
|
|
||||||
|
def _get_secret(db, key):
|
||||||
|
try:
|
||||||
|
from ..services.secrets_service import get_secret
|
||||||
|
return get_secret(db, key)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _connect_vcenter(endpoint, user, password):
|
||||||
|
"""Connexion a un vCenter. Retourne un ServiceInstance ou None."""
|
||||||
|
try:
|
||||||
|
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
||||||
|
ctx.check_hostname = False
|
||||||
|
ctx.verify_mode = ssl.CERT_NONE
|
||||||
|
si = SmartConnect(host=endpoint, user=user, pwd=password, sslContext=ctx)
|
||||||
|
return si
|
||||||
|
except Exception as e:
|
||||||
|
log.warning(f"Connexion vCenter {endpoint} echouee: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _find_vm(si, vm_name):
|
||||||
|
"""Cherche une VM par nom dans le vCenter. Retourne l'objet VM ou None."""
|
||||||
|
content = si.RetrieveContent()
|
||||||
|
container = content.viewManager.CreateContainerView(
|
||||||
|
content.rootFolder, [vim.VirtualMachine], True)
|
||||||
|
try:
|
||||||
|
for vm in container.view:
|
||||||
|
if vm.name.lower() == vm_name.lower():
|
||||||
|
return vm
|
||||||
|
finally:
|
||||||
|
container.Destroy()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _take_snapshot(vm, snap_name, description=""):
|
||||||
|
"""Prend un snapshot de la VM. Retourne (ok, message)."""
|
||||||
|
try:
|
||||||
|
task = vm.CreateSnapshot_Task(
|
||||||
|
name=snap_name,
|
||||||
|
description=description,
|
||||||
|
memory=False,
|
||||||
|
quiesce=True,
|
||||||
|
)
|
||||||
|
# Attendre la fin du task
|
||||||
|
while task.info.state in (vim.TaskInfo.State.queued, vim.TaskInfo.State.running):
|
||||||
|
import time
|
||||||
|
time.sleep(2)
|
||||||
|
if task.info.state == vim.TaskInfo.State.success:
|
||||||
|
return True, "Snapshot OK"
|
||||||
|
else:
|
||||||
|
err = str(task.info.error) if task.info.error else "Echec inconnu"
|
||||||
|
return False, f"Snapshot echoue: {err}"
|
||||||
|
except Exception as e:
|
||||||
|
return False, f"Erreur snapshot: {e}"
|
||||||
|
|
||||||
|
|
||||||
|
def get_vcenter_order(db, branch):
|
||||||
|
"""Retourne la liste ordonnee des vCenters selon la branche.
|
||||||
|
hprod: Senlis → Nanterre → DR
|
||||||
|
prod: Nanterre → Senlis → DR"""
|
||||||
|
from sqlalchemy import text
|
||||||
|
vcenters = db.execute(text(
|
||||||
|
"SELECT id, name, endpoint FROM vcenters WHERE is_active = true ORDER BY id"
|
||||||
|
)).fetchall()
|
||||||
|
|
||||||
|
vc_map = {}
|
||||||
|
for vc in vcenters:
|
||||||
|
ep = vc.endpoint.lower()
|
||||||
|
if "vpgesavcs1" in ep:
|
||||||
|
vc_map["senlis"] = vc
|
||||||
|
elif "vpmetavcs1" in ep:
|
||||||
|
vc_map["nanterre"] = vc
|
||||||
|
elif "vpsicavcs1" in ep:
|
||||||
|
vc_map["dr"] = vc
|
||||||
|
else:
|
||||||
|
vc_map.setdefault("other", []).append(vc)
|
||||||
|
|
||||||
|
if branch == "prod":
|
||||||
|
order = [vc_map.get("nanterre"), vc_map.get("senlis"), vc_map.get("dr")]
|
||||||
|
else:
|
||||||
|
order = [vc_map.get("senlis"), vc_map.get("nanterre"), vc_map.get("dr")]
|
||||||
|
|
||||||
|
return [v for v in order if v is not None]
|
||||||
|
|
||||||
|
|
||||||
|
def snapshot_server(hostname, vm_name, branch, db, snap_name=None):
|
||||||
|
"""Prend un snapshot pour un serveur.
|
||||||
|
Cherche la VM sur les vCenters dans l'ordre selon la branche.
|
||||||
|
Retourne dict: {ok, vcenter, detail, skipped}"""
|
||||||
|
if not PYVMOMI_OK:
|
||||||
|
return {"ok": False, "vcenter": "", "detail": "pyvmomi non installe", "skipped": True}
|
||||||
|
|
||||||
|
vc_user = _get_secret(db, "vcenter_user")
|
||||||
|
vc_pass = _get_secret(db, "vcenter_pass")
|
||||||
|
if not vc_user or not vc_pass:
|
||||||
|
return {"ok": False, "vcenter": "", "detail": "Credentials vCenter manquants (vcenter_user/vcenter_pass dans Settings > Secrets)", "skipped": True}
|
||||||
|
|
||||||
|
search_name = vm_name or hostname
|
||||||
|
if not snap_name:
|
||||||
|
snap_name = f"QW_{datetime.now().strftime('%Y%m%d_%H%M')}"
|
||||||
|
|
||||||
|
vcenters = get_vcenter_order(db, branch)
|
||||||
|
if not vcenters:
|
||||||
|
return {"ok": False, "vcenter": "", "detail": "Aucun vCenter actif configure", "skipped": True}
|
||||||
|
|
||||||
|
for vc in vcenters:
|
||||||
|
si = _connect_vcenter(vc.endpoint, vc_user, vc_pass)
|
||||||
|
if not si:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
vm = _find_vm(si, search_name)
|
||||||
|
if vm:
|
||||||
|
ok, msg = _take_snapshot(vm, snap_name,
|
||||||
|
description=f"QuickWin auto-snapshot {hostname}")
|
||||||
|
return {"ok": ok, "vcenter": vc.name, "detail": msg}
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
Disconnect(si)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
tried = ", ".join(vc.name for vc in vcenters)
|
||||||
|
return {"ok": False, "vcenter": "", "detail": f"VM '{search_name}' non trouvee sur: {tried}"}
|
||||||
@ -119,6 +119,11 @@ def list_servers(db, filters, page=1, per_page=50, sort="hostname", sort_dir="as
|
|||||||
where.append("s.licence_support = 'eol'")
|
where.append("s.licence_support = 'eol'")
|
||||||
else:
|
else:
|
||||||
where.append("s.etat = :etat"); params["etat"] = filters["etat"]
|
where.append("s.etat = :etat"); params["etat"] = filters["etat"]
|
||||||
|
where.append("COALESCE(s.licence_support, '') != 'eol'")
|
||||||
|
if filters.get("os"):
|
||||||
|
where.append("s.os_family = :os"); params["os"] = filters["os"]
|
||||||
|
if filters.get("owner"):
|
||||||
|
where.append("s.patch_os_owner = :owner"); params["owner"] = filters["owner"]
|
||||||
if filters.get("search"):
|
if filters.get("search"):
|
||||||
where.append("s.hostname ILIKE :search"); params["search"] = f"%{filters['search']}%"
|
where.append("s.hostname ILIKE :search"); params["search"] = f"%{filters['search']}%"
|
||||||
|
|
||||||
@ -203,7 +208,7 @@ def update_server(db, server_id, data, username):
|
|||||||
params = {"id": server_id}
|
params = {"id": server_id}
|
||||||
direct_fields = ["tier", "etat", "patch_os_owner", "responsable_nom",
|
direct_fields = ["tier", "etat", "patch_os_owner", "responsable_nom",
|
||||||
"referent_nom", "mode_operatoire", "commentaire", "ssh_method",
|
"referent_nom", "mode_operatoire", "commentaire", "ssh_method",
|
||||||
"pref_patch_jour", "pref_patch_heure"]
|
"domain_ltd", "pref_patch_jour", "pref_patch_heure"]
|
||||||
changed = []
|
changed = []
|
||||||
for field in direct_fields:
|
for field in direct_fields:
|
||||||
if data.get(field) is not None:
|
if data.get(field) is not None:
|
||||||
|
|||||||
@ -13,6 +13,8 @@
|
|||||||
<a href="/audit-full/patching?year={{ year }}" class="btn-sm {% if not scope %}bg-cyber-accent text-black{% else %}bg-cyber-border text-gray-400{% endif %} px-3 py-1">Tous</a>
|
<a href="/audit-full/patching?year={{ year }}" class="btn-sm {% if not scope %}bg-cyber-accent text-black{% else %}bg-cyber-border text-gray-400{% endif %} px-3 py-1">Tous</a>
|
||||||
<a href="/audit-full/patching?year={{ year }}&scope=secops" class="btn-sm {% if scope == 'secops' %}bg-cyber-green text-black{% else %}bg-cyber-border text-gray-400{% endif %} px-3 py-1">SecOps</a>
|
<a href="/audit-full/patching?year={{ year }}&scope=secops" class="btn-sm {% if scope == 'secops' %}bg-cyber-green text-black{% else %}bg-cyber-border text-gray-400{% endif %} px-3 py-1">SecOps</a>
|
||||||
<a href="/audit-full/patching?year={{ year }}&scope=other" class="btn-sm {% if scope == 'other' %}bg-cyber-yellow text-black{% else %}bg-cyber-border text-gray-400{% endif %} px-3 py-1">Hors SecOps</a>
|
<a href="/audit-full/patching?year={{ year }}&scope=other" class="btn-sm {% if scope == 'other' %}bg-cyber-yellow text-black{% else %}bg-cyber-border text-gray-400{% endif %} px-3 py-1">Hors SecOps</a>
|
||||||
|
<span class="text-gray-600 mx-1">|</span>
|
||||||
|
<a href="/audit-full/patching/export-csv?year={{ year }}{% if scope %}&scope={{ scope }}{% endif %}{% if search %}&q={{ search }}{% endif %}{% if domain %}&domain={{ domain }}{% endif %}" class="btn-sm bg-cyber-green text-black px-3 py-1">CSV</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -187,7 +189,7 @@
|
|||||||
<tr class="hover:bg-cyber-hover cursor-pointer" onclick="location.href='/audit-full/{{ s.id }}'">
|
<tr class="hover:bg-cyber-hover cursor-pointer" onclick="location.href='/audit-full/{{ s.id }}'">
|
||||||
<td class="p-2 font-mono text-cyber-accent font-bold">{{ s.hostname }}</td>
|
<td class="p-2 font-mono text-cyber-accent font-bold">{{ s.hostname }}</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"><span class="badge {% if s.env == 'Production' %}badge-green{% elif s.env == 'Recette' %}badge-yellow{% else %}badge-gray{% endif %}"title="{{ s.env or '' }}">{{ (s.env or '-')[:6] }}</span></td>
|
<td class="p-2 text-center"><span class="badge {% if s.env == 'Production' %}badge-green{% elif s.env == 'Recette' %}badge-yellow{% else %}badge-gray{% endif %}">{{ (s.env or '-')[:6] }}</span></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 font-bold {% if (s.patch_count or 0) >= 2 %}text-cyber-green{% elif (s.patch_count or 0) == 1 %}text-green-300{% else %}text-cyber-red{% endif %}">{{ s.patch_count or 0 }}</td>
|
<td class="p-2 text-center font-bold {% if (s.patch_count or 0) >= 2 %}text-cyber-green{% elif (s.patch_count or 0) == 1 %}text-green-300{% else %}text-cyber-red{% endif %}">{{ s.patch_count or 0 }}</td>
|
||||||
<td class="p-2 font-mono text-gray-400">{% if s.patch_weeks %}{% for w in s.patch_weeks.split(',') %}<span class="inline-block px-1 rounded text-xs {% if w == 'S15' %}bg-green-900/30 text-cyber-green{% else %}bg-cyber-border text-gray-400{% endif %} mr-1">{{ w }}</span>{% endfor %}{% else %}-{% endif %}</td>
|
<td class="p-2 font-mono text-gray-400">{% if s.patch_weeks %}{% for w in s.patch_weeks.split(',') %}<span class="inline-block px-1 rounded text-xs {% if w == 'S15' %}bg-green-900/30 text-cyber-green{% else %}bg-cyber-border text-gray-400{% endif %} mr-1">{{ w }}</span>{% endfor %}{% else %}-{% endif %}</td>
|
||||||
|
|||||||
@ -51,7 +51,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<nav class="flex-1 p-3 space-y-1">
|
<nav class="flex-1 p-3 space-y-1">
|
||||||
{% set p = perms if perms is defined else request.state.perms %}
|
{% set p = perms if perms is defined else request.state.perms %}
|
||||||
<a href="/dashboard" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'dashboard' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Dashboard</a>
|
{% if p.servers or p.qualys or p.audit or p.planning %}<a href="/dashboard" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'dashboard' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Dashboard</a>{% endif %}
|
||||||
{% if p.servers %}<a href="/servers" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if request.url.path == '/servers' or '/servers/' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Serveurs</a>{% endif %}
|
{% if p.servers %}<a href="/servers" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if request.url.path == '/servers' or '/servers/' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Serveurs</a>{% endif %}
|
||||||
{% if p.specifics %}<a href="/specifics" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'specifics' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6 text-xs">Specifiques</a>{% endif %}
|
{% if p.specifics %}<a href="/specifics" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'specifics' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6 text-xs">Specifiques</a>{% endif %}
|
||||||
{% if p.campaigns %}<a href="/campaigns" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'campaigns' in request.url.path and 'assignments' not in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Campagnes</a>{% endif %}
|
{% if p.campaigns %}<a href="/campaigns" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'campaigns' in request.url.path and 'assignments' not in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Campagnes</a>{% endif %}
|
||||||
@ -61,6 +61,9 @@
|
|||||||
{% if p.qualys %}<a href="/qualys/decoder" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'decoder' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6 text-xs">Décodeur</a>{% endif %}
|
{% if p.qualys %}<a href="/qualys/decoder" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'decoder' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6 text-xs">Décodeur</a>{% endif %}
|
||||||
{% if p.qualys %}<a href="/qualys/agents" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'agents' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6 text-xs">Agents</a>{% endif %}
|
{% if p.qualys %}<a href="/qualys/agents" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'agents' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6 text-xs">Agents</a>{% endif %}
|
||||||
{% if p.campaigns %}<a href="/safe-patching" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'safe-patching' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Safe Patching</a>{% endif %}
|
{% if p.campaigns %}<a href="/safe-patching" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'safe-patching' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Safe Patching</a>{% endif %}
|
||||||
|
{% if p.campaigns or p.quickwin %}<a href="/quickwin" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'quickwin' in request.url.path and 'safe' not in request.url.path and 'config' not in request.url.path and 'correspondance' not in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">QuickWin</a>{% endif %}
|
||||||
|
{% if p.campaigns or p.quickwin %}<a href="/quickwin/config" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if '/quickwin/config' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6 text-xs">Config exclusions</a>{% endif %}
|
||||||
|
{% if p.campaigns or p.quickwin %}<a href="/quickwin/correspondance" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'correspondance' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6 text-xs">Correspondance</a>{% endif %}
|
||||||
{% if p.planning %}<a href="/planning" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'planning' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Planning</a>{% endif %}
|
{% if p.planning %}<a href="/planning" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'planning' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Planning</a>{% endif %}
|
||||||
{% if p.audit %}<a href="/audit" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if request.url.path == '/audit' %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Audit</a>{% endif %}
|
{% if p.audit %}<a href="/audit" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if request.url.path == '/audit' %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Audit</a>{% endif %}
|
||||||
{% if p.audit in ('edit', 'admin') %}<a href="/audit/specific" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'specific' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6 text-xs">Spécifique</a>{% endif %}
|
{% if p.audit in ('edit', 'admin') %}<a href="/audit/specific" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'specific' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6 text-xs">Spécifique</a>{% endif %}
|
||||||
@ -69,6 +72,7 @@
|
|||||||
{% if p.servers %}<a href="/contacts" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'contacts' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Contacts</a>{% endif %}
|
{% if p.servers %}<a href="/contacts" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'contacts' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Contacts</a>{% endif %}
|
||||||
{% if p.users %}<a href="/users" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'users' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Utilisateurs</a>{% endif %}
|
{% if p.users %}<a href="/users" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'users' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Utilisateurs</a>{% endif %}
|
||||||
{% if p.settings %}<a href="/settings" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'settings' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Settings</a>{% endif %}
|
{% if p.settings %}<a href="/settings" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'settings' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Settings</a>{% endif %}
|
||||||
|
{% if p.settings %}<a href="/referentiel" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'referentiel' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6 text-xs">Référentiel</a>{% endif %}
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
<main class="flex-1 flex flex-col overflow-hidden">
|
<main class="flex-1 flex flex-col overflow-hidden">
|
||||||
|
|||||||
@ -36,10 +36,17 @@
|
|||||||
{% for e in envs %}<option value="{{ e.code }}" {% if e.name == s.environnement %}selected{% endif %}>{{ e.name }}</option>{% endfor %}
|
{% for e in envs %}<option value="{{ e.code }}" {% if e.name == s.environnement %}selected{% endif %}>{{ e.name }}</option>{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-xs text-gray-500">Domaine DNS (domain_ltd)</label>
|
||||||
|
<select name="domain_ltd" class="w-full">
|
||||||
|
<option value="">-- Aucun --</option>
|
||||||
|
{% for d in dns_list %}<option value="{{ d }}" {% if d == s.domain_ltd %}selected{% endif %}>{{ d }}</option>{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="text-xs text-gray-500">Zone</label>
|
<label class="text-xs text-gray-500">Zone</label>
|
||||||
<select name="zone" class="w-full">
|
<select name="zone" class="w-full">
|
||||||
{% for z in ['LAN','DMZ','EMV'] %}<option value="{{ z }}" {% if z == s.zone %}selected{% endif %}>{{ z }}</option>{% endfor %}
|
{% for z in zones_list %}<option value="{{ z }}" {% if z == s.zone %}selected{% endif %}>{{ z }}</option>{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@ -70,18 +77,18 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-2 gap-2">
|
<div class="grid grid-cols-2 gap-2">
|
||||||
<div>
|
<div>
|
||||||
<label class="text-xs text-gray-500">Jour préféré patching</label>
|
<label class="text-xs text-gray-500">Jour préféré patching</label>
|
||||||
<select name="pref_patch_jour" class="w-full">
|
<select name="pref_patch_jour" class="w-full">
|
||||||
{% for j in ['indifferent','lundi','mardi','mercredi','jeudi'] %}<option value="{{ j }}" {% if j == s.pref_patch_jour %}selected{% endif %}>{{ j }}</option>{% endfor %}
|
{% for j in ['indifferent','lundi','mardi','mercredi','jeudi'] %}<option value="{{ j }}" {% if j == s.pref_patch_jour %}selected{% endif %}>{{ j }}</option>{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="text-xs text-gray-500">Heure préférée</label>
|
<label class="text-xs text-gray-500">Heure préférée</label>
|
||||||
<input type="text" name="pref_patch_heure" value="{{ s.pref_patch_heure or '' }}" placeholder="ex: 14h00, tôt le matin" class="w-full">
|
<input type="text" name="pref_patch_heure" value="{{ s.pref_patch_heure or '' }}" placeholder="ex: 14h00, tôt le matin" class="w-full">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="text-xs text-gray-500">Mode opératoire</label>
|
<label class="text-xs text-gray-500">Mode opératoire</label>
|
||||||
<textarea name="mode_operatoire" rows="3" class="w-full">{{ s.mode_operatoire or '' }}</textarea>
|
<textarea name="mode_operatoire" rows="3" class="w-full">{{ s.mode_operatoire or '' }}</textarea>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@ -85,8 +85,35 @@
|
|||||||
|
|
||||||
<!-- Serveurs sans agent Qualys -->
|
<!-- Serveurs sans agent Qualys -->
|
||||||
{% if no_agent_servers %}
|
{% if no_agent_servers %}
|
||||||
<div class="card p-4 mb-4">
|
<div class="card p-4 mb-4" x-data="{fHost:'', fOs:'', fDom:'', fEnv:'', fEtat:''}">
|
||||||
<h3 class="text-sm font-bold text-cyber-red mb-3">Serveurs en production sans agent Qualys ({{ no_agent_servers|length }})</h3>
|
<div class="flex justify-between items-center mb-3">
|
||||||
|
<h3 class="text-sm font-bold text-cyber-red">Serveurs sans agent Qualys ({{ no_agent_servers|length }})</h3>
|
||||||
|
<a href="/qualys/agents/export-no-agent" class="btn-sm bg-cyber-green text-black px-3 py-1 text-xs">Exporter CSV</a>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2 mb-3">
|
||||||
|
<input type="text" x-model="fHost" placeholder="Hostname..." class="text-xs py-1 px-2 flex-1 font-mono">
|
||||||
|
<select x-model="fOs" class="text-xs py-1 px-2">
|
||||||
|
<option value="">OS</option>
|
||||||
|
<option value="linux">Linux</option>
|
||||||
|
<option value="windows">Windows</option>
|
||||||
|
</select>
|
||||||
|
<select x-model="fDom" class="text-xs py-1 px-2">
|
||||||
|
<option value="">Domaine</option>
|
||||||
|
{% set doms = no_agent_servers|map(attribute='domain')|unique|sort %}
|
||||||
|
{% for d in doms %}{% if d %}<option value="{{ d }}">{{ d }}</option>{% endif %}{% endfor %}
|
||||||
|
</select>
|
||||||
|
<select x-model="fEnv" class="text-xs py-1 px-2">
|
||||||
|
<option value="">Env</option>
|
||||||
|
{% set envs = no_agent_servers|map(attribute='env')|unique|sort %}
|
||||||
|
{% for e in envs %}{% if e %}<option value="{{ e }}">{{ e }}</option>{% endif %}{% endfor %}
|
||||||
|
</select>
|
||||||
|
<select x-model="fEtat" class="text-xs py-1 px-2">
|
||||||
|
<option value="">État</option>
|
||||||
|
{% set etats = no_agent_servers|map(attribute='etat')|unique|sort %}
|
||||||
|
{% for e in etats %}{% if e %}<option value="{{ e }}">{{ e }}</option>{% endif %}{% endfor %}
|
||||||
|
</select>
|
||||||
|
<button @click="fHost='';fOs='';fDom='';fEnv='';fEtat=''" class="text-xs text-gray-400 hover:text-cyber-accent">Reset</button>
|
||||||
|
</div>
|
||||||
<table class="w-full table-cyber text-xs">
|
<table class="w-full table-cyber text-xs">
|
||||||
<thead><tr>
|
<thead><tr>
|
||||||
<th class="text-left p-2">Hostname</th>
|
<th class="text-left p-2">Hostname</th>
|
||||||
@ -94,15 +121,23 @@
|
|||||||
<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">Zone</th>
|
<th class="p-2">Zone</th>
|
||||||
|
<th class="p-2">État</th>
|
||||||
</tr></thead>
|
</tr></thead>
|
||||||
<tbody>
|
<tbody id="noagent-body">
|
||||||
{% for s in no_agent_servers %}
|
{% for s in no_agent_servers %}
|
||||||
<tr>
|
<tr x-show="
|
||||||
|
(fHost === '' || '{{ s.hostname }}'.toLowerCase().includes(fHost.toLowerCase()))
|
||||||
|
&& (fOs === '' || '{{ s.os_family or '' }}'.toLowerCase() === fOs.toLowerCase())
|
||||||
|
&& (fDom === '' || '{{ s.domain or '' }}' === fDom)
|
||||||
|
&& (fEnv === '' || '{{ s.env or '' }}' === fEnv)
|
||||||
|
&& (fEtat === '' || '{{ s.etat or '' }}' === fEtat)
|
||||||
|
">
|
||||||
<td class="p-2 font-mono text-cyber-accent">{{ s.hostname }}</td>
|
<td class="p-2 font-mono text-cyber-accent">{{ s.hostname }}</td>
|
||||||
<td class="p-2 text-center">{{ s.os_family or '-' }}</td>
|
<td class="p-2 text-center">{{ s.os_family or '-' }}</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">{% 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>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
@ -113,7 +148,10 @@
|
|||||||
<!-- Agents inactifs -->
|
<!-- Agents inactifs -->
|
||||||
{% if inactive_agents %}
|
{% if inactive_agents %}
|
||||||
<div id="inactive-list" class="card p-4 mb-4">
|
<div id="inactive-list" class="card p-4 mb-4">
|
||||||
<h3 class="text-sm font-bold text-cyber-red mb-3">* Agents inactifs ({{ inactive_agents|length }})</h3>
|
<div class="flex justify-between items-center mb-3">
|
||||||
|
<h3 class="text-sm font-bold text-cyber-red">* Agents inactifs ({{ inactive_agents|length }})</h3>
|
||||||
|
<a href="/qualys/agents/export-inactive" class="btn-sm bg-cyber-green text-black px-3 py-1 text-xs">Exporter CSV</a>
|
||||||
|
</div>
|
||||||
<div class="card p-3 mb-3 text-xs text-gray-400" style="background:#111827;">
|
<div class="card p-3 mb-3 text-xs text-gray-400" style="background:#111827;">
|
||||||
<b>* Légende :</b> Ces serveurs ont un agent Qualys installé mais qui ne communique plus avec le cloud Qualys.
|
<b>* Légende :</b> Ces serveurs ont un agent Qualys installé mais qui ne communique plus avec le cloud Qualys.
|
||||||
Causes possibles : serveur éteint, flux réseau bloqué (port 443 vers qualysagent.qualys.eu), agent crashé, ou OS non supporté (RHEL 5 EOL).
|
Causes possibles : serveur éteint, flux réseau bloqué (port 443 vers qualysagent.qualys.eu), agent crashé, ou OS non supporté (RHEL 5 EOL).
|
||||||
|
|||||||
136
app/templates/quickwin.html
Normal file
136
app/templates/quickwin.html
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}QuickWin{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold" style="color:#00d4ff">QuickWin</h1>
|
||||||
|
<p class="text-sm text-gray-500">Campagnes patching rapide — exclusions par serveur — hors-prod d'abord — pas de reboot nécessaire</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
{% if can_create %}
|
||||||
|
<a href="/quickwin/config" class="btn-sm" style="background:#1e3a5f;color:#00d4ff;padding:6px 16px">Config exclusions</a>
|
||||||
|
<button onclick="document.getElementById('createModal').style.display='flex'" class="btn-primary" style="padding:6px 16px;font-size:0.85rem">+ Nouveau QuickWin</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if msg %}
|
||||||
|
<div style="background:#1a5a2e;color:#8f8;padding:8px 16px;border-radius:6px;margin-bottom:12px;font-size:0.85rem">
|
||||||
|
{% if msg == 'deleted' %}Campagne supprimée{% elif msg == 'error' %}Erreur création{% elif msg == 'no_servers' %}Aucun serveur configuré{% else %}{{ msg }}{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- KPIs -->
|
||||||
|
<div class="grid grid-cols-4 gap-4 mb-6">
|
||||||
|
<div class="card p-4 text-center">
|
||||||
|
<div class="text-3xl font-bold" style="color:#00d4ff">{{ runs|length }}</div>
|
||||||
|
<div class="text-xs text-gray-500">Campagnes</div>
|
||||||
|
</div>
|
||||||
|
<div class="card p-4 text-center">
|
||||||
|
<div class="text-3xl font-bold" style="color:#00ff88">{{ config_count }}</div>
|
||||||
|
<div class="text-xs text-gray-500">Serveurs configurés</div>
|
||||||
|
</div>
|
||||||
|
<div class="card p-4 text-center">
|
||||||
|
<div class="text-3xl font-bold" style="color:#ffcc00">S{{ current_week }}</div>
|
||||||
|
<div class="text-xs text-gray-500">Semaine courante</div>
|
||||||
|
</div>
|
||||||
|
<div class="card p-4 text-center">
|
||||||
|
<div class="text-3xl font-bold" style="color:#fff">{{ current_year }}</div>
|
||||||
|
<div class="text-xs text-gray-500">Année</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Runs list -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="p-4 flex items-center justify-between" style="border-bottom:1px solid #1e3a5f">
|
||||||
|
<h2 class="text-sm font-bold" style="color:#00d4ff">CAMPAGNES QUICKWIN</h2>
|
||||||
|
<span class="text-xs text-gray-500">{{ runs|length }} campagne(s)</span>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="table-cyber w-full">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="px-3 py-2">ID</th>
|
||||||
|
<th class="px-3 py-2">Semaine</th>
|
||||||
|
<th class="px-3 py-2">Label</th>
|
||||||
|
<th class="px-3 py-2">Statut</th>
|
||||||
|
<th class="px-3 py-2">Créé par</th>
|
||||||
|
<th class="px-3 py-2">Serveurs</th>
|
||||||
|
<th class="px-3 py-2">H-Prod</th>
|
||||||
|
<th class="px-3 py-2">Prod</th>
|
||||||
|
<th class="px-3 py-2">Patchés</th>
|
||||||
|
<th class="px-3 py-2">KO</th>
|
||||||
|
<th class="px-3 py-2">Date</th>
|
||||||
|
<th class="px-3 py-2">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for r in runs %}
|
||||||
|
<tr onclick="window.location='/quickwin/{{ r.id }}'" style="cursor:pointer">
|
||||||
|
<td class="px-3 py-2" style="color:#00d4ff;font-weight:bold">#{{ r.id }}</td>
|
||||||
|
<td class="px-3 py-2">S{{ '%02d'|format(r.week_number) }} {{ r.year }}</td>
|
||||||
|
<td class="px-3 py-2">{{ r.label }}</td>
|
||||||
|
<td class="px-3 py-2">
|
||||||
|
{% if r.status == 'draft' %}<span class="badge badge-gray">Brouillon</span>
|
||||||
|
{% elif r.status == 'hprod_in_progress' %}<span class="badge badge-yellow">H-Prod en cours</span>
|
||||||
|
{% elif r.status == 'hprod_done' %}<span class="badge badge-blue">H-Prod terminé</span>
|
||||||
|
{% elif r.status == 'prod_in_progress' %}<span class="badge badge-yellow">Prod en cours</span>
|
||||||
|
{% elif r.status == 'completed' %}<span class="badge badge-green">Terminé</span>
|
||||||
|
{% elif r.status == 'cancelled' %}<span class="badge badge-red">Annulé</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-2 text-gray-400">{{ r.created_by_name or '?' }}</td>
|
||||||
|
<td class="px-3 py-2 text-center">{{ r.total_entries }}</td>
|
||||||
|
<td class="px-3 py-2 text-center">{{ r.hprod_count }}</td>
|
||||||
|
<td class="px-3 py-2 text-center">{{ r.prod_count }}</td>
|
||||||
|
<td class="px-3 py-2 text-center" style="color:#00ff88">{{ r.patched_count }}</td>
|
||||||
|
<td class="px-3 py-2 text-center" style="color:#ff3366">{{ r.failed_count }}</td>
|
||||||
|
<td class="px-3 py-2 text-gray-500 text-xs">{{ r.created_at.strftime('%d/%m %H:%M') if r.created_at else '' }}</td>
|
||||||
|
<td class="px-3 py-2">
|
||||||
|
<a href="/quickwin/{{ r.id }}" class="btn-sm" style="background:#1e3a5f;color:#00d4ff">Voir</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
{% if not runs %}
|
||||||
|
<tr><td colspan="12" class="px-3 py-8 text-center text-gray-500">Aucune campagne QuickWin</td></tr>
|
||||||
|
{% endif %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create modal -->
|
||||||
|
<div id="createModal" style="display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.7);z-index:100;align-items:center;justify-content:center" onclick="if(event.target===this)this.style.display='none'">
|
||||||
|
<div class="card" style="width:500px;max-width:90vw;padding:24px">
|
||||||
|
<h3 style="color:#00d4ff;font-size:1.1rem;font-weight:bold;margin-bottom:16px">Nouveau QuickWin</h3>
|
||||||
|
<form method="post" action="/quickwin/create">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="text-xs text-gray-400 block mb-1">Label</label>
|
||||||
|
<input type="text" name="label" placeholder="Quick Win S{{ '%02d'|format(current_week) }} {{ current_year }}" style="width:100%">
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-3 mb-3">
|
||||||
|
<div class="flex-1">
|
||||||
|
<label class="text-xs text-gray-400 block mb-1">Semaine</label>
|
||||||
|
<input type="number" name="week_number" value="{{ current_week }}" min="1" max="53" style="width:100%">
|
||||||
|
</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<label class="text-xs text-gray-400 block mb-1">Année</label>
|
||||||
|
<input type="number" name="year" value="{{ current_year }}" style="width:100%">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="text-xs text-gray-400 block mb-1">Serveurs (IDs, vide = tous les configurés)</label>
|
||||||
|
<input type="text" name="server_ids" placeholder="Laisser vide pour tous les serveurs configurés" style="width:100%">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="text-xs text-gray-400 block mb-1">Notes</label>
|
||||||
|
<textarea name="notes" rows="2" style="width:100%" placeholder="Commentaires..."></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2 justify-end mt-4">
|
||||||
|
<button type="button" class="btn-sm" style="background:#333;color:#ccc;padding:6px 16px" onclick="document.getElementById('createModal').style.display='none'">Annuler</button>
|
||||||
|
<button type="submit" class="btn-primary" style="padding:6px 20px">Créer</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
206
app/templates/quickwin_config.html
Normal file
206
app/templates/quickwin_config.html
Normal file
@ -0,0 +1,206 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}QuickWin Config{% endblock %}
|
||||||
|
|
||||||
|
{% macro qs(p) -%}
|
||||||
|
?page={{ p }}&per_page={{ per_page }}&search={{ filters.search or '' }}&env={{ filters.env or '' }}&domain={{ filters.domain or '' }}&zone={{ filters.zone or '' }}
|
||||||
|
{%- endmacro %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<a href="/quickwin" class="text-xs text-gray-500 hover:text-gray-300">← Retour QuickWin</a>
|
||||||
|
<h1 class="text-xl font-bold" style="color:#00d4ff">Exclusions par serveur</h1>
|
||||||
|
<p class="text-xs text-gray-500">Tous les serveurs Linux en_production / secops — exclusions générales par défaut pré-remplies — pas de reboot nécessaire</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2 items-center">
|
||||||
|
<span class="text-sm text-gray-400">{{ total_count }} serveur(s)</span>
|
||||||
|
<button onclick="document.getElementById('bulkModal').style.display='flex'" class="btn-primary" style="padding:6px 16px;font-size:0.85rem">Modifier en masse</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if msg %}
|
||||||
|
<div style="background:#1a5a2e;color:#8f8;padding:8px 16px;border-radius:6px;margin-bottom:12px;font-size:0.85rem">
|
||||||
|
{% if 'saved' in msg %}Configuration sauvegardée{% elif 'deleted' in msg %}Exclusions spécifiques retirées{% elif 'added' in msg %}{{ msg.split('_')[1] }} serveur(s) mis à jour{% elif 'bulk' in msg %}Mise à jour groupée OK{% else %}{{ msg }}{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Filtre -->
|
||||||
|
<form method="GET" class="card mb-4" style="padding:10px 16px;display:flex;gap:12px;align-items:center">
|
||||||
|
<input type="text" name="search" value="{{ filters.search or '' }}" placeholder="Recherche serveur..." style="width:200px">
|
||||||
|
<select name="env" onchange="this.form.submit()" style="width:140px">
|
||||||
|
<option value="">Tous env.</option>
|
||||||
|
{% set envs = all_configs|map(attribute='environnement')|select('string')|unique|sort %}
|
||||||
|
{% for e in envs %}<option value="{{ e }}" {% if filters.env == e %}selected{% endif %}>{{ e }}</option>{% endfor %}
|
||||||
|
</select>
|
||||||
|
<select name="domain" onchange="this.form.submit()" style="width:140px">
|
||||||
|
<option value="">Tous domaines</option>
|
||||||
|
{% set doms = all_configs|map(attribute='domaine')|select('string')|unique|sort %}
|
||||||
|
{% for d in doms %}<option value="{{ d }}" {% if filters.domain == d %}selected{% endif %}>{{ d }}</option>{% endfor %}
|
||||||
|
</select>
|
||||||
|
<select name="zone" onchange="this.form.submit()" style="width:100px">
|
||||||
|
<option value="">Zone</option>
|
||||||
|
{% set zones = all_configs|map(attribute='zone')|select('string')|unique|sort %}
|
||||||
|
{% for z in zones %}<option value="{{ z }}" {% if filters.zone == z %}selected{% endif %}>{{ z }}</option>{% endfor %}
|
||||||
|
</select>
|
||||||
|
<select name="per_page" onchange="this.form.submit()" style="width:140px">
|
||||||
|
<option value="">Affichage / page</option>
|
||||||
|
{% for n in [14, 25, 50, 100] %}<option value="{{ n }}" {% if per_page == n %}selected{% endif %}>{{ n }} par page</option>{% endfor %}
|
||||||
|
</select>
|
||||||
|
<button type="submit" class="btn-primary" style="padding:4px 14px;font-size:0.8rem">Filtrer</button>
|
||||||
|
<a href="/quickwin/config" class="text-xs text-gray-500 hover:text-gray-300">Reset</a>
|
||||||
|
<span class="text-xs text-gray-500">{{ total_count }} serveur(s)</span>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Cartouche detail serveur -->
|
||||||
|
<div id="srvDetail" class="card mb-4" style="display:none;border-left:3px solid #00d4ff;padding:12px 16px">
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<h3 style="color:#00d4ff;font-weight:bold;font-size:0.95rem" id="detailName"></h3>
|
||||||
|
<button onclick="document.getElementById('srvDetail').style.display='none'" class="text-gray-500 hover:text-gray-300" style="font-size:1.2rem">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<div class="text-xs text-gray-500 mb-1 font-bold">Exclusions générales (OS / reboot)</div>
|
||||||
|
<pre id="detailGeneral" style="font-size:0.7rem;color:#ffcc00;white-space:pre-wrap;margin:0"></pre>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-xs text-gray-500 mb-1 font-bold">Exclusions spécifiques (applicatifs — hors périmètre secops)</div>
|
||||||
|
<pre id="detailSpecific" style="font-size:0.7rem;color:#ff8800;white-space:pre-wrap;margin:0"></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tableau serveurs -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="table-cyber w-full" id="srvTable">
|
||||||
|
<thead><tr>
|
||||||
|
<th class="px-2 py-2" style="width:30px"><input type="checkbox" id="checkAll" onchange="toggleAll(this)"></th>
|
||||||
|
<th class="px-2 py-2">Serveur</th>
|
||||||
|
<th class="px-2 py-2">Domaine</th>
|
||||||
|
<th class="px-2 py-2">Env</th>
|
||||||
|
<th class="px-2 py-2">Zone</th>
|
||||||
|
<th class="px-2 py-2">Tier</th>
|
||||||
|
<th class="px-2 py-2">Exclusions générales</th>
|
||||||
|
<th class="px-2 py-2">Exclusions spécifiques</th>
|
||||||
|
<th class="px-2 py-2">Notes</th>
|
||||||
|
<th class="px-2 py-2" style="width:60px">Save</th>
|
||||||
|
<th class="px-2 py-2" style="width:60px">Cmd</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for s in all_servers %}
|
||||||
|
<tr>
|
||||||
|
<td class="px-2 py-2"><input type="checkbox" class="srv-check" value="{{ s.server_id }}"></td>
|
||||||
|
<td class="px-2 py-2 font-bold" style="color:#00d4ff;cursor:pointer" onclick="showDetail('{{ s.hostname }}', this)">{{ s.hostname }}</td>
|
||||||
|
<td class="px-2 py-2 text-gray-400 text-xs">{{ s.domaine or '?' }}</td>
|
||||||
|
<td class="px-2 py-2 text-gray-400 text-xs">{{ s.environnement or '?' }}</td>
|
||||||
|
<td class="px-2 py-2 text-center"><span class="badge {% if s.zone == 'DMZ' %}badge-red{% else %}badge-blue{% endif %}">{{ s.zone or 'LAN' }}</span></td>
|
||||||
|
<td class="px-2 py-2 text-gray-400 text-xs">{{ s.tier }}</td>
|
||||||
|
<td class="px-2 py-2">
|
||||||
|
<form method="post" action="/quickwin/config/save" class="inline-form" style="display:flex;gap:4px;align-items:center">
|
||||||
|
<input type="hidden" name="server_id" value="{{ s.server_id }}">
|
||||||
|
<input type="text" name="general_excludes" value="{{ s.general_excludes }}"
|
||||||
|
style="width:200px;font-size:0.7rem;padding:2px 6px" title="{{ s.general_excludes }}">
|
||||||
|
</td>
|
||||||
|
<td class="px-2 py-2">
|
||||||
|
<input type="text" name="specific_excludes" value="{{ s.specific_excludes }}"
|
||||||
|
style="width:150px;font-size:0.7rem;padding:2px 6px" placeholder="sdcss* custom*...">
|
||||||
|
</td>
|
||||||
|
<td class="px-2 py-2">
|
||||||
|
<input type="text" name="notes" value="{{ s.notes }}"
|
||||||
|
style="width:80px;font-size:0.7rem;padding:2px 6px" placeholder="...">
|
||||||
|
<button type="submit" class="btn-sm" style="background:#1e3a5f;color:#00d4ff;font-size:0.65rem">OK</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
<td class="px-2 py-2">
|
||||||
|
<button type="button" class="btn-sm" style="background:#1a3a1a;color:#00ff88;font-size:0.6rem;white-space:nowrap" onclick="showDryRun('{{ s.hostname }}', this)">Dry Run</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
{% if not all_servers %}<tr><td colspan="11" class="px-2 py-8 text-center text-gray-500">Aucun serveur trouvé</td></tr>{% endif %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
<div class="flex justify-between items-center mt-4 text-sm text-gray-500">
|
||||||
|
<span>Page {{ page }} / {{ total_pages }} — {{ total_count }} serveurs</span>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
{% if page > 1 %}<a href="{{ qs(page - 1) }}" class="btn-sm bg-cyber-border text-gray-300">Précédent</a>{% endif %}
|
||||||
|
{% if page < total_pages %}<a href="{{ qs(page + 1) }}" class="btn-sm bg-cyber-border text-gray-300">Suivant</a>{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bulk modal -->
|
||||||
|
<div id="bulkModal" style="display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.7);z-index:100;align-items:center;justify-content:center" onclick="if(event.target===this)this.style.display='none'">
|
||||||
|
<div class="card" style="width:550px;max-width:90vw;padding:24px">
|
||||||
|
<h3 style="color:#00d4ff;font-weight:bold;margin-bottom:12px">Modification groupée</h3>
|
||||||
|
<p class="text-xs text-gray-500 mb-3">Cochez les serveurs dans le tableau, puis appliquez les exclusions.</p>
|
||||||
|
<form method="post" action="/quickwin/config/bulk-add">
|
||||||
|
<input type="hidden" name="server_ids" id="bulkIds">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="text-xs text-gray-400 block mb-1">Exclusions générales</label>
|
||||||
|
<textarea name="general_excludes" rows="3" style="width:100%;font-size:0.75rem">{{ default_excludes }}</textarea>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2 justify-end">
|
||||||
|
<button type="button" class="btn-sm" style="background:#333;color:#ccc;padding:6px 16px" onclick="document.getElementById('bulkModal').style.display='none'">Annuler</button>
|
||||||
|
<button type="submit" class="btn-primary" style="padding:6px 20px" onclick="collectIds()">Appliquer</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Dry Run modal -->
|
||||||
|
<div id="dryRunModal" style="display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.7);z-index:100;align-items:center;justify-content:center" onclick="if(event.target===this)this.style.display='none'">
|
||||||
|
<div class="card" style="width:700px;max-width:90vw;padding:24px">
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<h3 style="color:#00ff88;font-weight:bold" id="dryRunTitle">Dry Run</h3>
|
||||||
|
<button id="copyBtn" onclick="copyDryRun()" class="btn-sm" style="background:#1e3a5f;color:#00d4ff;font-size:0.75rem;padding:4px 12px">Copier</button>
|
||||||
|
</div>
|
||||||
|
<pre id="dryRunCmd" style="background:#0a0e17;border:1px solid #1e3a5f;border-radius:6px;padding:12px;font-size:0.75rem;color:#00ff88;white-space:pre-wrap;word-break:break-all;max-height:400px;overflow-y:auto"></pre>
|
||||||
|
<div class="flex justify-end mt-3">
|
||||||
|
<button type="button" class="btn-sm" style="background:#333;color:#ccc;padding:6px 16px" onclick="document.getElementById('dryRunModal').style.display='none'">Fermer</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function showDetail(hostname, td) {
|
||||||
|
const tr = td.closest('tr');
|
||||||
|
const ge = tr.querySelector('input[name=general_excludes]').value.trim();
|
||||||
|
const se = tr.querySelector('input[name=specific_excludes]').value.trim();
|
||||||
|
document.getElementById('detailName').textContent = hostname;
|
||||||
|
document.getElementById('detailGeneral').textContent = ge ? ge.split(/\s+/).join('\n') : '(aucune)';
|
||||||
|
document.getElementById('detailSpecific').textContent = se ? se.split(/\s+/).join('\n') : '(aucune)';
|
||||||
|
const panel = document.getElementById('srvDetail');
|
||||||
|
panel.style.display = 'block';
|
||||||
|
panel.scrollIntoView({behavior: 'smooth', block: 'nearest'});
|
||||||
|
}
|
||||||
|
function showDryRun(hostname, btn) {
|
||||||
|
const tr = btn.closest('tr');
|
||||||
|
const ge = tr.querySelector('input[name=general_excludes]').value.trim();
|
||||||
|
const se = tr.querySelector('input[name=specific_excludes]').value.trim();
|
||||||
|
const all = (ge + ' ' + se).trim().split(/\s+/).filter(x => x);
|
||||||
|
const excludes = all.map(e => '--exclude=' + e).join(' \\\n ');
|
||||||
|
const cmd = 'yum update -y \\\n ' + excludes;
|
||||||
|
document.getElementById('dryRunTitle').textContent = 'Dry Run — ' + hostname;
|
||||||
|
document.getElementById('dryRunCmd').textContent = cmd;
|
||||||
|
document.getElementById('dryRunModal').style.display = 'flex';
|
||||||
|
}
|
||||||
|
function copyDryRun() {
|
||||||
|
const text = document.getElementById('dryRunCmd').textContent;
|
||||||
|
navigator.clipboard.writeText(text).then(() => {
|
||||||
|
const btn = document.getElementById('copyBtn');
|
||||||
|
btn.textContent = 'Copi\u00e9 !';
|
||||||
|
setTimeout(() => btn.textContent = 'Copier', 1500);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function toggleAll(cb) {
|
||||||
|
document.querySelectorAll('.srv-check').forEach(c => c.checked = cb.checked);
|
||||||
|
}
|
||||||
|
function collectIds() {
|
||||||
|
const ids = Array.from(document.querySelectorAll('.srv-check:checked')).map(c => c.value);
|
||||||
|
document.getElementById('bulkIds').value = ids.join(',');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
258
app/templates/quickwin_correspondance.html
Normal file
258
app/templates/quickwin_correspondance.html
Normal file
@ -0,0 +1,258 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Correspondance QuickWin #{{ run.id }}{% endblock %}
|
||||||
|
|
||||||
|
{% macro qs(pg=page) -%}
|
||||||
|
?page={{ pg }}&per_page={{ per_page }}&search={{ filters.search or '' }}&pair_filter={{ filters.pair_filter or '' }}&domain_filter={{ filters.domain_filter or '' }}&env_filter={{ filters.env_filter or '' }}
|
||||||
|
{%- endmacro %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<a href="/quickwin/{{ run.id }}" class="text-xs text-gray-500 hover:text-gray-300">← Retour campagne</a>
|
||||||
|
<h1 class="text-xl font-bold" style="color:#a78bfa">Correspondance H-Prod ↔ Prod</h1>
|
||||||
|
<p class="text-xs text-gray-500">{{ run.label }} — Appariement des serveurs hors-production avec leur homologue production</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2 items-center">
|
||||||
|
<form method="post" action="/quickwin/{{ run.id }}/correspondance/auto">
|
||||||
|
<button class="btn-primary" style="padding:5px 16px;font-size:0.85rem">Auto-apparier</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="/quickwin/{{ run.id }}/correspondance/clear-all"
|
||||||
|
onsubmit="return confirm('Supprimer tous les appariements ?')">
|
||||||
|
<button class="btn-sm btn-danger" style="padding:4px 12px">Tout effacer</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if msg %}
|
||||||
|
{% if msg == 'auto' %}
|
||||||
|
{% set am = request.query_params.get('am', '0') %}
|
||||||
|
{% set au = request.query_params.get('au', '0') %}
|
||||||
|
{% set aa = request.query_params.get('aa', '0') %}
|
||||||
|
<div style="background:#1a5a2e;color:#8f8;padding:8px 16px;border-radius:6px;margin-bottom:12px;font-size:0.85rem">
|
||||||
|
Auto-appariement terminé : {{ am }} apparié(s), {{ au }} sans homologue, {{ aa }} anomalie(s)
|
||||||
|
</div>
|
||||||
|
{% elif msg == 'cleared' %}
|
||||||
|
<div style="background:#5a3a1a;color:#ffcc00;padding:8px 16px;border-radius:6px;margin-bottom:12px;font-size:0.85rem">
|
||||||
|
Tous les appariements ont été supprimés.
|
||||||
|
</div>
|
||||||
|
{% elif msg == 'bulk' %}
|
||||||
|
{% set bc = request.query_params.get('bc', '0') %}
|
||||||
|
<div style="background:#1a5a2e;color:#8f8;padding:8px 16px;border-radius:6px;margin-bottom:12px;font-size:0.85rem">
|
||||||
|
{{ bc }} appariement(s) modifié(s) en masse.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- KPIs -->
|
||||||
|
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin-bottom:16px">
|
||||||
|
<div class="card p-3 text-center">
|
||||||
|
<div class="text-2xl font-bold" style="color:#fff">{{ stats.total }}</div>
|
||||||
|
<div class="text-xs text-gray-500">Total H-Prod</div>
|
||||||
|
</div>
|
||||||
|
<div class="card p-3 text-center">
|
||||||
|
<div class="text-2xl font-bold" style="color:#00ff88">{{ stats.matched }}</div>
|
||||||
|
<div class="text-xs text-gray-500">Appariés</div>
|
||||||
|
</div>
|
||||||
|
<div class="card p-3 text-center">
|
||||||
|
<div class="text-2xl font-bold" style="color:#ffcc00">{{ stats.unmatched }}</div>
|
||||||
|
<div class="text-xs text-gray-500">Sans homologue</div>
|
||||||
|
</div>
|
||||||
|
<div class="card p-3 text-center">
|
||||||
|
<div class="text-2xl font-bold" style="color:#ff3366">{{ stats.anomalies }}</div>
|
||||||
|
<div class="text-xs text-gray-500">Anomalies</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filtres -->
|
||||||
|
<form method="GET" class="card mb-4" style="padding:10px 16px;display:flex;gap:12px;align-items:center;flex-wrap:wrap">
|
||||||
|
<input type="text" name="search" value="{{ filters.search or '' }}" placeholder="Recherche hostname..." style="width:200px">
|
||||||
|
<select name="pair_filter" onchange="this.form.submit()" style="width:160px">
|
||||||
|
<option value="">Tous</option>
|
||||||
|
<option value="matched" {% if filters.pair_filter == 'matched' %}selected{% endif %}>Appariés</option>
|
||||||
|
<option value="unmatched" {% if filters.pair_filter == 'unmatched' %}selected{% endif %}>Sans homologue</option>
|
||||||
|
<option value="anomaly" {% if filters.pair_filter == 'anomaly' %}selected{% endif %}>Anomalies</option>
|
||||||
|
</select>
|
||||||
|
<select name="domain_filter" onchange="this.form.submit()" style="width:150px">
|
||||||
|
<option value="">Tous domaines</option>
|
||||||
|
{% for d in domains_in_run %}
|
||||||
|
<option value="{{ d }}" {% if filters.domain_filter == d %}selected{% endif %}>{{ d }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<select name="env_filter" onchange="this.form.submit()" style="width:140px">
|
||||||
|
<option value="">Tous envs</option>
|
||||||
|
<option value="preprod" {% if filters.env_filter == 'preprod' %}selected{% endif %}>Pré-Prod</option>
|
||||||
|
<option value="recette" {% if filters.env_filter == 'recette' %}selected{% endif %}>Recette</option>
|
||||||
|
<option value="dev" {% if filters.env_filter == 'dev' %}selected{% endif %}>Développement</option>
|
||||||
|
<option value="test" {% if filters.env_filter == 'test' %}selected{% endif %}>Test</option>
|
||||||
|
</select>
|
||||||
|
<select name="per_page" onchange="this.form.submit()" style="width:70px">
|
||||||
|
{% for n in [20,50,100,200] %}
|
||||||
|
<option value="{{ n }}" {% if per_page == n %}selected{% endif %}>{{ n }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<button type="submit" class="btn-primary" style="padding:4px 14px;font-size:0.8rem">Filtrer</button>
|
||||||
|
<a href="/quickwin/{{ run.id }}/correspondance" class="text-xs text-gray-500 hover:text-gray-300">Reset</a>
|
||||||
|
<span class="text-xs text-gray-500" style="margin-left:auto">{{ total_filtered }} résultat(s)</span>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Actions en masse -->
|
||||||
|
<div class="card mb-3" style="padding:8px 16px;display:flex;gap:10px;align-items:center">
|
||||||
|
<span class="text-xs text-gray-400"><span id="sel-count">0</span> sélectionné(s)</span>
|
||||||
|
<button class="btn-sm" style="background:#ff336622;color:#ff3366;padding:3px 12px" onclick="bulkClear()">Dissocier la sélection</button>
|
||||||
|
<span style="color:#1e3a5f">|</span>
|
||||||
|
<span class="text-xs text-gray-400">Associer la sélection à :</span>
|
||||||
|
<select id="bulk-prod" style="width:220px;font-size:0.8rem;padding:3px 6px">
|
||||||
|
<option value="">-- Serveur prod --</option>
|
||||||
|
{% for a in available %}
|
||||||
|
<option value="{{ a.id }}">{{ a.hostname }}{% if a.domaine %} ({{ a.domaine }}){% endif %}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<button class="btn-sm" style="background:#00ff8822;color:#00ff88;padding:3px 12px" onclick="bulkAssign()">Associer</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Table -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="table-wrap" style="max-height:65vh;overflow-y:auto">
|
||||||
|
<table class="table-cyber w-full">
|
||||||
|
<thead style="position:sticky;top:0;z-index:1"><tr>
|
||||||
|
<th class="px-1 py-2" style="width:28px"><input type="checkbox" id="check-all" title="Tout"></th>
|
||||||
|
<th class="px-2 py-2" style="width:160px">Serveur H-Prod</th>
|
||||||
|
<th class="px-2 py-2" style="width:100px">Domaine</th>
|
||||||
|
<th class="px-2 py-2" style="width:90px">Env</th>
|
||||||
|
<th class="px-2 py-2" style="width:160px">Candidat auto</th>
|
||||||
|
<th class="px-2 py-2" style="width:50px">Statut</th>
|
||||||
|
<th class="px-2 py-2">Serveur Prod apparié</th>
|
||||||
|
<th class="px-2 py-2" style="width:100px">Domaine Prod</th>
|
||||||
|
<th class="px-2 py-2" style="width:80px">Action</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for p in pairs %}
|
||||||
|
<tr id="row-{{ p.hprod_id }}" style="{% if p.is_anomaly %}background:#ff336610{% elif not p.is_matched %}background:#ffcc0008{% endif %}">
|
||||||
|
<td class="px-1 py-2"><input type="checkbox" class="row-check" value="{{ p.hprod_id }}"></td>
|
||||||
|
<td class="px-2 py-2 font-bold" style="color:#00d4ff">{{ p.hprod_hostname }}</td>
|
||||||
|
<td class="px-2 py-2 text-xs text-gray-400">{{ p.hprod_domaine }}</td>
|
||||||
|
<td class="px-2 py-2 text-xs">
|
||||||
|
{% if p.is_anomaly %}<span class="badge badge-red" title="Lettre 'p' mais classé hprod">{{ p.hprod_env or '?' }}</span>
|
||||||
|
{% else %}<span class="text-gray-400">{{ p.hprod_env }}</span>{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="px-2 py-2 text-xs text-gray-500">{{ p.candidate }}</td>
|
||||||
|
<td class="px-2 py-2 text-center">
|
||||||
|
{% if p.is_matched %}<span class="badge badge-green">OK</span>
|
||||||
|
{% elif p.is_anomaly %}<span class="badge badge-red">!</span>
|
||||||
|
{% else %}<span class="badge badge-yellow">--</span>{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="px-2 py-2">
|
||||||
|
{% if p.is_matched %}
|
||||||
|
<span style="color:#00ff88;font-weight:600">{{ p.prod_hostname }}</span>
|
||||||
|
{% else %}
|
||||||
|
<select class="prod-select" data-hprod="{{ p.hprod_id }}" style="width:100%;font-size:0.8rem;padding:3px 6px">
|
||||||
|
<option value="">-- Choisir serveur prod --</option>
|
||||||
|
{% for a in available %}
|
||||||
|
<option value="{{ a.id }}">{{ a.hostname }}{% if a.domaine %} ({{ a.domaine }}){% endif %}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="px-2 py-2 text-xs text-gray-400">
|
||||||
|
{% if p.is_matched %}{{ p.prod_domaine }}{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="px-2 py-2 text-center">
|
||||||
|
{% if p.is_matched %}
|
||||||
|
<button class="btn-sm" style="background:#ff336622;color:#ff3366;padding:2px 8px"
|
||||||
|
onclick="clearPair({{ p.hprod_id }})">X</button>
|
||||||
|
{% else %}
|
||||||
|
<button class="btn-sm" style="background:#00ff8822;color:#00ff88;padding:2px 8px"
|
||||||
|
onclick="setPairFromSelect({{ p.hprod_id }})">OK</button>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
{% if not pairs %}
|
||||||
|
<tr><td colspan="9" class="px-2 py-8 text-center text-gray-500">Aucun résultat{% if filters.search or filters.pair_filter %} pour ces filtres{% endif %}</td></tr>
|
||||||
|
{% endif %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
{% if total_pages > 1 %}
|
||||||
|
<div style="display:flex;justify-content:center;gap:6px;margin-top:12px">
|
||||||
|
{% if page > 1 %}
|
||||||
|
<a href="/quickwin/{{ run.id }}/correspondance{{ qs(page - 1) }}" class="btn-sm" style="background:#1e3a5f;color:#94a3b8;padding:4px 10px">←</a>
|
||||||
|
{% endif %}
|
||||||
|
{% for pg in range(1, total_pages + 1) %}
|
||||||
|
{% if pg == page %}
|
||||||
|
<span class="btn-sm" style="background:#00d4ff;color:#0a0e17;padding:4px 10px;font-weight:bold">{{ pg }}</span>
|
||||||
|
{% elif pg <= 3 or pg >= total_pages - 1 or (pg >= page - 1 and pg <= page + 1) %}
|
||||||
|
<a href="/quickwin/{{ run.id }}/correspondance{{ qs(pg) }}" class="btn-sm" style="background:#1e3a5f;color:#94a3b8;padding:4px 10px">{{ pg }}</a>
|
||||||
|
{% elif pg == 4 or pg == total_pages - 2 %}
|
||||||
|
<span class="text-gray-500" style="padding:4px 4px">…</span>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% if page < total_pages %}
|
||||||
|
<a href="/quickwin/{{ run.id }}/correspondance{{ qs(page + 1) }}" class="btn-sm" style="background:#1e3a5f;color:#94a3b8;padding:4px 10px">→</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<script>
|
||||||
|
/* ---- Select all / count ---- */
|
||||||
|
const checkAll = document.getElementById('check-all');
|
||||||
|
if (checkAll) {
|
||||||
|
checkAll.addEventListener('change', function() {
|
||||||
|
document.querySelectorAll('.row-check').forEach(cb => cb.checked = this.checked);
|
||||||
|
updateSelCount();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
document.querySelectorAll('.row-check').forEach(cb => cb.addEventListener('change', updateSelCount));
|
||||||
|
function updateSelCount() {
|
||||||
|
document.getElementById('sel-count').textContent = document.querySelectorAll('.row-check:checked').length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Single actions ---- */
|
||||||
|
function setPairFromSelect(hprodId) {
|
||||||
|
const sel = document.querySelector('select[data-hprod="' + hprodId + '"]');
|
||||||
|
if (!sel) return;
|
||||||
|
const prodId = parseInt(sel.value);
|
||||||
|
if (!prodId) { alert('Choisissez un serveur prod'); return; }
|
||||||
|
apiSetPair(hprodId, prodId).then(() => location.reload());
|
||||||
|
}
|
||||||
|
function clearPair(hprodId) {
|
||||||
|
if (!confirm('Dissocier cet appariement ?')) return;
|
||||||
|
apiSetPair(hprodId, 0).then(() => location.reload());
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Bulk actions ---- */
|
||||||
|
function getSelected() {
|
||||||
|
return [...document.querySelectorAll('.row-check:checked')].map(cb => parseInt(cb.value));
|
||||||
|
}
|
||||||
|
function bulkClear() {
|
||||||
|
const ids = getSelected();
|
||||||
|
if (!ids.length) { alert('Aucune ligne sélectionnée'); return; }
|
||||||
|
if (!confirm('Dissocier ' + ids.length + ' appariement(s) ?')) return;
|
||||||
|
Promise.all(ids.map(id => apiSetPair(id, 0))).then(() => {
|
||||||
|
location.href = '/quickwin/{{ run.id }}/correspondance?msg=bulk&bc=' + ids.length;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function bulkAssign() {
|
||||||
|
const ids = getSelected();
|
||||||
|
if (!ids.length) { alert('Aucune ligne sélectionnée'); return; }
|
||||||
|
const prodId = parseInt(document.getElementById('bulk-prod').value);
|
||||||
|
if (!prodId) { alert('Choisissez un serveur prod'); return; }
|
||||||
|
if (!confirm('Associer ' + ids.length + ' serveur(s) au même prod ?')) return;
|
||||||
|
Promise.all(ids.map(id => apiSetPair(id, prodId))).then(() => {
|
||||||
|
location.href = '/quickwin/{{ run.id }}/correspondance?msg=bulk&bc=' + ids.length;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- API call ---- */
|
||||||
|
function apiSetPair(hprodId, prodId) {
|
||||||
|
return fetch('/api/quickwin/correspondance/set-pair', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({hprod_id: hprodId, prod_id: prodId})
|
||||||
|
}).then(r => r.json());
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
890
app/templates/quickwin_detail.html
Normal file
890
app/templates/quickwin_detail.html
Normal file
@ -0,0 +1,890 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}QuickWin #{{ run.id }}{% endblock %}
|
||||||
|
|
||||||
|
{% set STEPS = [
|
||||||
|
("draft", "Brouillon", "#94a3b8"),
|
||||||
|
("prereq", "Pr\u00e9requis", "#00d4ff"),
|
||||||
|
("snapshot", "Snapshot", "#a78bfa"),
|
||||||
|
("patching", "Patching", "#ffcc00"),
|
||||||
|
("result", "R\u00e9sultats", "#00ff88"),
|
||||||
|
("completed", "Termin\u00e9", "#10b981"),
|
||||||
|
] %}
|
||||||
|
{% set current_step_idx = namespace(val=0) %}
|
||||||
|
{% for s in STEPS %}{% if s[0] == run.status %}{% set current_step_idx.val = loop.index0 %}{% endif %}{% endfor %}
|
||||||
|
{% set can_modify = run.status in ('draft', 'prereq') %}
|
||||||
|
|
||||||
|
{% macro qs(hp=hp_page, pp=p_page) -%}
|
||||||
|
?hp_page={{ hp }}&p_page={{ pp }}&per_page={{ per_page }}&search={{ filters.search or '' }}&status={{ filters.status or '' }}&domain={{ filters.domain or '' }}
|
||||||
|
{%- endmacro %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<a href="/quickwin" class="text-xs text-gray-500 hover:text-gray-300">← Retour campagnes</a>
|
||||||
|
<h1 class="text-xl font-bold" style="color:#00d4ff">{{ run.label }}</h1>
|
||||||
|
<p class="text-xs text-gray-500">S{{ '%02d'|format(run.week_number) }} {{ run.year }} — Créé par {{ run.created_by_name or '?' }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2 items-center">
|
||||||
|
<a href="/quickwin/{{ run.id }}/correspondance" class="btn-sm" style="background:#1e3a5f;color:#a78bfa;padding:4px 14px;text-decoration:none">Correspondance</a>
|
||||||
|
<a href="/quickwin/{{ run.id }}/logs" class="btn-sm" style="background:#1e3a5f;color:#94a3b8;padding:4px 14px;text-decoration:none">Logs</a>
|
||||||
|
<form method="post" action="/quickwin/{{ run.id }}/delete" onsubmit="return confirm('Supprimer cette campagne ?')">
|
||||||
|
<button class="btn-sm btn-danger" style="padding:4px 12px">Supprimer</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if msg %}
|
||||||
|
<div style="background:#1a5a2e;color:#8f8;padding:8px 16px;border-radius:6px;margin-bottom:12px;font-size:0.85rem">{{ msg }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Step Progress Bar -->
|
||||||
|
<div class="card mb-4" style="padding:16px 20px">
|
||||||
|
<div style="display:flex;align-items:center;gap:0">
|
||||||
|
{% for step_id, step_label, step_color in STEPS %}
|
||||||
|
{% set idx = loop.index0 %}
|
||||||
|
{% set is_current = (step_id == run.status) %}
|
||||||
|
{% set is_done = (idx < current_step_idx.val) %}
|
||||||
|
<div style="flex:1;text-align:center;position:relative">
|
||||||
|
<div style="width:32px;height:32px;border-radius:50%;margin:0 auto 4px;display:flex;align-items:center;justify-content:center;font-weight:bold;font-size:0.8rem;
|
||||||
|
{% if is_done %}background:{{ step_color }};color:#0a0e17{% elif is_current %}background:{{ step_color }};color:#0a0e17;box-shadow:0 0 12px {{ step_color }}{% else %}background:#1e3a5f;color:#4a5568{% endif %}">
|
||||||
|
{% if is_done %}✓{% else %}{{ idx + 1 }}{% endif %}
|
||||||
|
</div>
|
||||||
|
<div style="font-size:0.7rem;{% if is_current %}color:{{ step_color }};font-weight:bold{% elif is_done %}color:#94a3b8{% else %}color:#4a5568{% endif %}">{{ step_label }}</div>
|
||||||
|
</div>
|
||||||
|
{% if not loop.last %}
|
||||||
|
<div style="flex:1;height:2px;{% if idx < current_step_idx.val %}background:{{ step_color }}{% else %}background:#1e3a5f{% endif %};margin-bottom:18px"></div>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;justify-content:space-between;margin-top:12px">
|
||||||
|
{% if current_step_idx.val > 0 %}
|
||||||
|
<form method="post" action="/quickwin/{{ run.id }}/advance">
|
||||||
|
<input type="hidden" name="target" value="{{ STEPS[current_step_idx.val - 1][0] }}">
|
||||||
|
<button class="btn-sm" style="background:#1e3a5f;color:#94a3b8;padding:4px 14px">← Étape précédente</button>
|
||||||
|
</form>
|
||||||
|
{% else %}<div></div>{% endif %}
|
||||||
|
{% if current_step_idx.val < 5 %}
|
||||||
|
<form method="post" action="/quickwin/{{ run.id }}/advance">
|
||||||
|
<input type="hidden" name="target" value="{{ STEPS[current_step_idx.val + 1][0] }}">
|
||||||
|
<button class="btn-primary" style="padding:4px 18px;font-size:0.85rem">Étape suivante : {{ STEPS[current_step_idx.val + 1][1] }} →</button>
|
||||||
|
</form>
|
||||||
|
{% else %}<div></div>{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- KPIs -->
|
||||||
|
<div style="display:grid;grid-template-columns:repeat(6,1fr);gap:12px;margin-bottom:20px">
|
||||||
|
<div class="card p-3 text-center">
|
||||||
|
<div class="text-2xl font-bold" style="color:#fff">{{ stats.total }}</div>
|
||||||
|
<div class="text-xs text-gray-500">Total</div>
|
||||||
|
</div>
|
||||||
|
<div class="card p-3 text-center">
|
||||||
|
<div class="text-2xl font-bold" style="color:#00d4ff">{{ stats.hprod_total }}</div>
|
||||||
|
<div class="text-xs text-gray-500">H-Prod</div>
|
||||||
|
</div>
|
||||||
|
<div class="card p-3 text-center">
|
||||||
|
<div class="text-2xl font-bold" style="color:#ffcc00">{{ stats.prod_total }}</div>
|
||||||
|
<div class="text-xs text-gray-500">Prod</div>
|
||||||
|
</div>
|
||||||
|
<div class="card p-3 text-center">
|
||||||
|
<div class="text-2xl font-bold" style="color:#00ff88">{{ stats.patched }}</div>
|
||||||
|
<div class="text-xs text-gray-500">Patchés</div>
|
||||||
|
</div>
|
||||||
|
<div class="card p-3 text-center">
|
||||||
|
<div class="text-2xl font-bold" style="color:#ff3366">{{ stats.failed }}</div>
|
||||||
|
<div class="text-xs text-gray-500">KO</div>
|
||||||
|
</div>
|
||||||
|
<div class="card p-3 text-center">
|
||||||
|
<div class="text-2xl font-bold" style="color:#ff8800">{{ stats.reboot_count }}</div>
|
||||||
|
<div class="text-xs text-gray-500">Reboot</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ========== SCOPE SELECTOR (draft + prereq) ========== -->
|
||||||
|
{% if run.status == 'draft' and scope %}
|
||||||
|
<div class="card mb-4" style="border-left:3px solid {% if run.status == 'draft' %}#94a3b8{% else %}#00d4ff{% endif %}">
|
||||||
|
<div class="p-3" style="border-bottom:1px solid #1e3a5f">
|
||||||
|
<h3 style="color:{% if run.status == 'draft' %}#94a3b8{% else %}#00d4ff{% endif %};font-weight:bold;font-size:0.9rem">
|
||||||
|
Périmètre de la campagne
|
||||||
|
</h3>
|
||||||
|
<p class="text-xs text-gray-500 mt-1">Cochez les domaines et zones à inclure. Les serveurs hors périmètre seront marqués « Exclu ».</p>
|
||||||
|
</div>
|
||||||
|
<form method="post" action="/quickwin/{{ run.id }}/apply-scope" id="scope-form" style="padding:16px">
|
||||||
|
<input type="hidden" name="scope_domains" id="h-scope-domains" value="">
|
||||||
|
<input type="hidden" name="scope_zones" id="h-scope-zones" value="">
|
||||||
|
<div style="display:grid;grid-template-columns:2fr 1fr;gap:20px;margin-bottom:14px">
|
||||||
|
<!-- Domaines -->
|
||||||
|
<div>
|
||||||
|
<div class="text-xs font-bold text-gray-400 mb-2">DOMAINES</div>
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:2px;background:#0d1520;border-radius:6px;padding:8px 10px">
|
||||||
|
{% for d in scope.domains %}
|
||||||
|
{% set active = scope.dom_active.get(d, 0) %}
|
||||||
|
{% set total = scope.dom_counts.get(d, 0) %}
|
||||||
|
<label style="display:flex;align-items:center;gap:6px;padding:3px 0;cursor:pointer;font-size:0.82rem;color:#cbd5e1">
|
||||||
|
<input type="checkbox" class="scope-dom" value="{{ d }}" {% if active > 0 %}checked{% endif %}>
|
||||||
|
{{ d }}
|
||||||
|
<span style="color:#4a5568;font-size:0.7rem">({{ total }})</span>
|
||||||
|
{% if active > 0 and active < total %}<span style="color:#ffcc00;font-size:0.65rem">{{ active }} actifs</span>{% endif %}
|
||||||
|
</label>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Zones -->
|
||||||
|
<div>
|
||||||
|
<div class="text-xs font-bold text-gray-400 mb-2">ZONES</div>
|
||||||
|
<div style="background:#0d1520;border-radius:6px;padding:8px 10px">
|
||||||
|
{% for z in scope.zones %}
|
||||||
|
{% set active = scope.zone_active.get(z, 0) %}
|
||||||
|
{% set total = scope.zone_counts.get(z, 0) %}
|
||||||
|
<label style="display:flex;align-items:center;gap:6px;padding:3px 0;cursor:pointer;font-size:0.82rem;color:#cbd5e1">
|
||||||
|
<input type="checkbox" class="scope-zone" value="{{ z }}" {% if active > 0 %}checked{% endif %}>
|
||||||
|
{{ z }}
|
||||||
|
<span style="color:#4a5568;font-size:0.7rem">({{ total }})</span>
|
||||||
|
{% if active > 0 and active < total %}<span style="color:#ffcc00;font-size:0.65rem">{{ active }} actifs</span>{% endif %}
|
||||||
|
</label>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-3 items-center">
|
||||||
|
<button type="submit" class="btn-primary" style="padding:6px 20px;font-size:0.9rem"
|
||||||
|
onclick="return prepareScopeForm()">
|
||||||
|
Appliquer le périmètre
|
||||||
|
</button>
|
||||||
|
<span class="text-xs text-gray-500" id="scope-preview"></span>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Step-specific content -->
|
||||||
|
|
||||||
|
{% if run.status == 'prereq' %}
|
||||||
|
<div class="card mb-4" style="border-left:3px solid #00d4ff;padding:16px">
|
||||||
|
<h3 style="color:#00d4ff;font-weight:bold;margin-bottom:8px">Vérification des prérequis</h3>
|
||||||
|
<p class="text-xs text-gray-400 mb-3">Vérifie : résolution DNS, SSH (PSMP/Key), Satellite/YUM, espace disque (<90%)</p>
|
||||||
|
<div style="display:flex;gap:16px;margin-bottom:12px">
|
||||||
|
<div>
|
||||||
|
<span class="badge badge-green">{{ step_hp.prereq_ok }} OK</span>
|
||||||
|
<span class="badge badge-red">{{ step_hp.prereq_ko }} KO</span>
|
||||||
|
<span class="badge badge-gray">{{ step_hp.prereq_pending }} en attente</span>
|
||||||
|
<span class="text-xs text-gray-500 ml-2">(H-Prod)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2 mb-3">
|
||||||
|
<button id="btn-check-hprod" class="btn-primary" style="padding:6px 18px;font-size:0.85rem"
|
||||||
|
onclick="startPrereqStream('hprod')">Lancer check H-Prod</button>
|
||||||
|
{% if prod_ok %}
|
||||||
|
<button id="btn-check-prod" class="btn-primary" style="padding:6px 18px;font-size:0.85rem;background:#ffcc00;color:#0a0e17"
|
||||||
|
onclick="startPrereqStream('prod')">Lancer check Prod</button>
|
||||||
|
{% endif %}
|
||||||
|
<button id="btn-stop" style="display:none;background:#ff3366;color:#fff;padding:6px 18px;font-size:0.85rem;border-radius:6px;cursor:pointer"
|
||||||
|
onclick="stopPrereqStream()">Arrêter</button>
|
||||||
|
</div>
|
||||||
|
<!-- Terminal -->
|
||||||
|
<div id="prereq-terminal" style="display:none">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px">
|
||||||
|
<span id="prereq-progress" class="text-xs text-gray-400"></span>
|
||||||
|
<span id="prereq-stats" class="text-xs"></span>
|
||||||
|
</div>
|
||||||
|
<div style="background:#000;border:1px solid #1e3a5f;border-radius:6px;padding:8px;font-family:monospace;font-size:0.75rem;max-height:350px;overflow-y:auto;color:#8f8" id="prereq-log"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if run.status == 'snapshot' %}
|
||||||
|
<div class="card mb-4" style="border-left:3px solid #a78bfa;padding:16px">
|
||||||
|
<h3 style="color:#a78bfa;font-weight:bold;margin-bottom:8px">Snapshots VM</h3>
|
||||||
|
<p class="text-xs text-gray-400 mb-3">Connexion vSphere → recherche VM → snapshot automatique. Les serveurs physiques sont ignorés (vérifier backup Commvault).</p>
|
||||||
|
<div style="display:flex;gap:16px;margin-bottom:12px">
|
||||||
|
<div>
|
||||||
|
<span class="badge badge-green">{{ step_hp.snap_ok }} fait(s)</span>
|
||||||
|
<span class="badge badge-gray">{{ step_hp.snap_pending }} en attente</span>
|
||||||
|
<span class="text-xs text-gray-500 ml-2">(H-Prod)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2 mb-3" style="flex-wrap:wrap;align-items:center">
|
||||||
|
<button id="btn-snap-hprod" class="btn-primary" style="padding:6px 18px;font-size:0.85rem;background:#a78bfa;color:#0a0e17"
|
||||||
|
onclick="startSnapshotStream('hprod')">Prendre Snapshots H-Prod</button>
|
||||||
|
{% if prod_ok %}
|
||||||
|
<button id="btn-snap-prod" class="btn-primary" style="padding:6px 18px;font-size:0.85rem;background:#ffcc00;color:#0a0e17"
|
||||||
|
onclick="startSnapshotStream('prod')">Prendre Snapshots Prod</button>
|
||||||
|
{% endif %}
|
||||||
|
<form method="post" action="/quickwin/{{ run.id }}/snapshot/mark-all" style="display:inline">
|
||||||
|
<input type="hidden" name="branch" value="hprod">
|
||||||
|
<button class="btn-sm" style="background:#1e3a5f;color:#a78bfa;padding:4px 14px">Tout marquer fait (H-Prod)</button>
|
||||||
|
</form>
|
||||||
|
<button id="btn-snap-stop" style="display:none;background:#ff3366;color:#fff;padding:6px 18px;font-size:0.85rem;border-radius:6px;cursor:pointer"
|
||||||
|
onclick="stopSnapshotStream()">Arrêter</button>
|
||||||
|
<span class="text-xs text-gray-500" style="margin-left:8px">Ordre vCenter : H-Prod = Senlis → Nanterre → DR | Prod = Nanterre → Senlis → DR</span>
|
||||||
|
</div>
|
||||||
|
<!-- Terminal snapshot -->
|
||||||
|
<div id="snap-terminal" style="display:none">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px">
|
||||||
|
<span id="snap-progress" class="text-xs text-gray-400"></span>
|
||||||
|
<span id="snap-stats" class="text-xs"></span>
|
||||||
|
</div>
|
||||||
|
<div style="background:#000;border:1px solid #1e3a5f;border-radius:6px;padding:8px;font-family:monospace;font-size:0.75rem;max-height:350px;overflow-y:auto;color:#8f8" id="snap-log"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if run.status == 'patching' %}
|
||||||
|
|
||||||
|
{% if prod_ok and stats.prod_total > 0 %}
|
||||||
|
<!-- Prereq + Snapshot Prod (si pas encore faits) -->
|
||||||
|
<div class="card mb-4" style="border-left:3px solid #ff8800;padding:16px">
|
||||||
|
<h3 style="color:#ff8800;font-weight:bold;margin-bottom:8px">Préparation Production</h3>
|
||||||
|
<p class="text-xs text-gray-400 mb-3">Avant de patcher la prod, lancez les checks prereq et snapshots sur les serveurs production.</p>
|
||||||
|
<div class="flex gap-2 mb-3" style="flex-wrap:wrap;align-items:center">
|
||||||
|
<button id="btn-check-prod-p" class="btn-primary" style="padding:6px 18px;font-size:0.85rem;background:#ff8800;color:#0a0e17"
|
||||||
|
onclick="startPrereqStream('prod')">Check Prereq Prod</button>
|
||||||
|
<button id="btn-stop-prereq-prod" style="display:none;background:#ff3366;color:#fff;padding:6px 18px;font-size:0.85rem;border-radius:6px;cursor:pointer"
|
||||||
|
onclick="stopPrereqStream()">Arrêter</button>
|
||||||
|
<button id="btn-snap-prod-p" class="btn-primary" style="padding:6px 18px;font-size:0.85rem;background:#a78bfa;color:#0a0e17"
|
||||||
|
onclick="startSnapshotStream('prod')">Prendre Snapshots Prod</button>
|
||||||
|
<button id="btn-snap-stop-prod" style="display:none;background:#ff3366;color:#fff;padding:6px 18px;font-size:0.85rem;border-radius:6px;cursor:pointer"
|
||||||
|
onclick="stopSnapshotStream()">Arrêter</button>
|
||||||
|
<form method="post" action="/quickwin/{{ run.id }}/snapshot/mark-all">
|
||||||
|
<input type="hidden" name="branch" value="prod">
|
||||||
|
<button class="btn-sm" style="background:#a78bfa22;color:#a78bfa;padding:4px 14px">Tout marquer snap OK (prod)</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<!-- Terminal prereq prod -->
|
||||||
|
<div id="prereq-terminal" style="display:none;margin-bottom:12px">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px">
|
||||||
|
<span id="prereq-progress" class="text-xs text-gray-400"></span>
|
||||||
|
<span id="prereq-stats" class="text-xs"></span>
|
||||||
|
</div>
|
||||||
|
<div style="background:#000;border:1px solid #1e3a5f;border-radius:6px;padding:8px;font-family:monospace;font-size:0.75rem;max-height:350px;overflow-y:auto;color:#8f8" id="prereq-log"></div>
|
||||||
|
</div>
|
||||||
|
<!-- Terminal snapshot prod -->
|
||||||
|
<div id="snap-terminal" style="display:none">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px">
|
||||||
|
<span id="snap-progress" class="text-xs text-gray-400"></span>
|
||||||
|
<span id="snap-stats" class="text-xs"></span>
|
||||||
|
</div>
|
||||||
|
<div style="background:#000;border:1px solid #1e3a5f;border-radius:6px;padding:8px;font-family:monospace;font-size:0.75rem;max-height:350px;overflow-y:auto;color:#8f8" id="snap-log"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="card mb-4" style="border-left:3px solid #ffcc00;padding:16px">
|
||||||
|
<h3 style="color:#ffcc00;font-weight:bold;margin-bottom:8px">Exécution du patching</h3>
|
||||||
|
<p class="text-xs text-gray-400 mb-3">Étape 1 : Générer les commandes. Étape 2 : Vérifier. Étape 3 : Exécuter via SSH.</p>
|
||||||
|
|
||||||
|
<!-- Boutons generation -->
|
||||||
|
<div class="flex gap-2 mb-3" style="flex-wrap:wrap;align-items:center">
|
||||||
|
<form method="post" action="/quickwin/{{ run.id }}/build-commands">
|
||||||
|
<input type="hidden" name="branch" value="hprod">
|
||||||
|
<button class="btn-primary" style="padding:6px 18px;font-size:0.85rem;background:#ffcc00;color:#0a0e17">1. Générer commandes H-Prod</button>
|
||||||
|
</form>
|
||||||
|
{% if prod_ok %}
|
||||||
|
<form method="post" action="/quickwin/{{ run.id }}/build-commands">
|
||||||
|
<input type="hidden" name="branch" value="prod">
|
||||||
|
<button class="btn-primary" style="padding:6px 18px;font-size:0.85rem;background:#ff8800;color:#0a0e17">1. Générer commandes Prod</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
<button class="btn-sm" style="background:#1e3a5f;color:#ffcc00;padding:4px 14px" onclick="loadCommands('hprod')">Voir commandes H-Prod</button>
|
||||||
|
{% if prod_ok %}
|
||||||
|
<button class="btn-sm" style="background:#1e3a5f;color:#ff8800;padding:4px 14px" onclick="loadCommands('prod')">Voir commandes Prod</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tableau commandes (charge en JS) -->
|
||||||
|
<div id="cmd-panel" style="display:none;margin-bottom:16px">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
|
||||||
|
<h4 id="cmd-title" class="text-sm font-bold" style="color:#ffcc00"></h4>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button id="btn-exec-patch" class="btn-primary" style="padding:6px 20px;font-size:0.85rem;background:#ff3366;color:#fff"
|
||||||
|
onclick="confirmExec()">2. Exécuter les commandes</button>
|
||||||
|
<button id="btn-patch-stop" style="display:none;background:#ff3366;color:#fff;padding:6px 18px;font-size:0.85rem;border-radius:6px;cursor:pointer"
|
||||||
|
onclick="stopPatchStream()">Arrêter</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="max-height:250px;overflow-y:auto;background:#0a0e17;border:1px solid #1e3a5f;border-radius:6px">
|
||||||
|
<table class="table-cyber w-full" style="font-size:0.75rem">
|
||||||
|
<thead><tr>
|
||||||
|
<th class="px-2 py-1" style="width:150px">Serveur</th>
|
||||||
|
<th class="px-2 py-1">Commande</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody id="cmd-tbody"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Terminal patching -->
|
||||||
|
<div id="patch-terminal" style="display:none">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px">
|
||||||
|
<span id="patch-progress" class="text-xs text-gray-400"></span>
|
||||||
|
<span id="patch-stats" class="text-xs"></span>
|
||||||
|
</div>
|
||||||
|
<div style="background:#000;border:1px solid #1e3a5f;border-radius:6px;padding:8px;font-family:monospace;font-size:0.75rem;max-height:400px;overflow-y:auto;color:#8f8" id="patch-log"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if run.status == 'result' %}
|
||||||
|
<div class="card mb-4" style="border-left:3px solid #00ff88;padding:16px">
|
||||||
|
<h3 style="color:#00ff88;font-weight:bold;margin-bottom:8px">Résultats</h3>
|
||||||
|
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:12px">
|
||||||
|
<div class="card p-3 text-center" style="border-color:#00ff88">
|
||||||
|
<div class="text-xl font-bold" style="color:#00ff88">{{ stats.patched }}</div>
|
||||||
|
<div class="text-xs text-gray-500">Patchés</div>
|
||||||
|
</div>
|
||||||
|
<div class="card p-3 text-center" style="border-color:#ff3366">
|
||||||
|
<div class="text-xl font-bold" style="color:#ff3366">{{ stats.failed }}</div>
|
||||||
|
<div class="text-xs text-gray-500">KO</div>
|
||||||
|
</div>
|
||||||
|
<div class="card p-3 text-center" style="border-color:#94a3b8">
|
||||||
|
<div class="text-xl font-bold" style="color:#94a3b8">{{ stats.pending }}</div>
|
||||||
|
<div class="text-xs text-gray-500">En attente</div>
|
||||||
|
</div>
|
||||||
|
<div class="card p-3 text-center" style="border-color:#ff8800">
|
||||||
|
<div class="text-xl font-bold" style="color:#ff8800">{{ stats.reboot_count }}</div>
|
||||||
|
<div class="text-xs text-gray-500">Reboot</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if run.status == 'completed' %}
|
||||||
|
<div class="card mb-4" style="border-left:3px solid #10b981;padding:16px">
|
||||||
|
<h3 style="color:#10b981;font-weight:bold;margin-bottom:8px">Campagne terminée</h3>
|
||||||
|
<p class="text-xs text-gray-400 mb-3">{{ stats.patched }} patché(s), {{ stats.failed }} KO, {{ stats.reboot_count }} reboot(s).</p>
|
||||||
|
<a href="/quickwin/{{ run.id }}/report" class="btn-primary" style="padding:6px 18px;font-size:0.85rem;display:inline-block;text-decoration:none">Télécharger le rapport</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Filtres table (contextuels selon l'etape) -->
|
||||||
|
<form method="GET" class="card mb-4" style="padding:10px 16px;display:flex;gap:12px;align-items:center;flex-wrap:wrap">
|
||||||
|
<input type="text" name="search" value="{{ filters.search or '' }}" placeholder="Recherche serveur..." style="width:200px">
|
||||||
|
<select name="domain" onchange="this.form.submit()" style="width:160px">
|
||||||
|
<option value="">Tous domaines</option>
|
||||||
|
{% set doms = entries|map(attribute='domaine')|select('string')|unique|sort %}
|
||||||
|
{% for d in doms %}<option value="{{ d }}" {% if filters.domain == d %}selected{% endif %}>{{ d }}</option>{% endfor %}
|
||||||
|
</select>
|
||||||
|
{% if run.status in ('prereq','snapshot','patching','result','completed') %}
|
||||||
|
<select name="prereq_filter" onchange="this.form.submit()" style="width:140px">
|
||||||
|
<option value="">Prereq: tous</option>
|
||||||
|
<option value="ok" {% if filters.prereq == 'ok' %}selected{% endif %}>Prereq OK</option>
|
||||||
|
<option value="ko" {% if filters.prereq == 'ko' %}selected{% endif %}>Prereq KO</option>
|
||||||
|
<option value="pending" {% if filters.prereq == 'pending' %}selected{% endif %}>Non vérifié</option>
|
||||||
|
</select>
|
||||||
|
{% endif %}
|
||||||
|
{% if run.status in ('snapshot','patching','result','completed') %}
|
||||||
|
<select name="snap_filter" onchange="this.form.submit()" style="width:130px">
|
||||||
|
<option value="">Snap: tous</option>
|
||||||
|
<option value="ok" {% if filters.snap == 'ok' %}selected{% endif %}>Snap fait</option>
|
||||||
|
<option value="pending" {% if filters.snap == 'pending' %}selected{% endif %}>Snap en attente</option>
|
||||||
|
</select>
|
||||||
|
{% endif %}
|
||||||
|
{% if run.status in ('patching','result','completed') %}
|
||||||
|
<select name="status" onchange="this.form.submit()" style="width:140px">
|
||||||
|
<option value="">Tous statuts</option>
|
||||||
|
<option value="pending" {% if filters.status == 'pending' %}selected{% endif %}>En attente</option>
|
||||||
|
<option value="patched" {% if filters.status == 'patched' %}selected{% endif %}>Patché</option>
|
||||||
|
<option value="failed" {% if filters.status == 'failed' %}selected{% endif %}>KO</option>
|
||||||
|
<option value="excluded" {% if filters.status == 'excluded' %}selected{% endif %}>Exclu</option>
|
||||||
|
<option value="skipped" {% if filters.status == 'skipped' %}selected{% endif %}>Ignoré</option>
|
||||||
|
</select>
|
||||||
|
{% endif %}
|
||||||
|
<select name="per_page" onchange="this.form.submit()" style="width:130px">
|
||||||
|
<option value="">Par page</option>
|
||||||
|
{% for n in [14, 25, 50, 100] %}<option value="{{ n }}" {% if per_page == n %}selected{% endif %}>{{ n }}</option>{% endfor %}
|
||||||
|
</select>
|
||||||
|
<button type="submit" class="btn-primary" style="padding:4px 14px;font-size:0.8rem">Filtrer</button>
|
||||||
|
<a href="/quickwin/{{ run.id }}" class="text-xs text-gray-500 hover:text-gray-300">Reset</a>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% if not prod_ok %}
|
||||||
|
<div class="card mb-4" style="border-left:3px solid #ff3366;padding:12px 16px">
|
||||||
|
<p style="color:#ff3366;font-size:0.85rem;font-weight:600">Hors-production d'abord : {{ stats.hprod_pending }} serveur(s) hprod en attente.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- ========== MACRO: Entry table ========== -->
|
||||||
|
{% macro entry_table(rows, branch_label, branch_color, branch_key, page_num, total_pages, total_count) %}
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="p-3 flex items-center justify-between" style="border-bottom:1px solid #1e3a5f">
|
||||||
|
<h2 class="text-sm font-bold" style="color:{{ branch_color }}">{{ branch_label }} ({{ total_count }})</h2>
|
||||||
|
<div class="flex gap-1 items-center">
|
||||||
|
<span class="badge badge-green">{{ rows|selectattr('status','eq','patched')|list|length }} OK</span>
|
||||||
|
<span class="badge badge-red">{{ rows|selectattr('status','eq','failed')|list|length }} KO</span>
|
||||||
|
<span class="badge badge-gray">{{ rows|selectattr('status','eq','pending')|list|length }} en attente</span>
|
||||||
|
{% if can_modify %}
|
||||||
|
<button class="btn-sm" style="background:#ff336622;color:#ff3366;padding:2px 10px;font-size:0.7rem;margin-left:8px"
|
||||||
|
onclick="removeSelected('{{ branch_key }}')">Supprimer sélection</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="table-cyber w-full">
|
||||||
|
<thead><tr>
|
||||||
|
{% if can_modify %}<th class="px-1 py-2" style="width:28px"><input type="checkbox" class="rm-check-all" data-branch="{{ branch_key }}" title="Tout"></th>{% endif %}
|
||||||
|
<th class="px-2 py-2">Serveur</th>
|
||||||
|
<th class="px-2 py-2">Domaine</th>
|
||||||
|
<th class="px-2 py-2">Env</th>
|
||||||
|
<th class="px-2 py-2">Statut</th>
|
||||||
|
{% if run.status in ('prereq','snapshot','patching','result','completed') %}
|
||||||
|
<th class="px-2 py-2">Prereq</th>
|
||||||
|
{% endif %}
|
||||||
|
{% if run.status in ('snapshot','patching','result','completed') %}
|
||||||
|
<th class="px-2 py-2">Snap</th>
|
||||||
|
{% endif %}
|
||||||
|
<th class="px-2 py-2">Exclusions gén.</th>
|
||||||
|
<th class="px-2 py-2">Exclusions spéc.</th>
|
||||||
|
<th class="px-2 py-2">Packages</th>
|
||||||
|
<th class="px-2 py-2">Date patch</th>
|
||||||
|
<th class="px-2 py-2">Reboot</th>
|
||||||
|
<th class="px-2 py-2">Notes</th>
|
||||||
|
{% if run.status == 'patching' %}
|
||||||
|
<th class="px-2 py-2">Action</th>
|
||||||
|
{% endif %}
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for e in rows %}
|
||||||
|
<tr data-id="{{ e.id }}">
|
||||||
|
{% if can_modify %}<td class="px-1 py-2"><input type="checkbox" class="rm-check rm-{{ branch_key }}" value="{{ e.id }}"></td>{% endif %}
|
||||||
|
<td class="px-2 py-2 font-bold" style="color:{{ branch_color }}">{{ e.hostname }}</td>
|
||||||
|
<td class="px-2 py-2 text-gray-400">{{ e.domaine or '?' }}</td>
|
||||||
|
<td class="px-2 py-2 text-gray-400">{{ e.environnement or '?' }}</td>
|
||||||
|
<td class="px-2 py-2">
|
||||||
|
{% if e.status == 'patched' %}<span class="badge badge-green">Patché</span>
|
||||||
|
{% elif e.status == 'failed' %}<span class="badge badge-red">KO</span>
|
||||||
|
{% elif e.status == 'in_progress' %}<span class="badge badge-yellow">En cours</span>
|
||||||
|
{% elif e.status == 'excluded' %}<span class="badge badge-gray">Exclu</span>
|
||||||
|
{% elif e.status == 'skipped' %}<span class="badge badge-gray">Ignoré</span>
|
||||||
|
{% else %}<span class="badge badge-gray">En attente</span>{% endif %}
|
||||||
|
</td>
|
||||||
|
{% if run.status in ('prereq','snapshot','patching','result','completed') %}
|
||||||
|
<td class="px-2 py-2 text-center">
|
||||||
|
{% if e.prereq_ok == true %}<span style="color:#00ff88" title="{{ e.prereq_detail }}">✓</span>
|
||||||
|
{% elif e.prereq_ok == false %}<span style="color:#ff3366" title="{{ e.prereq_detail }}">✗</span>
|
||||||
|
{% else %}<span style="color:#4a5568">—</span>{% endif %}
|
||||||
|
</td>
|
||||||
|
{% endif %}
|
||||||
|
{% if run.status in ('snapshot','patching','result','completed') %}
|
||||||
|
<td class="px-2 py-2 text-center">
|
||||||
|
{% if e.snap_done %}<span style="color:#a78bfa">✓</span>
|
||||||
|
{% else %}
|
||||||
|
{% if run.status == 'snapshot' %}
|
||||||
|
<form method="post" action="/quickwin/{{ run.id }}/snapshot/mark" style="display:inline">
|
||||||
|
<input type="hidden" name="entry_id" value="{{ e.id }}">
|
||||||
|
<input type="hidden" name="done" value="true">
|
||||||
|
<button class="btn-sm" style="background:#a78bfa22;color:#a78bfa;font-size:0.6rem">Fait</button>
|
||||||
|
</form>
|
||||||
|
{% else %}<span style="color:#4a5568">—</span>{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
{% endif %}
|
||||||
|
<td class="px-2 py-2 text-xs" style="color:#ffcc00;max-width:150px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="{{ e.general_excludes }}">
|
||||||
|
<span class="editable" data-id="{{ e.id }}" data-field="general_excludes">{{ e.general_excludes or '—' }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-2 py-2 text-xs" style="color:#ff8800;max-width:150px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="{{ e.specific_excludes }}">
|
||||||
|
<span class="editable" data-id="{{ e.id }}" data-field="specific_excludes">{{ e.specific_excludes or '—' }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-2 py-2 text-center">{{ e.patch_packages_count or '—' }}</td>
|
||||||
|
<td class="px-2 py-2 text-xs text-gray-500">{{ e.patch_date.strftime('%d/%m %H:%M') if e.patch_date else '—' }}</td>
|
||||||
|
<td class="px-2 py-2 text-center">{% if e.reboot_required %}<span style="color:#ff3366">OUI</span>{% else %}—{% endif %}</td>
|
||||||
|
<td class="px-2 py-2 text-xs text-gray-500">
|
||||||
|
<span class="editable" data-id="{{ e.id }}" data-field="notes">{{ e.notes or '—' }}</span>
|
||||||
|
</td>
|
||||||
|
{% if run.status == 'patching' %}
|
||||||
|
<td class="px-2 py-2">
|
||||||
|
{% if e.status == 'pending' and e.prereq_ok and e.snap_done %}
|
||||||
|
<div class="flex gap-1">
|
||||||
|
<form method="post" action="/quickwin/{{ run.id }}/mark-patched" style="display:inline">
|
||||||
|
<input type="hidden" name="entry_id" value="{{ e.id }}">
|
||||||
|
<input type="hidden" name="patch_status" value="patched">
|
||||||
|
<button class="btn-sm" style="background:#00ff8822;color:#00ff88;font-size:0.6rem">OK</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="/quickwin/{{ run.id }}/mark-patched" style="display:inline">
|
||||||
|
<input type="hidden" name="entry_id" value="{{ e.id }}">
|
||||||
|
<input type="hidden" name="patch_status" value="failed">
|
||||||
|
<button class="btn-sm" style="background:#ff336622;color:#ff3366;font-size:0.6rem">KO</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
{% endif %}
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
{% if not rows %}<tr><td colspan="16" class="px-2 py-6 text-center text-gray-500">Aucun serveur{% if filters.search or filters.status or filters.domain %} (filtre actif){% endif %}</td></tr>{% endif %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% if total_pages > 1 %}
|
||||||
|
<div class="flex justify-between items-center p-3 text-sm text-gray-500" style="border-top:1px solid #1e3a5f">
|
||||||
|
<span>Page {{ page_num }} / {{ total_pages }} — {{ total_count }} serveur(s)</span>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
{% if branch_key == 'hp' %}
|
||||||
|
{% if page_num > 1 %}<a href="{{ qs(hp=page_num - 1, pp=p_page) }}" class="btn-sm bg-cyber-border text-gray-300">Préc.</a>{% endif %}
|
||||||
|
{% if page_num < total_pages %}<a href="{{ qs(hp=page_num + 1, pp=p_page) }}" class="btn-sm bg-cyber-border text-gray-300">Suiv.</a>{% endif %}
|
||||||
|
{% else %}
|
||||||
|
{% if page_num > 1 %}<a href="{{ qs(hp=hp_page, pp=page_num - 1) }}" class="btn-sm bg-cyber-border text-gray-300">Préc.</a>{% endif %}
|
||||||
|
{% if page_num < total_pages %}<a href="{{ qs(hp=hp_page, pp=page_num + 1) }}" class="btn-sm bg-cyber-border text-gray-300">Suiv.</a>{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
<!-- H-PROD -->
|
||||||
|
{{ entry_table(hprod, "HORS-PRODUCTION", "#00d4ff", "hp", hp_page, hp_total_pages, hprod_total) }}
|
||||||
|
|
||||||
|
<!-- PROD -->
|
||||||
|
{% if prod_ok %}
|
||||||
|
{{ entry_table(prod, "PRODUCTION", "#ffcc00", "pr", p_page, p_total_pages, prod_total) }}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if run.notes %}
|
||||||
|
<div class="card p-4 mb-4">
|
||||||
|
<h3 class="text-xs font-bold text-gray-500 mb-2">NOTES</h3>
|
||||||
|
<p class="text-sm text-gray-300">{{ run.notes }}</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Hidden remove form -->
|
||||||
|
<form method="post" action="/quickwin/{{ run.id }}/remove-entries" id="remove-form" style="display:none">
|
||||||
|
<input type="hidden" name="entry_ids" id="remove-entry-ids" value="">
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
/* ---- Scope selector: collect checked domains/zones into hidden fields ---- */
|
||||||
|
function prepareScopeForm() {
|
||||||
|
const doms = [...document.querySelectorAll('.scope-dom:checked')].map(c => c.value);
|
||||||
|
const zones = [...document.querySelectorAll('.scope-zone:checked')].map(c => c.value);
|
||||||
|
if (!doms.length && !zones.length) {
|
||||||
|
alert('Sélectionnez au moins un domaine ou une zone');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
document.getElementById('h-scope-domains').value = doms.join(',');
|
||||||
|
document.getElementById('h-scope-zones').value = zones.join(',');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
/* ---- Scope: live preview of selection ---- */
|
||||||
|
document.querySelectorAll('.scope-dom, .scope-zone').forEach(cb => {
|
||||||
|
cb.addEventListener('change', function() {
|
||||||
|
const dc = document.querySelectorAll('.scope-dom:checked').length;
|
||||||
|
const dt = document.querySelectorAll('.scope-dom').length;
|
||||||
|
const zc = document.querySelectorAll('.scope-zone:checked').length;
|
||||||
|
const zt = document.querySelectorAll('.scope-zone').length;
|
||||||
|
const el = document.getElementById('scope-preview');
|
||||||
|
if (el) el.textContent = dc + '/' + dt + ' domaines, ' + zc + '/' + zt + ' zones';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ---- Add panel: select-all + prepare form ---- */
|
||||||
|
const addCheckAll = document.getElementById('add-check-all');
|
||||||
|
if (addCheckAll) {
|
||||||
|
addCheckAll.addEventListener('change', function() {
|
||||||
|
document.querySelectorAll('.add-check').forEach(cb => cb.checked = this.checked);
|
||||||
|
updateAddCount();
|
||||||
|
});
|
||||||
|
document.querySelectorAll('.add-check').forEach(cb => {
|
||||||
|
cb.addEventListener('change', updateAddCount);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function updateAddCount() {
|
||||||
|
const cnt = document.querySelectorAll('.add-check:checked').length;
|
||||||
|
const el = document.getElementById('add-count');
|
||||||
|
if (el) el.textContent = cnt;
|
||||||
|
}
|
||||||
|
function prepareAddForm() {
|
||||||
|
const ids = [...document.querySelectorAll('.add-check:checked')].map(cb => cb.value);
|
||||||
|
if (!ids.length) { alert('Aucun serveur s\u00e9lectionn\u00e9'); return false; }
|
||||||
|
document.getElementById('add-server-ids').value = ids.join(',');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Remove: select-all per branch + submit ---- */
|
||||||
|
document.querySelectorAll('.rm-check-all').forEach(masterCb => {
|
||||||
|
masterCb.addEventListener('change', function() {
|
||||||
|
const branch = this.dataset.branch;
|
||||||
|
document.querySelectorAll('.rm-' + branch).forEach(cb => cb.checked = this.checked);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
function removeSelected(branch) {
|
||||||
|
const ids = [...document.querySelectorAll('.rm-' + branch + ':checked')].map(cb => cb.value);
|
||||||
|
if (!ids.length) { alert('Aucun serveur s\u00e9lectionn\u00e9'); return; }
|
||||||
|
if (!confirm('Supprimer ' + ids.length + ' serveur(s) de la campagne ?')) return;
|
||||||
|
document.getElementById('remove-entry-ids').value = ids.join(',');
|
||||||
|
document.getElementById('remove-form').submit();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Inline edit ---- */
|
||||||
|
document.querySelectorAll('.editable').forEach(el => {
|
||||||
|
el.style.cursor = 'pointer';
|
||||||
|
el.addEventListener('dblclick', function() {
|
||||||
|
const field = this.dataset.field;
|
||||||
|
const id = this.dataset.id;
|
||||||
|
const current = this.textContent.trim() === '\u2014' ? '' : this.textContent.trim();
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.value = current;
|
||||||
|
input.style.cssText = 'background:#0a0e17;border:1px solid #00d4ff;color:#fff;padding:2px 6px;border-radius:4px;font-size:0.75rem;width:100%';
|
||||||
|
this.textContent = '';
|
||||||
|
this.appendChild(input);
|
||||||
|
input.focus();
|
||||||
|
input.select();
|
||||||
|
const save = () => {
|
||||||
|
const val = input.value.trim();
|
||||||
|
this.textContent = val || '\u2014';
|
||||||
|
fetch('/api/quickwin/entry/update', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({id: parseInt(id), field: field, value: val})
|
||||||
|
});
|
||||||
|
};
|
||||||
|
input.addEventListener('blur', save);
|
||||||
|
input.addEventListener('keydown', e => {
|
||||||
|
if (e.key === 'Enter') { e.preventDefault(); input.blur(); }
|
||||||
|
if (e.key === 'Escape') { this.textContent = current || '\u2014'; }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ---- SSE Prereq Terminal ---- */
|
||||||
|
let prereqSource = null;
|
||||||
|
function startPrereqStream(branch) {
|
||||||
|
const terminal = document.getElementById('prereq-terminal');
|
||||||
|
const log = document.getElementById('prereq-log');
|
||||||
|
const progress = document.getElementById('prereq-progress');
|
||||||
|
const stats = document.getElementById('prereq-stats');
|
||||||
|
const btnStop = document.getElementById('btn-stop');
|
||||||
|
|
||||||
|
terminal.style.display = 'block';
|
||||||
|
log.innerHTML = '';
|
||||||
|
const btnStop2 = document.getElementById('btn-stop-prereq-prod');
|
||||||
|
if (btnStop) btnStop.style.display = 'inline-block';
|
||||||
|
if (btnStop2) btnStop2.style.display = 'inline-block';
|
||||||
|
const btnHprod = document.getElementById('btn-check-hprod');
|
||||||
|
if (btnHprod) btnHprod.disabled = true;
|
||||||
|
const btnProd = document.getElementById('btn-check-prod');
|
||||||
|
if (btnProd) btnProd.disabled = true;
|
||||||
|
const btnProdP = document.getElementById('btn-check-prod-p');
|
||||||
|
if (btnProdP) btnProdP.disabled = true;
|
||||||
|
|
||||||
|
let okCount = 0, koCount = 0;
|
||||||
|
|
||||||
|
prereqSource = new EventSource('/quickwin/{{ run.id }}/prereq-stream?branch=' + branch);
|
||||||
|
prereqSource.onmessage = function(ev) {
|
||||||
|
const d = JSON.parse(ev.data);
|
||||||
|
if (d.type === 'start') {
|
||||||
|
addLine(log, '>>> Lancement check ' + d.branch + ' (' + d.total + ' serveurs)', '#00d4ff');
|
||||||
|
} else if (d.type === 'progress') {
|
||||||
|
progress.textContent = d.idx + '/' + d.total + ' — ' + d.hostname + '...';
|
||||||
|
} else if (d.type === 'result') {
|
||||||
|
const color = d.ok ? '#00ff88' : '#ff3366';
|
||||||
|
const icon = d.ok ? '\u2713' : '\u2717';
|
||||||
|
let line = icon + ' ' + d.hostname;
|
||||||
|
if (d.fqdn) line += ' (' + d.fqdn + ')';
|
||||||
|
line += ' DNS:' + (d.dns?'OK':'KO') + ' SSH:' + (d.ssh?'OK':'KO') + ' SAT:' + (d.sat?'OK':'KO') + ' DISK:' + (d.disk?'OK':'KO');
|
||||||
|
if (!d.ok && d.detail) line += '\n \u2514 ' + d.detail;
|
||||||
|
addLine(log, line, color);
|
||||||
|
if (d.ok) okCount++; else koCount++;
|
||||||
|
stats.innerHTML = '<span style="color:#00ff88">' + okCount + ' OK</span> — <span style="color:#ff3366">' + koCount + ' KO</span>';
|
||||||
|
progress.textContent = d.idx + '/' + d.total;
|
||||||
|
} else if (d.type === 'done') {
|
||||||
|
addLine(log, '\n>>> Termin\u00e9 : ' + d.ok + ' OK, ' + d.ko + ' KO sur ' + d.total, '#00d4ff');
|
||||||
|
stopPrereqStream();
|
||||||
|
progress.textContent = 'Termin\u00e9';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
prereqSource.onerror = function() {
|
||||||
|
addLine(log, '>>> Connexion interrompue', '#ff3366');
|
||||||
|
stopPrereqStream();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function stopPrereqStream() {
|
||||||
|
if (prereqSource) { prereqSource.close(); prereqSource = null; }
|
||||||
|
const btnStop = document.getElementById('btn-stop');
|
||||||
|
if (btnStop) btnStop.style.display = 'none';
|
||||||
|
const btnStop2 = document.getElementById('btn-stop-prereq-prod');
|
||||||
|
if (btnStop2) btnStop2.style.display = 'none';
|
||||||
|
const btnHprod = document.getElementById('btn-check-hprod');
|
||||||
|
if (btnHprod) btnHprod.disabled = false;
|
||||||
|
const btnProd = document.getElementById('btn-check-prod');
|
||||||
|
if (btnProd) btnProd.disabled = false;
|
||||||
|
const btnProdP = document.getElementById('btn-check-prod-p');
|
||||||
|
if (btnProdP) btnProdP.disabled = false;
|
||||||
|
}
|
||||||
|
function addLine(container, text, color) {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.style.color = color || '#ccc';
|
||||||
|
el.style.whiteSpace = 'pre-wrap';
|
||||||
|
el.style.wordBreak = 'break-all';
|
||||||
|
el.textContent = text;
|
||||||
|
container.appendChild(el);
|
||||||
|
container.scrollTop = container.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- SSE Snapshot Terminal ---- */
|
||||||
|
let snapSource = null;
|
||||||
|
function startSnapshotStream(branch) {
|
||||||
|
const terminal = document.getElementById('snap-terminal');
|
||||||
|
const log = document.getElementById('snap-log');
|
||||||
|
const progress = document.getElementById('snap-progress');
|
||||||
|
const stats = document.getElementById('snap-stats');
|
||||||
|
const btnStop = document.getElementById('btn-snap-stop');
|
||||||
|
|
||||||
|
terminal.style.display = 'block';
|
||||||
|
log.innerHTML = '';
|
||||||
|
const btnStop2 = document.getElementById('btn-snap-stop-prod');
|
||||||
|
if (btnStop) btnStop.style.display = 'inline-block';
|
||||||
|
if (btnStop2) btnStop2.style.display = 'inline-block';
|
||||||
|
const btnSnapHprod = document.getElementById('btn-snap-hprod');
|
||||||
|
if (btnSnapHprod) btnSnapHprod.disabled = true;
|
||||||
|
const btnProd = document.getElementById('btn-snap-prod');
|
||||||
|
if (btnProd) btnProd.disabled = true;
|
||||||
|
const btnProdP = document.getElementById('btn-snap-prod-p');
|
||||||
|
if (btnProdP) btnProdP.disabled = true;
|
||||||
|
|
||||||
|
let okCount = 0, koCount = 0;
|
||||||
|
|
||||||
|
snapSource = new EventSource('/quickwin/{{ run.id }}/snapshot-stream?branch=' + branch);
|
||||||
|
snapSource.onmessage = function(ev) {
|
||||||
|
const d = JSON.parse(ev.data);
|
||||||
|
if (d.type === 'start') {
|
||||||
|
addLine(log, '>>> Snapshots ' + d.branch + ' : ' + d.vms + ' VMs, ' + d.physical + ' physique(s) ignor\u00e9(s)', '#a78bfa');
|
||||||
|
if (d.physical > 0) {
|
||||||
|
addLine(log, '\u26a0 ' + d.physical + ' serveur(s) physique(s) : pas de snapshot VM. V\u00e9rifier les backups Commvault.', '#ffcc00');
|
||||||
|
}
|
||||||
|
} else if (d.type === 'progress') {
|
||||||
|
progress.textContent = d.idx + '/' + d.total + ' \u2014 ' + d.hostname + '...';
|
||||||
|
} else if (d.type === 'result') {
|
||||||
|
const color = d.ok ? '#00ff88' : '#ff3366';
|
||||||
|
const icon = d.ok ? '\u2713' : '\u2717';
|
||||||
|
let line = icon + ' ' + d.hostname;
|
||||||
|
if (d.vcenter) line += ' [' + d.vcenter + ']';
|
||||||
|
line += ' \u2014 ' + d.detail;
|
||||||
|
addLine(log, line, color);
|
||||||
|
if (d.ok) okCount++; else koCount++;
|
||||||
|
stats.innerHTML = '<span style="color:#00ff88">' + okCount + ' OK</span> \u2014 <span style="color:#ff3366">' + koCount + ' KO</span>';
|
||||||
|
progress.textContent = d.idx + '/' + d.total;
|
||||||
|
} else if (d.type === 'done') {
|
||||||
|
let summary = '\n>>> Termin\u00e9 : ' + d.ok + ' OK, ' + d.ko + ' KO sur ' + d.total + ' VMs';
|
||||||
|
if (d.physical > 0) summary += ' (' + d.physical + ' physiques ignor\u00e9s)';
|
||||||
|
addLine(log, summary, '#a78bfa');
|
||||||
|
stopSnapshotStream();
|
||||||
|
progress.textContent = 'Termin\u00e9';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
snapSource.onerror = function() {
|
||||||
|
addLine(log, '>>> Connexion interrompue', '#ff3366');
|
||||||
|
stopSnapshotStream();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function stopSnapshotStream() {
|
||||||
|
if (snapSource) { snapSource.close(); snapSource = null; }
|
||||||
|
const btnStop = document.getElementById('btn-snap-stop');
|
||||||
|
if (btnStop) btnStop.style.display = 'none';
|
||||||
|
const btnStop2 = document.getElementById('btn-snap-stop-prod');
|
||||||
|
if (btnStop2) btnStop2.style.display = 'none';
|
||||||
|
const btnSnapHprod = document.getElementById('btn-snap-hprod');
|
||||||
|
if (btnSnapHprod) btnSnapHprod.disabled = false;
|
||||||
|
const btnProd = document.getElementById('btn-snap-prod');
|
||||||
|
if (btnProd) btnProd.disabled = false;
|
||||||
|
const btnProdP = document.getElementById('btn-snap-prod-p');
|
||||||
|
if (btnProdP) btnProdP.disabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Patching: load commands + execute via SSE ---- */
|
||||||
|
let patchBranch = 'hprod';
|
||||||
|
function loadCommands(branch) {
|
||||||
|
patchBranch = branch;
|
||||||
|
fetch('/api/quickwin/{{ run.id }}/commands/' + branch)
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(cmds => {
|
||||||
|
const panel = document.getElementById('cmd-panel');
|
||||||
|
const tbody = document.getElementById('cmd-tbody');
|
||||||
|
const title = document.getElementById('cmd-title');
|
||||||
|
title.textContent = cmds.length + ' commande(s) ' + (branch === 'hprod' ? 'H-Prod' : 'Prod');
|
||||||
|
tbody.innerHTML = '';
|
||||||
|
if (!cmds.length) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="2" class="px-2 py-4 text-center text-gray-500">Aucune commande. G\u00e9n\u00e9rez d\'abord les commandes.</td></tr>';
|
||||||
|
}
|
||||||
|
cmds.forEach(c => {
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
tr.innerHTML = '<td class="px-2 py-1 font-bold" style="color:#00d4ff">' + c.hostname + '</td>'
|
||||||
|
+ '<td class="px-2 py-1" style="color:#ffcc00;font-family:monospace;word-break:break-all">' + c.command + '</td>';
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
});
|
||||||
|
panel.style.display = 'block';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function confirmExec() {
|
||||||
|
if (!confirm('ATTENTION : Ceci va ex\u00e9cuter yum update sur tous les serveurs ' + patchBranch.toUpperCase() + '.\n\nConfirmer l\'ex\u00e9cution ?')) return;
|
||||||
|
startPatchStream(patchBranch);
|
||||||
|
}
|
||||||
|
let patchSource = null;
|
||||||
|
function startPatchStream(branch) {
|
||||||
|
const terminal = document.getElementById('patch-terminal');
|
||||||
|
const log = document.getElementById('patch-log');
|
||||||
|
const progress = document.getElementById('patch-progress');
|
||||||
|
const stats = document.getElementById('patch-stats');
|
||||||
|
const btnStop = document.getElementById('btn-patch-stop');
|
||||||
|
const btnExec = document.getElementById('btn-exec-patch');
|
||||||
|
|
||||||
|
terminal.style.display = 'block';
|
||||||
|
log.innerHTML = '';
|
||||||
|
btnStop.style.display = 'inline-block';
|
||||||
|
btnExec.disabled = true;
|
||||||
|
|
||||||
|
let okCount = 0, koCount = 0;
|
||||||
|
|
||||||
|
patchSource = new EventSource('/quickwin/{{ run.id }}/patch-stream?branch=' + branch);
|
||||||
|
patchSource.onmessage = function(ev) {
|
||||||
|
const d = JSON.parse(ev.data);
|
||||||
|
if (d.type === 'start') {
|
||||||
|
addLine(log, '>>> Patching ' + d.branch + ' (' + d.total + ' serveurs)', '#ffcc00');
|
||||||
|
} else if (d.type === 'progress') {
|
||||||
|
const st = d.status === 'connecting' ? 'Connexion SSH...' : 'Ex\u00e9cution yum...';
|
||||||
|
progress.textContent = d.idx + '/' + d.total + ' \u2014 ' + d.hostname + ' \u2014 ' + st;
|
||||||
|
if (d.status === 'connecting') {
|
||||||
|
addLine(log, '\n[' + d.idx + '/' + d.total + '] ' + d.hostname + ' \u2014 connexion...', '#94a3b8');
|
||||||
|
} else {
|
||||||
|
addLine(log, ' \u2192 ' + d.command, '#ffcc00');
|
||||||
|
}
|
||||||
|
} else if (d.type === 'result') {
|
||||||
|
const color = d.ok ? '#00ff88' : '#ff3366';
|
||||||
|
const icon = d.ok ? '\u2713' : '\u2717';
|
||||||
|
let line = ' ' + icon + ' ' + d.hostname;
|
||||||
|
if (d.packages) line += ' (' + d.packages + ' paquets)';
|
||||||
|
if (d.reboot) line += ' [REBOOT REQUIS]';
|
||||||
|
if (d.exit_code !== undefined && d.exit_code !== 0) line += ' (exit ' + d.exit_code + ')';
|
||||||
|
addLine(log, line, color);
|
||||||
|
if (d.detail) {
|
||||||
|
const color2 = d.ok ? '#6b7280' : '#ff6688';
|
||||||
|
const lines = d.detail.split('\n').filter(l => l.trim());
|
||||||
|
lines.forEach(l => addLine(log, ' \u2502 ' + l.trim(), color2));
|
||||||
|
}
|
||||||
|
if (d.ok) okCount++; else koCount++;
|
||||||
|
stats.innerHTML = '<span style="color:#00ff88">' + okCount + ' OK</span> \u2014 <span style="color:#ff3366">' + koCount + ' KO</span>';
|
||||||
|
progress.textContent = d.idx + '/' + d.total;
|
||||||
|
} else if (d.type === 'done') {
|
||||||
|
addLine(log, '\n>>> Termin\u00e9 : ' + d.ok + ' OK, ' + d.ko + ' KO sur ' + d.total, '#ffcc00');
|
||||||
|
stopPatchStream();
|
||||||
|
progress.textContent = 'Termin\u00e9';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
patchSource.onerror = function() {
|
||||||
|
addLine(log, '>>> Connexion interrompue', '#ff3366');
|
||||||
|
stopPatchStream();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function stopPatchStream() {
|
||||||
|
if (patchSource) { patchSource.close(); patchSource = null; }
|
||||||
|
document.getElementById('btn-patch-stop').style.display = 'none';
|
||||||
|
document.getElementById('btn-exec-patch').disabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Auto-load commands if redirected with show_cmds ---- */
|
||||||
|
(function() {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const showBranch = params.get('show_cmds');
|
||||||
|
if (showBranch) loadCommands(showBranch);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
107
app/templates/quickwin_logs.html
Normal file
107
app/templates/quickwin_logs.html
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Logs QuickWin #{{ run.id }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<a href="/quickwin/{{ run.id }}" class="text-xs text-gray-500 hover:text-gray-300">← Retour campagne</a>
|
||||||
|
<h1 class="text-xl font-bold" style="color:#00d4ff">Logs — {{ run.label }}</h1>
|
||||||
|
<p class="text-xs text-gray-500">S{{ '%02d'|format(run.week_number) }} {{ run.year }} — {{ total_logs }} entrée(s)</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2 items-center">
|
||||||
|
<a href="/quickwin/{{ run.id }}" class="btn-sm" style="background:#1e3a5f;color:#94a3b8;padding:4px 14px">Campagne</a>
|
||||||
|
<form method="post" action="/quickwin/{{ run.id }}/logs/clear" onsubmit="return confirm('Supprimer tous les logs ?')">
|
||||||
|
<button class="btn-sm btn-danger" style="padding:4px 12px">Vider les logs</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if msg %}
|
||||||
|
<div style="background:#1a5a2e;color:#8f8;padding:8px 16px;border-radius:6px;margin-bottom:12px;font-size:0.85rem">{{ msg }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Stats -->
|
||||||
|
<div style="display:flex;gap:12px;margin-bottom:16px">
|
||||||
|
<div class="card p-3 text-center" style="flex:1">
|
||||||
|
<div class="text-xl font-bold" style="color:#fff">{{ total_logs }}</div>
|
||||||
|
<div class="text-xs text-gray-500">Total</div>
|
||||||
|
</div>
|
||||||
|
<div class="card p-3 text-center" style="flex:1">
|
||||||
|
<div class="text-xl font-bold" style="color:#00ff88">{{ log_stats.get('success', 0) }}</div>
|
||||||
|
<div class="text-xs text-gray-500">Success</div>
|
||||||
|
</div>
|
||||||
|
<div class="card p-3 text-center" style="flex:1">
|
||||||
|
<div class="text-xl font-bold" style="color:#00d4ff">{{ log_stats.get('info', 0) }}</div>
|
||||||
|
<div class="text-xs text-gray-500">Info</div>
|
||||||
|
</div>
|
||||||
|
<div class="card p-3 text-center" style="flex:1">
|
||||||
|
<div class="text-xl font-bold" style="color:#ffcc00">{{ log_stats.get('warn', 0) }}</div>
|
||||||
|
<div class="text-xs text-gray-500">Warn</div>
|
||||||
|
</div>
|
||||||
|
<div class="card p-3 text-center" style="flex:1">
|
||||||
|
<div class="text-xl font-bold" style="color:#ff3366">{{ log_stats.get('error', 0) }}</div>
|
||||||
|
<div class="text-xs text-gray-500">Error</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filtres -->
|
||||||
|
<form method="GET" class="card mb-4" style="padding:10px 16px;display:flex;gap:12px;align-items:center;flex-wrap:wrap">
|
||||||
|
<select name="level" onchange="this.form.submit()" style="width:130px">
|
||||||
|
<option value="">Tous niveaux</option>
|
||||||
|
<option value="success" {% if filters.level == 'success' %}selected{% endif %}>Success</option>
|
||||||
|
<option value="info" {% if filters.level == 'info' %}selected{% endif %}>Info</option>
|
||||||
|
<option value="warn" {% if filters.level == 'warn' %}selected{% endif %}>Warn</option>
|
||||||
|
<option value="error" {% if filters.level == 'error' %}selected{% endif %}>Error</option>
|
||||||
|
</select>
|
||||||
|
<select name="step" onchange="this.form.submit()" style="width:140px">
|
||||||
|
<option value="">Toutes étapes</option>
|
||||||
|
{% set steps_seen = logs|map(attribute='step')|unique|sort %}
|
||||||
|
{% for s in steps_seen %}<option value="{{ s }}" {% if filters.step == s %}selected{% endif %}>{{ s }}</option>{% endfor %}
|
||||||
|
</select>
|
||||||
|
<input type="text" name="hostname" value="{{ filters.hostname or '' }}" placeholder="Hostname..." style="width:180px">
|
||||||
|
<button type="submit" class="btn-primary" style="padding:4px 14px;font-size:0.8rem">Filtrer</button>
|
||||||
|
<a href="/quickwin/{{ run.id }}/logs" class="text-xs text-gray-500 hover:text-gray-300">Reset</a>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Logs table -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="table-wrap" style="max-height:70vh;overflow-y:auto">
|
||||||
|
<table class="table-cyber w-full">
|
||||||
|
<thead style="position:sticky;top:0;z-index:1"><tr>
|
||||||
|
<th class="px-2 py-2" style="width:140px">Date</th>
|
||||||
|
<th class="px-2 py-2" style="width:70px">Niveau</th>
|
||||||
|
<th class="px-2 py-2" style="width:80px">Étape</th>
|
||||||
|
<th class="px-2 py-2" style="width:120px">Hostname</th>
|
||||||
|
<th class="px-2 py-2">Message</th>
|
||||||
|
<th class="px-2 py-2" style="width:100px">Par</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for l in logs %}
|
||||||
|
<tr style="{% if l.level == 'error' %}background:#ff336610{% elif l.level == 'warn' %}background:#ffcc0008{% endif %}">
|
||||||
|
<td class="px-2 py-2 text-xs text-gray-500" style="white-space:nowrap">{{ l.created_at.strftime('%d/%m %H:%M:%S') }}</td>
|
||||||
|
<td class="px-2 py-2 text-center">
|
||||||
|
{% if l.level == 'success' %}<span class="badge badge-green">OK</span>
|
||||||
|
{% elif l.level == 'info' %}<span class="badge" style="background:#00d4ff22;color:#00d4ff">INFO</span>
|
||||||
|
{% elif l.level == 'warn' %}<span class="badge badge-yellow">WARN</span>
|
||||||
|
{% elif l.level == 'error' %}<span class="badge badge-red">ERR</span>
|
||||||
|
{% else %}<span class="badge badge-gray">{{ l.level }}</span>{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="px-2 py-2 text-xs text-gray-400">{{ l.step }}</td>
|
||||||
|
<td class="px-2 py-2 text-xs" style="color:#00d4ff">{{ l.hostname or '' }}</td>
|
||||||
|
<td class="px-2 py-2 text-sm">
|
||||||
|
{{ l.message }}
|
||||||
|
{% if l.detail %}
|
||||||
|
<div class="text-xs text-gray-500 mt-1" style="max-width:500px;word-break:break-all">{{ l.detail }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="px-2 py-2 text-xs text-gray-500">{{ l.created_by or '' }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
{% if not logs %}
|
||||||
|
<tr><td colspan="6" class="px-2 py-8 text-center text-gray-500">Aucun log{% if filters.level or filters.step or filters.hostname %} pour ces filtres{% endif %}</td></tr>
|
||||||
|
{% endif %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
408
app/templates/referentiel.html
Normal file
408
app/templates/referentiel.html
Normal file
@ -0,0 +1,408 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Référentiel{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-xl font-bold" style="color:#00d4ff">Référentiel</h1>
|
||||||
|
<p class="text-xs text-gray-500">Gestion centralisée des domaines, environnements, zones et associations</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% set msg = request.query_params.get('msg', '') %}
|
||||||
|
{% set detail = request.query_params.get('detail', '') %}
|
||||||
|
{% if msg == 'added' %}
|
||||||
|
<div style="background:#1a5a2e;color:#8f8;padding:8px 16px;border-radius:6px;margin-bottom:12px;font-size:0.85rem">
|
||||||
|
Elément ajouté avec succès.
|
||||||
|
</div>
|
||||||
|
{% elif msg == 'updated' %}
|
||||||
|
<div style="background:#1a5a2e;color:#8f8;padding:8px 16px;border-radius:6px;margin-bottom:12px;font-size:0.85rem">
|
||||||
|
Elément mis à jour.
|
||||||
|
</div>
|
||||||
|
{% elif msg == 'deleted' %}
|
||||||
|
<div style="background:#5a3a1a;color:#ffcc00;padding:8px 16px;border-radius:6px;margin-bottom:12px;font-size:0.85rem">
|
||||||
|
Elément supprimé.
|
||||||
|
</div>
|
||||||
|
{% elif msg == 'nodelete' %}
|
||||||
|
<div style="background:#5a1a1a;color:#ff3366;padding:8px 16px;border-radius:6px;margin-bottom:12px;font-size:0.85rem">
|
||||||
|
Suppression impossible : {{ detail }} serveur(s) lié(s). Dissociez-les d'abord.
|
||||||
|
</div>
|
||||||
|
{% elif msg == 'exists' %}
|
||||||
|
<div style="background:#5a3a1a;color:#ffcc00;padding:8px 16px;border-radius:6px;margin-bottom:12px;font-size:0.85rem">
|
||||||
|
Cette association domaine × environnement existe déjà.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Onglets -->
|
||||||
|
<div style="display:flex;gap:4px;margin-bottom:16px">
|
||||||
|
{% for t, label in [('domains','Domaines'), ('envs','Environnements'), ('assocs','Associations'), ('zones','Zones'), ('dns','Domaines DNS')] %}
|
||||||
|
<a href="/referentiel?tab={{ t }}"
|
||||||
|
style="padding:8px 20px;border-radius:8px 8px 0 0;font-size:0.85rem;font-weight:600;
|
||||||
|
{% if tab == t %}background:#1e3a5f;color:#00d4ff;border-bottom:2px solid #00d4ff{% else %}background:#111827;color:#94a3b8{% endif %}">
|
||||||
|
{{ label }}
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<!-- ONGLET DOMAINES -->
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
{% if tab == 'domains' %}
|
||||||
|
<div class="card">
|
||||||
|
<table class="table-cyber w-full">
|
||||||
|
<thead><tr>
|
||||||
|
<th class="px-2 py-2" style="width:40px">ID</th>
|
||||||
|
<th class="px-2 py-2">Nom</th>
|
||||||
|
<th class="px-2 py-2" style="width:70px">Code</th>
|
||||||
|
<th class="px-2 py-2">Description</th>
|
||||||
|
<th class="px-2 py-2" style="width:70px">Ordre</th>
|
||||||
|
<th class="px-2 py-2" style="width:70px">Actif</th>
|
||||||
|
<th class="px-2 py-2" style="width:80px">Serveurs</th>
|
||||||
|
<th class="px-2 py-2" style="width:140px">Actions</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for d in domains %}
|
||||||
|
<tr id="dom-row-{{ d.id }}">
|
||||||
|
<form method="post" action="/referentiel/domains/{{ d.id }}/edit">
|
||||||
|
<td class="px-2 py-2 text-xs text-gray-500">{{ d.id }}</td>
|
||||||
|
<td class="px-2 py-2"><input type="text" name="name" value="{{ d.name }}" style="width:100%" required></td>
|
||||||
|
<td class="px-2 py-2"><input type="text" name="code" value="{{ d.code }}" style="width:100%;text-transform:uppercase" required></td>
|
||||||
|
<td class="px-2 py-2"><input type="text" name="description" value="{{ d.description or '' }}" style="width:100%"></td>
|
||||||
|
<td class="px-2 py-2"><input type="number" name="display_order" value="{{ d.display_order }}" style="width:100%"></td>
|
||||||
|
<td class="px-2 py-2 text-center">
|
||||||
|
<input type="checkbox" name="is_active" {% if d.is_active %}checked{% endif %}>
|
||||||
|
</td>
|
||||||
|
<td class="px-2 py-2 text-center">
|
||||||
|
<span class="badge badge-blue">{{ dom_srv_counts.get(d.id, 0) }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-2 py-2 text-center" style="white-space:nowrap">
|
||||||
|
<input type="hidden" name="default_excludes" value="{{ d.default_excludes or '' }}">
|
||||||
|
<input type="hidden" name="default_patch_window" value="{{ d.default_patch_window or '' }}">
|
||||||
|
<button type="submit" class="btn-sm" style="background:#00d4ff22;color:#00d4ff;padding:3px 10px">Sauver</button>
|
||||||
|
</form>
|
||||||
|
{% if dom_srv_counts.get(d.id, 0) == 0 %}
|
||||||
|
<form method="post" action="/referentiel/domains/{{ d.id }}/delete" style="display:inline"
|
||||||
|
onsubmit="return confirm('Supprimer le domaine {{ d.name }} ?')">
|
||||||
|
<button type="submit" class="btn-sm" style="background:#ff336622;color:#ff3366;padding:3px 10px">Suppr</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if can_modify %}
|
||||||
|
<div class="card mt-3" style="padding:12px 16px">
|
||||||
|
<h3 class="text-sm font-bold mb-2" style="color:#00ff88">Ajouter un domaine</h3>
|
||||||
|
<form method="post" action="/referentiel/domains/add" style="display:flex;gap:10px;align-items:end;flex-wrap:wrap">
|
||||||
|
<div>
|
||||||
|
<label class="text-xs text-gray-500">Nom *</label>
|
||||||
|
<input type="text" name="name" required style="width:160px" placeholder="Infrastructure">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-xs text-gray-500">Code *</label>
|
||||||
|
<input type="text" name="code" required style="width:80px;text-transform:uppercase" placeholder="INF">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-xs text-gray-500">Description</label>
|
||||||
|
<input type="text" name="description" style="width:200px">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-xs text-gray-500">Excludes par défaut</label>
|
||||||
|
<input type="text" name="default_excludes" style="width:200px">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-xs text-gray-500">Fenêtre patch</label>
|
||||||
|
<input type="text" name="default_patch_window" style="width:120px" placeholder="mardi 22h">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-xs text-gray-500">Ordre</label>
|
||||||
|
<input type="number" name="display_order" value="0" style="width:60px">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn-primary" style="padding:6px 18px;font-size:0.85rem">Ajouter</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<!-- ONGLET ENVIRONNEMENTS -->
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
{% elif tab == 'envs' %}
|
||||||
|
<div class="card">
|
||||||
|
<table class="table-cyber w-full">
|
||||||
|
<thead><tr>
|
||||||
|
<th class="px-2 py-2" style="width:40px">ID</th>
|
||||||
|
<th class="px-2 py-2">Nom</th>
|
||||||
|
<th class="px-2 py-2" style="width:80px">Code</th>
|
||||||
|
<th class="px-2 py-2" style="width:80px">Serveurs</th>
|
||||||
|
<th class="px-2 py-2" style="width:140px">Actions</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for e in envs %}
|
||||||
|
<tr>
|
||||||
|
<form method="post" action="/referentiel/envs/{{ e.id }}/edit">
|
||||||
|
<td class="px-2 py-2 text-xs text-gray-500">{{ e.id }}</td>
|
||||||
|
<td class="px-2 py-2"><input type="text" name="name" value="{{ e.name }}" style="width:100%" required></td>
|
||||||
|
<td class="px-2 py-2"><input type="text" name="code" value="{{ e.code }}" style="width:100%;text-transform:uppercase" required></td>
|
||||||
|
<td class="px-2 py-2 text-center">
|
||||||
|
<span class="badge badge-blue">{{ env_srv_counts.get(e.id, 0) }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-2 py-2 text-center" style="white-space:nowrap">
|
||||||
|
<button type="submit" class="btn-sm" style="background:#00d4ff22;color:#00d4ff;padding:3px 10px">Sauver</button>
|
||||||
|
</form>
|
||||||
|
{% if env_srv_counts.get(e.id, 0) == 0 %}
|
||||||
|
<form method="post" action="/referentiel/envs/{{ e.id }}/delete" style="display:inline"
|
||||||
|
onsubmit="return confirm('Supprimer l environnement {{ e.name }} ?')">
|
||||||
|
<button type="submit" class="btn-sm" style="background:#ff336622;color:#ff3366;padding:3px 10px">Suppr</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if can_modify %}
|
||||||
|
<div class="card mt-3" style="padding:12px 16px">
|
||||||
|
<h3 class="text-sm font-bold mb-2" style="color:#00ff88">Ajouter un environnement</h3>
|
||||||
|
<form method="post" action="/referentiel/envs/add" style="display:flex;gap:10px;align-items:end">
|
||||||
|
<div>
|
||||||
|
<label class="text-xs text-gray-500">Nom *</label>
|
||||||
|
<input type="text" name="name" required style="width:200px" placeholder="Production">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-xs text-gray-500">Code *</label>
|
||||||
|
<input type="text" name="code" required style="width:80px;text-transform:uppercase" placeholder="PRD">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn-primary" style="padding:6px 18px;font-size:0.85rem">Ajouter</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<!-- ONGLET ASSOCIATIONS -->
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
{% elif tab == 'assocs' %}
|
||||||
|
<div class="card">
|
||||||
|
<div class="table-wrap" style="max-height:60vh;overflow-y:auto">
|
||||||
|
<table class="table-cyber w-full">
|
||||||
|
<thead style="position:sticky;top:0;z-index:1"><tr>
|
||||||
|
<th class="px-2 py-2" style="width:40px">ID</th>
|
||||||
|
<th class="px-2 py-2">Domaine</th>
|
||||||
|
<th class="px-2 py-2">Environnement</th>
|
||||||
|
<th class="px-2 py-2">Responsable</th>
|
||||||
|
<th class="px-2 py-2">Email resp.</th>
|
||||||
|
<th class="px-2 py-2">Référent</th>
|
||||||
|
<th class="px-2 py-2">Email réf.</th>
|
||||||
|
<th class="px-2 py-2" style="width:60px">Actif</th>
|
||||||
|
<th class="px-2 py-2" style="width:60px">Srv</th>
|
||||||
|
<th class="px-2 py-2" style="width:140px">Actions</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for a in assocs %}
|
||||||
|
<tr>
|
||||||
|
<form method="post" action="/referentiel/assocs/{{ a.id }}/edit">
|
||||||
|
<td class="px-2 py-2 text-xs text-gray-500">{{ a.id }}</td>
|
||||||
|
<td class="px-2 py-2" style="color:#00d4ff;font-weight:600">{{ a.domain_name }}</td>
|
||||||
|
<td class="px-2 py-2" style="color:#a78bfa">{{ a.env_name }}</td>
|
||||||
|
<td class="px-2 py-2"><input type="text" name="responsable_nom" value="{{ a.responsable_nom or '' }}" style="width:100%"></td>
|
||||||
|
<td class="px-2 py-2"><input type="text" name="responsable_email" value="{{ a.responsable_email or '' }}" style="width:100%"></td>
|
||||||
|
<td class="px-2 py-2"><input type="text" name="referent_nom" value="{{ a.referent_nom or '' }}" style="width:100%"></td>
|
||||||
|
<td class="px-2 py-2"><input type="text" name="referent_email" value="{{ a.referent_email or '' }}" style="width:100%"></td>
|
||||||
|
<td class="px-2 py-2 text-center">
|
||||||
|
<input type="checkbox" name="is_active" {% if a.is_active %}checked{% endif %}>
|
||||||
|
</td>
|
||||||
|
<td class="px-2 py-2 text-center">
|
||||||
|
<span class="badge badge-blue">{{ a.nb_servers or 0 }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-2 py-2 text-center" style="white-space:nowrap">
|
||||||
|
<input type="hidden" name="patch_window" value="{{ a.patch_window or '' }}">
|
||||||
|
<input type="hidden" name="patch_excludes" value="{{ a.patch_excludes or '' }}">
|
||||||
|
<button type="submit" class="btn-sm" style="background:#00d4ff22;color:#00d4ff;padding:3px 10px">Sauver</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="/referentiel/assocs/{{ a.id }}/delete" style="display:inline"
|
||||||
|
onsubmit="return confirm('Supprimer {{ a.domain_name }} x {{ a.env_name }} ?')">
|
||||||
|
<button type="submit" class="btn-sm" style="background:#ff336622;color:#ff3366;padding:3px 10px">Suppr</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
{% if not assocs %}
|
||||||
|
<tr><td colspan="10" class="px-2 py-8 text-center text-gray-500">Aucune association</td></tr>
|
||||||
|
{% endif %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if can_modify %}
|
||||||
|
<div class="card mt-3" style="padding:12px 16px">
|
||||||
|
<h3 class="text-sm font-bold mb-2" style="color:#00ff88">Ajouter une association</h3>
|
||||||
|
<form method="post" action="/referentiel/assocs/add" style="display:flex;gap:10px;align-items:end;flex-wrap:wrap">
|
||||||
|
<div>
|
||||||
|
<label class="text-xs text-gray-500">Domaine *</label>
|
||||||
|
<select name="domain_id" required style="width:180px">
|
||||||
|
<option value="">-- Choisir --</option>
|
||||||
|
{% for d in domains %}<option value="{{ d.id }}">{{ d.name }} ({{ d.code }})</option>{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-xs text-gray-500">Environnement *</label>
|
||||||
|
<select name="environment_id" required style="width:180px">
|
||||||
|
<option value="">-- Choisir --</option>
|
||||||
|
{% for e in envs %}<option value="{{ e.id }}">{{ e.name }} ({{ e.code }})</option>{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-xs text-gray-500">Responsable</label>
|
||||||
|
<input type="text" name="responsable_nom" style="width:140px">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-xs text-gray-500">Email resp.</label>
|
||||||
|
<input type="email" name="responsable_email" style="width:180px">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-xs text-gray-500">Référent</label>
|
||||||
|
<input type="text" name="referent_nom" style="width:140px">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-xs text-gray-500">Email réf.</label>
|
||||||
|
<input type="email" name="referent_email" style="width:180px">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn-primary" style="padding:6px 18px;font-size:0.85rem">Ajouter</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<!-- ONGLET ZONES -->
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
{% elif tab == 'zones' %}
|
||||||
|
<div class="card">
|
||||||
|
<table class="table-cyber w-full">
|
||||||
|
<thead><tr>
|
||||||
|
<th class="px-2 py-2" style="width:40px">ID</th>
|
||||||
|
<th class="px-2 py-2">Nom</th>
|
||||||
|
<th class="px-2 py-2">Description</th>
|
||||||
|
<th class="px-2 py-2" style="width:70px">DMZ</th>
|
||||||
|
<th class="px-2 py-2" style="width:80px">Serveurs</th>
|
||||||
|
<th class="px-2 py-2" style="width:140px">Actions</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for z in zones %}
|
||||||
|
<tr>
|
||||||
|
<form method="post" action="/referentiel/zones/{{ z.id }}/edit">
|
||||||
|
<td class="px-2 py-2 text-xs text-gray-500">{{ z.id }}</td>
|
||||||
|
<td class="px-2 py-2"><input type="text" name="name" value="{{ z.name }}" style="width:100%" required></td>
|
||||||
|
<td class="px-2 py-2"><input type="text" name="description" value="{{ z.description or '' }}" style="width:100%"></td>
|
||||||
|
<td class="px-2 py-2 text-center">
|
||||||
|
<input type="checkbox" name="is_dmz" {% if z.is_dmz %}checked{% endif %}>
|
||||||
|
</td>
|
||||||
|
<td class="px-2 py-2 text-center">
|
||||||
|
<span class="badge badge-blue">{{ zone_srv_counts.get(z.id, 0) }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-2 py-2 text-center" style="white-space:nowrap">
|
||||||
|
<button type="submit" class="btn-sm" style="background:#00d4ff22;color:#00d4ff;padding:3px 10px">Sauver</button>
|
||||||
|
</form>
|
||||||
|
{% if zone_srv_counts.get(z.id, 0) == 0 %}
|
||||||
|
<form method="post" action="/referentiel/zones/{{ z.id }}/delete" style="display:inline"
|
||||||
|
onsubmit="return confirm('Supprimer la zone {{ z.name }} ?')">
|
||||||
|
<button type="submit" class="btn-sm" style="background:#ff336622;color:#ff3366;padding:3px 10px">Suppr</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if can_modify %}
|
||||||
|
<div class="card mt-3" style="padding:12px 16px">
|
||||||
|
<h3 class="text-sm font-bold mb-2" style="color:#00ff88">Ajouter une zone</h3>
|
||||||
|
<form method="post" action="/referentiel/zones/add" style="display:flex;gap:10px;align-items:end">
|
||||||
|
<div>
|
||||||
|
<label class="text-xs text-gray-500">Nom *</label>
|
||||||
|
<input type="text" name="name" required style="width:160px" placeholder="LAN">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-xs text-gray-500">Description</label>
|
||||||
|
<input type="text" name="description" style="width:250px">
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;align-items:center;gap:4px;padding-bottom:2px">
|
||||||
|
<input type="checkbox" name="is_dmz" id="new-dmz">
|
||||||
|
<label for="new-dmz" class="text-xs text-gray-400">DMZ</label>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn-primary" style="padding:6px 18px;font-size:0.85rem">Ajouter</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<!-- ONGLET DOMAINES DNS -->
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
{% elif tab == 'dns' %}
|
||||||
|
<div class="card">
|
||||||
|
<div class="p-3" style="border-bottom:1px solid #1e3a5f">
|
||||||
|
<p class="text-xs text-gray-500">Suffixes DNS utilisés dans le champ <code>domain_ltd</code> des serveurs (ex: sanef.groupe, sanef-rec.fr)</p>
|
||||||
|
</div>
|
||||||
|
<table class="table-cyber w-full">
|
||||||
|
<thead><tr>
|
||||||
|
<th class="px-2 py-2" style="width:40px">ID</th>
|
||||||
|
<th class="px-2 py-2">Nom (suffixe DNS)</th>
|
||||||
|
<th class="px-2 py-2">Description</th>
|
||||||
|
<th class="px-2 py-2" style="width:70px">Actif</th>
|
||||||
|
<th class="px-2 py-2" style="width:80px">Serveurs</th>
|
||||||
|
<th class="px-2 py-2" style="width:140px">Actions</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for d in dns_domains %}
|
||||||
|
<tr>
|
||||||
|
<form method="post" action="/referentiel/dns/{{ d.id }}/edit">
|
||||||
|
<td class="px-2 py-2 text-xs text-gray-500">{{ d.id }}</td>
|
||||||
|
<td class="px-2 py-2"><input type="text" name="name" value="{{ d.name }}" style="width:100%" required></td>
|
||||||
|
<td class="px-2 py-2"><input type="text" name="description" value="{{ d.description or '' }}" style="width:100%"></td>
|
||||||
|
<td class="px-2 py-2 text-center">
|
||||||
|
<input type="checkbox" name="is_active" {% if d.is_active %}checked{% endif %}>
|
||||||
|
</td>
|
||||||
|
<td class="px-2 py-2 text-center">
|
||||||
|
<span class="badge badge-blue">{{ dns_srv_counts.get(d.id, 0) }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-2 py-2 text-center" style="white-space:nowrap">
|
||||||
|
<button type="submit" class="btn-sm" style="background:#00d4ff22;color:#00d4ff;padding:3px 10px">Sauver</button>
|
||||||
|
</form>
|
||||||
|
{% if dns_srv_counts.get(d.id, 0) == 0 %}
|
||||||
|
<form method="post" action="/referentiel/dns/{{ d.id }}/delete" style="display:inline"
|
||||||
|
onsubmit="return confirm('Supprimer {{ d.name }} ?')">
|
||||||
|
<button type="submit" class="btn-sm" style="background:#ff336622;color:#ff3366;padding:3px 10px">Suppr</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if can_modify %}
|
||||||
|
<div class="card mt-3" style="padding:12px 16px">
|
||||||
|
<h3 class="text-sm font-bold mb-2" style="color:#00ff88">Ajouter un domaine DNS</h3>
|
||||||
|
<form method="post" action="/referentiel/dns/add" style="display:flex;gap:10px;align-items:end">
|
||||||
|
<div>
|
||||||
|
<label class="text-xs text-gray-500">Suffixe DNS *</label>
|
||||||
|
<input type="text" name="name" required style="width:200px" placeholder="sanef.groupe">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-xs text-gray-500">Description</label>
|
||||||
|
<input type="text" name="description" style="width:250px" placeholder="Domaine production SANEF">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn-primary" style="padding:6px 18px;font-size:0.85rem">Ajouter</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
@ -2,7 +2,7 @@
|
|||||||
{% block title %}Serveurs{% endblock %}
|
{% block title %}Serveurs{% endblock %}
|
||||||
|
|
||||||
{% macro sort_url(col) -%}
|
{% macro sort_url(col) -%}
|
||||||
?sort={{ col }}&sort_dir={% if sort == col and sort_dir == 'asc' %}desc{% else %}asc{% endif %}&search={{ filters.search or '' }}&domain={{ filters.domain or '' }}&env={{ filters.env or '' }}&tier={{ filters.tier or '' }}&etat={{ filters.etat or '' }}&page=1
|
?sort={{ col }}&sort_dir={% if sort == col and sort_dir == 'asc' %}desc{% else %}asc{% endif %}&search={{ filters.search or '' }}&domain={{ filters.domain or '' }}&env={{ filters.env or '' }}&tier={{ filters.tier or '' }}&etat={{ filters.etat or '' }}&os={{ filters.os or '' }}&owner={{ filters.owner or '' }}&page=1
|
||||||
{%- endmacro %}
|
{%- endmacro %}
|
||||||
|
|
||||||
{% macro sort_icon(col) -%}
|
{% macro sort_icon(col) -%}
|
||||||
@ -10,14 +10,14 @@
|
|||||||
{%- endmacro %}
|
{%- endmacro %}
|
||||||
|
|
||||||
{% macro qs(p) -%}
|
{% macro qs(p) -%}
|
||||||
?page={{ p }}&search={{ filters.search or '' }}&domain={{ filters.domain or '' }}&env={{ filters.env or '' }}&tier={{ filters.tier or '' }}&etat={{ filters.etat or '' }}&sort={{ sort }}&sort_dir={{ sort_dir }}
|
?page={{ p }}&search={{ filters.search or '' }}&domain={{ filters.domain or '' }}&env={{ filters.env or '' }}&tier={{ filters.tier or '' }}&etat={{ filters.etat or '' }}&os={{ filters.os or '' }}&owner={{ filters.owner or '' }}&sort={{ sort }}&sort_dir={{ sort_dir }}
|
||||||
{%- endmacro %}
|
{%- endmacro %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="flex justify-between items-center mb-4">
|
<div class="flex justify-between items-center mb-4">
|
||||||
<h2 class="text-xl font-bold text-cyber-accent">Serveurs <span class="text-sm text-gray-500">({{ total }})</span></h2>
|
<h2 class="text-xl font-bold text-cyber-accent">Serveurs <span class="text-sm text-gray-500">({{ total }})</span></h2>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<button class="btn-sm bg-cyber-green text-black" onclick="alert('Export bientot')">Export CSV</button>
|
<a href="/servers/export-csv?search={{ filters.search or '' }}&domain={{ filters.domain or '' }}&env={{ filters.env or '' }}&tier={{ filters.tier or '' }}&etat={{ filters.etat or '' }}&os={{ filters.os or '' }}&owner={{ filters.owner or '' }}" class="btn-sm bg-cyber-green text-black">Export CSV</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -38,6 +38,15 @@
|
|||||||
<select name="etat" onchange="this.form.submit()"><option value="">Etat</option>
|
<select name="etat" onchange="this.form.submit()"><option value="">Etat</option>
|
||||||
{% for e in ['en_production','en_implementation','en_decommissionnement','decommissionne','eteint','eol'] %}<option value="{{ e }}" {% if filters.etat == e %}selected{% endif %}>{{ e.replace("en_","En ").replace("_"," ").title() }}</option>{% endfor %}
|
{% for e in ['en_production','en_implementation','en_decommissionnement','decommissionne','eteint','eol'] %}<option value="{{ e }}" {% if filters.etat == e %}selected{% endif %}>{{ e.replace("en_","En ").replace("_"," ").title() }}</option>{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
|
<select name="os" onchange="this.form.submit()"><option value="">OS</option>
|
||||||
|
<option value="linux" {% if filters.os == 'linux' %}selected{% endif %}>Linux</option>
|
||||||
|
<option value="windows" {% if filters.os == 'windows' %}selected{% endif %}>Windows</option>
|
||||||
|
</select>
|
||||||
|
<select name="owner" onchange="this.form.submit()"><option value="">Owner</option>
|
||||||
|
<option value="secops" {% if filters.owner == 'secops' %}selected{% endif %}>secops</option>
|
||||||
|
<option value="ipop" {% if filters.owner == 'ipop' %}selected{% endif %}>ipop</option>
|
||||||
|
<option value="na" {% if filters.owner == 'na' %}selected{% endif %}>na</option>
|
||||||
|
</select>
|
||||||
<button type="submit" class="btn-primary px-3 py-1 text-sm">Filtrer</button>
|
<button type="submit" class="btn-primary px-3 py-1 text-sm">Filtrer</button>
|
||||||
<a href="/servers" class="text-xs text-gray-500 hover:text-gray-300">Reset</a>
|
<a href="/servers" class="text-xs text-gray-500 hover:text-gray-300">Reset</a>
|
||||||
</form>
|
</form>
|
||||||
@ -68,7 +77,7 @@ const bulkValues = {
|
|||||||
domain_code: [{% for d in domains_list %}{v:"{{ d.code }}", l:"{{ d.name }}"},{% endfor %}],
|
domain_code: [{% for d in domains_list %}{v:"{{ d.code }}", l:"{{ d.name }}"},{% endfor %}],
|
||||||
env_code: [{% for e in envs_list %}{v:"{{ e.code }}", l:"{{ e.name }}"},{% endfor %}],
|
env_code: [{% for e in envs_list %}{v:"{{ e.code }}", l:"{{ e.name }}"},{% endfor %}],
|
||||||
tier: [{v:"tier0",l:"tier0"},{v:"tier1",l:"tier1"},{v:"tier2",l:"tier2"},{v:"tier3",l:"tier3"}],
|
tier: [{v:"tier0",l:"tier0"},{v:"tier1",l:"tier1"},{v:"tier2",l:"tier2"},{v:"tier3",l:"tier3"}],
|
||||||
etat: [{v:"en_production",l:"en_production"},{v:"en_implementation",l:"en_implementation"},{v:"en_decommissionnement",l:"en_decommissionnement"},{v:"decommissionne",l:"décommissionné"}],
|
etat: [{v:"en_production",l:"En production"},{v:"en_implementation",l:"En implémentation"},{v:"en_decommissionnement",l:"En décommissionnement"},{v:"decommissionne",l:"Décommissionné"},{v:"eteint",l:"Éteint"},{v:"eol",l:"EOL"}],
|
||||||
patch_os_owner: [{v:"secops",l:"secops"},{v:"ipop",l:"ipop"},{v:"na",l:"na"}],
|
patch_os_owner: [{v:"secops",l:"secops"},{v:"ipop",l:"ipop"},{v:"na",l:"na"}],
|
||||||
licence_support: [{v:"active",l:"active"},{v:"eol",l:"eol"},{v:"els",l:"els"}],
|
licence_support: [{v:"active",l:"active"},{v:"eol",l:"eol"},{v:"els",l:"els"}],
|
||||||
};
|
};
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user